Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 }
|
return () => { cancelled = true }
|
||||||
}
|
}
|
||||||
void Promise.all([
|
const loadFlowCards = async () => {
|
||||||
getTrafficFlows(backendUrl, range),
|
// These reads share the collector's constrained admission budget.
|
||||||
getFlowExporters(backendUrl, range),
|
const stats = await getTrafficFlows(backendUrl, range)
|
||||||
getFlowClients(backendUrl, range),
|
if (cancelled) return
|
||||||
]).then(([stats, exporters, clients]) => {
|
const exporters = await getFlowExporters(backendUrl, range)
|
||||||
|
if (cancelled) return
|
||||||
|
const clients = await getFlowClients(backendUrl, range)
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setFlowResult({ key: flowKey, receivedAt: Date.now(), stats, exporters: exporters.exporters, clients: clients.clients })
|
setFlowResult({ key: flowKey, receivedAt: Date.now(), stats, exporters: exporters.exporters, clients: clients.clients })
|
||||||
setSelectedId(prev => {
|
setSelectedId(prev => {
|
||||||
const list = flowScope === "users" ? clients.clients : exporters.exporters
|
const list = flowScope === "users" ? clients.clients : exporters.exporters
|
||||||
return list.some(x => x.id === prev) ? prev : (list[0]?.id ?? "")
|
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 : "Не удалось загрузить потоки" })
|
if (!cancelled) setFlowResult({ key: flowKey, error: e instanceof Error ? e.message : "Не удалось загрузить потоки" })
|
||||||
})
|
})
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
|
|||||||
+10
-1
@@ -1,7 +1,16 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss" source(none);
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
@import "shadcn/tailwind.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. */
|
/* SVG focus follows the object shape, never the text/foreignObject bounding box. */
|
||||||
.network-map-focusable {
|
.network-map-focusable {
|
||||||
outline: none;
|
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": [],
|
"operations": [],
|
||||||
"reason": "Управление самим MCP, транспорт или машинная авторизация: отдельного AI-инструмента нет."
|
"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",
|
"method": "GET",
|
||||||
"path": "/api/mcp/drift",
|
"path": "/api/mcp/drift",
|
||||||
@@ -1611,6 +1618,13 @@
|
|||||||
"operations": [],
|
"operations": [],
|
||||||
"reason": "Управление самим MCP, транспорт или машинная авторизация: отдельного AI-инструмента нет."
|
"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",
|
"method": "POST",
|
||||||
"path": "/api/mcp/management-channels",
|
"path": "/api/mcp/management-channels",
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
"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: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:auth": "tsx --test src/modules/mcp/routing-manual-inverse-history.test.ts && tsx src/db/application-writer-profile-registry-structure.test.ts && tsx src/db/application-writer-profile-registry-read.test.ts && tsx src/db/application-writer-profile-registry-read-parity.test.ts && tsx src/db/application-writer-profile-format.test.ts && tsx src/plugins/auth-path.test.ts && tsx src/lib/permissions.test.ts && tsx src/plugins/auth-provenance.test.ts && tsx src/services/bandwidth-rest-speed-original.test.ts && tsx src/plugins/auth.smoke.test.ts && tsx src/plugins/auth.ospf-optimizer.test.ts && npm run test:mcp",
|
||||||
"test:mcp": "tsx --test src/services/server-poll-error-provenance.test.ts && tsx --test src/modules/mcp/plan-expiry.test.ts src/modules/mcp/result-budget.test.ts src/modules/mcp/result-delivery.test.ts src/modules/mcp/wire-admission.test.ts src/modules/mcp/flow-envelope-allowance.test.ts src/modules/mcp/sdk-terminal-budget.test.ts src/modules/mcp/inflight-owner.test.ts && npm run test:database-source-binding && tsx --test src/modules/mcp/integration-fixture-expiry.test.ts && npm run test:mcp:operation-origin && npm run test:mcp:service-context && npm run test:mcp:operation-locks && tsx --test src/modules/mcp/routing-community-public.test.ts src/modules/mcp/management-transport.test.ts src/modules/mcp/routing-community-context-model.test.ts src/modules/mcp/routing-community-context-native.test.ts ../packages/contracts/src/mcp-routing.test.ts && tsx src/modules/mcp/human-artifact-receipts.test.ts && tsx src/modules/mcp/chr-overlay-readiness.test.ts && tsx src/modules/mcp/chr-recovery-source.test.ts && tsx src/modules/mcp/chr-native-failure.test.ts && tsx src/modules/mcp/ospf-owner-graph.test.ts && tsx src/modules/mcp/routing-measurement-address.test.ts && tsx src/modules/mcp/routing-observation-source.test.ts && tsx src/services/traffic-flow-talkers-model.test.ts && tsx src/services/bgp-prefix-model.test.ts && tsx src/services/vrf-members.test.ts && tsx src/services/routing-gateway-reference.test.ts && tsx src/services/bgp-export-model.test.ts && tsx src/services/traffic-history-model.test.ts && tsx src/services/internet-path-snapshot.test.ts && tsx src/services/internet-path-observation.test.ts && tsx src/modules/mcp/coverage.test.ts && tsx src/modules/mcp/policy.test.ts && tsx src/modules/mcp/events-batch-model.test.ts && tsx src/modules/mcp/operation-revisions.test.ts && tsx src/modules/mcp/native-create-identity.test.ts && tsx src/modules/mcp/ros-step-verification.test.ts && tsx src/modules/mcp/ros-prefix.test.ts && tsx src/modules/mcp/native-conservation-http.test.ts && tsx src/modules/mcp/security.test.ts && tsx src/modules/mcp/state-file-publish.test.ts && tsx src/modules/mcp/routing-model.test.ts && tsx src/modules/mcp/routing-observations-model.test.ts && tsx src/modules/mcp/ospf-health.test.ts && tsx src/modules/mcp/ecmp-preflight.test.ts && tsx src/modules/mcp/recursive-recovery.test.ts && tsx src/modules/mcp/control-path-model.test.ts && tsx src/modules/mcp/routing-community-scoped-model.test.ts && tsx src/modules/mcp/routing-community-scoped-native.test.ts && tsx src/modules/mcp/path-verification.test.ts && tsx src/modules/mcp/database-maintenance.test.ts && tsx src/routes/mcp.test.ts && tsx ../lib/auth-mcp.test.ts && tsx --test src/modules/mcp/routing-controller-fence.test.ts && tsx src/test/chr-vrf-companion-proof.test.ts && tsx src/test/chr-vrf-companion-telemetry.test.ts && tsx src/test/chr-vrf-companion-address-space.test.ts && tsx src/modules/mcp/flow-facts-rebuild-actions.test.ts && tsx src/modules/mcp/ipsec-peer-create-dependencies.test.ts && tsx src/services/ipsec-certificate-update-model.test.ts && tsx src/modules/mcp/ipsec-native-dispatch.test.ts && tsx src/modules/mcp/ipsec-user-update-actions.test.ts && tsx src/modules/mcp/ipsec-server-delete-actions.test.ts && tsx src/services/ipsec-proof-telemetry.test.ts && tsx src/services/vrf-production-admission.test.ts && tsx src/modules/mcp/flow-host-keys-actions.test.ts && tsx src/services/vrf-production-read.test.ts && tsx src/modules/mcp/ipsec-user-create-actions.test.ts && tsx src/modules/mcp/flow-settings-actions.test.ts && tsx src/services/vrf-production-ticket-store.test.ts && npm run test:mcp:routing-baseline",
|
"test:mcp": "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: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-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: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/bindings.test.ts && tsx src/modules/users/interface-inventory.test.ts && tsx src/routes/users-catalog.test.ts",
|
||||||
|
|||||||
@@ -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,10 +6,14 @@ import { randomBytes, createHash } from 'node:crypto'
|
|||||||
import { createServer } from 'node:net'
|
import { createServer } from 'node:net'
|
||||||
import { spawn } from 'node:child_process'
|
import { spawn } from 'node:child_process'
|
||||||
const sourceRoot = resolve(process.argv[2] ?? fileURLToPath(new URL('../../../', import.meta.url)))
|
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',
|
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/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']
|
'backend/src/modules/mcp/portal-revocation.ts', 'backend/src/plugins/auth.ts', 'backend/src/index.ts', 'node_modules/@modelcontextprotocol/client/package.json', 'node_modules/@modelcontextprotocol/server/package.json']
|
||||||
|
if (scenario === 'bridge-vlan') paths.push('backend/scripts/mcp-external-process/bridge-vlan-client.mjs', 'backend/scripts/mcp-external-process/bridge-vlan-fixture.ts',
|
||||||
|
'backend/src/modules/mcp/bridge-vlan-actions.ts', 'backend/src/services/bridge-vlan-configuration.ts', 'packages/contracts/src/bridge-vlan.ts')
|
||||||
async function pins() { const result = []; for (const path of paths) result.push({ path, sha256: createHash('sha256').update(await readFile(resolve(sourceRoot, path))).digest('hex') }); return result }
|
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 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))
|
const listener = createServer(); await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve)); const port = listener.address().port; await new Promise(resolve => listener.close(resolve))
|
||||||
@@ -19,7 +23,7 @@ const postgres = new EmbeddedPostgres({ databaseDir: join(directory, 'pg'), port
|
|||||||
try {
|
try {
|
||||||
await postgres.initialise(); await postgres.start()
|
await postgres.initialise(); await postgres.start()
|
||||||
process.exitCode = await new Promise((done, reject) => {
|
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]])),
|
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'),
|
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' } })
|
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 assert from "node:assert/strict"
|
||||||
|
import { createHash } from "node:crypto"
|
||||||
import { spawn } from "node:child_process"
|
import { spawn } from "node:child_process"
|
||||||
import { createServer } from "node:net"
|
import { createServer } from "node:net"
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||||
@@ -49,6 +50,61 @@ try {
|
|||||||
await updateMcpSettings({ enabled: true }, actor)
|
await updateMcpSettings({ enabled: true }, actor)
|
||||||
app = await buildApp({ startScheduler: false, logger: false }); await app.listen({ port, host: "127.0.0.1" })
|
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 jwt = await new SignJWT({ sub: actor.id, apps: actor.apps, permissions: actor.permissions }).setProtectedHeader({ alg: "HS256" }).setIssuer(env.authIssuer).setIssuedAt().setExpirationTime("1h").sign(new TextEncoder().encode(env.jwtSecret))
|
||||||
|
const portalHeaders = { authorization: `Bearer ${jwt}`, "content-type": "application/json" }
|
||||||
|
assert.equal((await pool.query("SELECT count(*)::int AS count FROM mcp_connections WHERE owner_id=$1", [actor.id])).rows[0].count, 0)
|
||||||
|
const callback = "http://localhost:45678/callback?registered=1"
|
||||||
|
const registration = await fetch(origin + "/api/mcp/oauth/clients", { method: "POST", headers: portalHeaders,
|
||||||
|
body: JSON.stringify({ name: "Owned denial fixture", redirectUris: [callback] }) })
|
||||||
|
assert.equal(registration.status, 200)
|
||||||
|
const { clientId } = await registration.json() as { clientId: string }
|
||||||
|
const consent = (payload: Record<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
|
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"],
|
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]!])),
|
env: { ...Object.fromEntries(["SystemRoot", "WINDIR", "TEMP", "TMP", "PATH", "Path"].filter(k => process.env[k]).map(k => [k, process.env[k]!])),
|
||||||
@@ -90,7 +146,8 @@ try {
|
|||||||
const logs = JSON.stringify((await pool.query("SELECT tool,input,result,error_code FROM mcp_requests ORDER BY created_at")).rows)
|
const logs = JSON.stringify((await pool.query("SELECT tool,input,result,error_code FROM mcp_requests ORDER BY created_at")).rows)
|
||||||
for (const token of [...credentials, jwt, secret]) { assert(!logs.includes(token)); assert(!stdout.includes(token)); assert(!stderr.includes(token)) }
|
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,
|
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))
|
await writeFile(process.env.OWN_EXTERNAL_RESULT_FILE!, JSON.stringify(final, null, 2))
|
||||||
console.log("Owned external process SDK result:", JSON.stringify(final))
|
console.log("Owned external process SDK result:", JSON.stringify(final))
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -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 (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
|
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]
|
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.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 (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 }
|
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 = []) {
|
export function vrfSliceDiagnostic(result, log, childBudgetMs, privateValues = []) {
|
||||||
assert(result && typeof result === 'object')
|
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.code) && typeof result.childExitConfirmed === 'boolean')
|
||||||
assert(Number.isSafeInteger(result.caseCount) && result.caseCount >= 0)
|
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.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))
|
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 }
|
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 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 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]]))
|
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
|
currentSlice = slice; currentChildExitConfirmed = false; lastSliceResult = null
|
||||||
await persist(slice + '-started.json', { version: 1, slice, status: 'running', accepted: false, childExitConfirmed: false })
|
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)
|
const cwd = join(owned, 'vrf-production-' + slice), rel = relative(owned, cwd)
|
||||||
assert(rel && !rel.startsWith('..') && !isAbsolute(rel)); await mkdir(cwd)
|
assert(rel && !rel.startsWith('..') && !isAbsolute(rel)); await mkdir(cwd)
|
||||||
assert.equal(await realpath(cwd), cwd); assert(!(await lstat(cwd)).isSymbolicLink())
|
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)
|
unknownTimer = setTimeout(() => { if (!exited) { stop('child-exit-unconfirmed'); resolveExit(1) } }, childBudgetMs + 15000)
|
||||||
})
|
})
|
||||||
clearTimeout(deadline); clearTimeout(stopTimer); clearTimeout(unknownTimer)
|
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))
|
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 (!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 }
|
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
|
lastSliceResult = result; currentChildExitConfirmed = exited
|
||||||
const diagnostic = vrfSliceDiagnostic(result, log, childBudgetMs, [databaseUrl, target.password, decodeURIComponent(target.password), authSecret])
|
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 }))
|
console.log(JSON.stringify({ profile: 'test:vrf:production', ...result }))
|
||||||
if (!result.accepted) break
|
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 }
|
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: 68, completedCases: results.reduce((sum, value) => sum + value.caseCount, 0), diagnosticDirectory: diagnosticPath })
|
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))
|
await writeFile(join(owned, 'vrf-production-acceptance.json'), JSON.stringify(receipt, null, 2))
|
||||||
return receipt
|
return receipt
|
||||||
} catch {
|
} 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)
|
||||||
|
})
|
||||||
@@ -12,10 +12,10 @@ const reviewed = (source: string, extra: Partial<MigrationReview> = {}) => {
|
|||||||
const bytes = Buffer.from(source), file = "9000_fixture.sql"
|
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 })
|
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", () => {
|
await test("all 56 exact file pins and every top-level schema operation are inventoried", () => {
|
||||||
assert.equal(inventory.length, 55); assert.equal(MIGRATION_MANIFEST.length, 55)
|
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 === "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))
|
assert(inventory.every(file => file.operations.length === file.statementCount && file.effects.length > 0))
|
||||||
let tables = new Set<string>()
|
let tables = new Set<string>()
|
||||||
for (const file of inventory) {
|
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.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"])
|
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", () => {
|
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"])
|
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) }
|
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)
|
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"))
|
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`)
|
console.log(`migration-manifest: ${cases} inert parser/inventory/baseline scenarios passed; ${inventory.length} exact files`)
|
||||||
@@ -854,6 +854,20 @@ const classifiedMigrationPins: readonly MigrationReview[] = [
|
|||||||
"effects": [
|
"effects": [
|
||||||
"Replaces the ordinary claim guard function without changing backup settings, metadata or entries during migration."
|
"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."
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { ConnectionRow, McpPrincipal } from "./store.js"
|
|||||||
import type { RosSnapshot, RosStep } from "./resources.js"
|
import type { RosSnapshot, RosStep } from "./resources.js"
|
||||||
|
|
||||||
export interface ActionContext {
|
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. */
|
/** Process-internal persistent controller identity; never parsed from tool JSON. */
|
||||||
persistentRoutingProfile?: { id: string; version: number }
|
persistentRoutingProfile?: { id: string; version: number }
|
||||||
/** Available only during target resolution, never retained by the read/prepare handler. */
|
/** Available only during target resolution, never retained by the read/prepare handler. */
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { registerIpsecExistingCertificateExportActions } from "./ipsec-existing-
|
|||||||
import { registerIpsecUserUpdateActions } from "./ipsec-user-update-actions.js"
|
import { registerIpsecUserUpdateActions } from "./ipsec-user-update-actions.js"
|
||||||
import { prepareIpsecNativeWrite } from "./ipsec-native-write-actions.js"
|
import { prepareIpsecNativeWrite } from "./ipsec-native-write-actions.js"
|
||||||
import { isIP } from "node:net"
|
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 { z } from "zod"
|
||||||
import { logFiltersSchema, logSettingsPatchSchema } from "@mmapp/contracts"
|
import { logFiltersSchema, logSettingsPatchSchema } from "@mmapp/contracts"
|
||||||
import { appUserCreateSchema, appUserUpdateSchema, appUserRoleSchema, sectionPermSchema, serverPermSchema, userBindingCreateSchema } from "@mmapp/contracts/users"
|
import { appUserCreateSchema, appUserUpdateSchema, appUserRoleSchema, sectionPermSchema, serverPermSchema, userBindingCreateSchema } from "@mmapp/contracts/users"
|
||||||
@@ -17,7 +19,7 @@ import { hasPermission } from "../../lib/permissions.js"
|
|||||||
import { assertOperationReplayScope, findOperationReplayTombstone, operationHistoryExpired } from "./operation-replay-tombstones.js"
|
import { assertOperationReplayScope, findOperationReplayTombstone, operationHistoryExpired } from "./operation-replay-tombstones.js"
|
||||||
import { pool } from "../../db/index.js"
|
import { pool } from "../../db/index.js"
|
||||||
import { getServerReadById } from "../servers/service/servers-service.js"
|
import { getServerReadById } from "../servers/service/servers-service.js"
|
||||||
import { listUsers, getUserById, listInterfaceCatalog, getInterfaceCatalogState } from "../users/service/users-service.js"
|
import { listUsers, getUserById, listInterfaceCatalog, getInterfaceCatalogState, validateUserBindingCreate, UsersServiceError } from "../users/service/users-service.js"
|
||||||
import { getLogSettings } from "../../services/router-logs-collector.js"
|
import { getLogSettings } from "../../services/router-logs-collector.js"
|
||||||
import { readLogPage, readLogSummary, readLogContext, readLogEntry } from "../../services/router-logs-query.js"
|
import { readLogPage, readLogSummary, readLogContext, readLogEntry } from "../../services/router-logs-query.js"
|
||||||
import { logWhere } from "../../services/router-logs-model.js"
|
import { logWhere } from "../../services/router-logs-model.js"
|
||||||
@@ -104,6 +106,7 @@ import { registerRecursiveRecovery } from "./recursive-recovery.js"
|
|||||||
import { registerNetworkHostActions } from "./network-host-actions.js"
|
import { registerNetworkHostActions } from "./network-host-actions.js"
|
||||||
import { registerBgpFilterBindingActions } from "./bgp-filter-binding-actions.js"
|
import { registerBgpFilterBindingActions } from "./bgp-filter-binding-actions.js"
|
||||||
import { registerBgpCommunityPolicyActions } from "./bgp-community-policy-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 serverId = z.number().int().positive().max(Number.MAX_SAFE_INTEGER)
|
||||||
const rowId = z.string().trim().min(1).max(200)
|
const rowId = z.string().trim().min(1).max(200)
|
||||||
@@ -127,6 +130,7 @@ registerRecursiveRecovery(add)
|
|||||||
registerNetworkHostActions(add)
|
registerNetworkHostActions(add)
|
||||||
registerBgpFilterBindingActions(add)
|
registerBgpFilterBindingActions(add)
|
||||||
registerBgpCommunityPolicyActions(add)
|
registerBgpCommunityPolicyActions(add)
|
||||||
|
registerBridgeVlanActions(add)
|
||||||
registerTrafficHistoryActions(add)
|
registerTrafficHistoryActions(add)
|
||||||
registerSidebarCountsActions(add)
|
registerSidebarCountsActions(add)
|
||||||
registerLogArtifactActions(add)
|
registerLogArtifactActions(add)
|
||||||
@@ -154,7 +158,7 @@ for (const resource of rosResources) {
|
|||||||
const delegated = resource.id === "wireguard" && action !== "create" || resource.id === "wireguard_peer" || resource.id === "gre" && action !== "create" || resource.id === "ipsec_peer" && action !== "create" || resource.id === "certificate" && action === "delete" || resource.id === "container" && action === "delete"
|
const 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" ? 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, {
|
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) => {
|
prepare: async (input, context) => {
|
||||||
if (resource.id === "vrf" && input.productionAdmission !== true) throw new McpError("UNSUPPORTED_CAPABILITY", "VRF требует явного productionAdmission:true для проверенного native executor; raw план недоступен", 409)
|
if (resource.id === "vrf" && input.productionAdmission !== true) throw new McpError("UNSUPPORTED_CAPABILITY", "VRF требует явного productionAdmission:true для проверенного native executor; raw план недоступен", 409)
|
||||||
const sid = Number(input.serverId), id = input.id as string | undefined
|
const sid = Number(input.serverId), id = input.id as string | undefined
|
||||||
@@ -250,13 +254,24 @@ for (const resource of rosResources) {
|
|||||||
if (dependent) throw new McpError("STATE_CONFLICT", "Объект используется: сначала подготовьте явное изменение зависимостей", 409)
|
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
|
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)
|
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,
|
return { target: { scope: "servers", serverIds: [sid] }, objects: references, snapshots,
|
||||||
...(resource.id === "vrf" && input.productionAdmission === true ? { vrfProductionAdmission: true as const } : {}),
|
...(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 })),
|
steps: splitOspfPassiveUpdate({ serverId: sid, resource: resource.id, action, id, fields, ...(typeof placeBeforeId === "string" ? { placeBeforeId } : {}) }).map(step => ({ kind: "ros" as const, step })),
|
||||||
requiresHuman: true,
|
requiresHuman: true,
|
||||||
warnings: ["Нативное изменение может затронуть управление устройством и требует подтверждения человеком.", "Автоматическое каскадное изменение зависимостей не выполняется.", ...(placeBeforeId ? [`VRF создаётся перед записью ${placeBeforeId}; порядок определяет принадлежность интерфейсов.`] : [])],
|
warnings: ["Нативное изменение может затронуть управление устройством и требует подтверждения человеком.", "Автоматическое каскадное изменение зависимостей не выполняется.", ...(resource.id === "vrf" && action === "delete" ? ["При удалении VRF RouterOS может удалить автоматический IPv6 link-local и его connected-маршрут пустого bridge; статические адреса сохраняются."] : []), ...(placeBeforeId ? [`VRF создаётся перед записью ${placeBeforeId}; порядок определяет принадлежность интерфейсов.`] : [])],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -268,7 +283,7 @@ for (const definition of [
|
|||||||
{ id: "firewall.update", tool: "mm_firewall_rule_update_plan", title: "План изменения правила firewall", resources: ["ip_filter", "ip_nat", "ip_mangle", "ip_raw", "ipv6_filter", "ipv6_nat", "ipv6_mangle", "ipv6_raw"] },
|
{ 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) {
|
] 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(), {
|
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) => {
|
prepare: async (input, context) => {
|
||||||
const resource = resourceById(String(input.resource)), fields = resource.fields.safeParse(input.fields)
|
const resource = resourceById(String(input.resource)), fields = resource.fields.safeParse(input.fields)
|
||||||
if (!fields.success) throw new McpError("INVALID_INPUT", "Поля не соответствуют типизированной схеме выбранного объекта", 400)
|
if (!fields.success) throw new McpError("INVALID_INPUT", "Поля не соответствуют типизированной схеме выбранного объекта", 400)
|
||||||
@@ -322,6 +337,21 @@ for (const action of ["create", "update", "delete", "binding_add", "binding_remo
|
|||||||
const ids = [...new Set([...(user?.servers ?? []).map(server => Number(server.serverId)), ...(binding?.serverId ? [binding.serverId] : []),
|
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))])]
|
...(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 (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,
|
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-права портала."] }
|
steps: [{ kind: "service", service: `users.${action}`, input }], warnings: ["Изменение /users не меняет JWT-права портала."] }
|
||||||
},
|
},
|
||||||
@@ -350,16 +380,17 @@ function logFilters(input: Record<string, unknown>, context: ActionContext) {
|
|||||||
}
|
}
|
||||||
add("logs.query", "mm_logs_query", "История логов", "logs", "mm:servers:read", "read", "servers", logInput, {
|
add("logs.query", "mm_logs_query", "История логов", "logs", "mm:servers:read", "read", "servers", logInput, {
|
||||||
read: async (input, context) => {
|
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
|
let cursor: string | undefined, watermark: string | undefined
|
||||||
if (input.cursor) {
|
if (input.cursor) {
|
||||||
try {
|
try {
|
||||||
const state = JSON.parse(await decryptSecret(String(input.cursor))) as { generation: string; scope: string; cursor: string; watermark: string; from: string }
|
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")
|
if (state.generation !== generation || state.scope !== fingerprint({ filters, connection: context.connection.id })) throw new Error("cursor")
|
||||||
cursor = state.cursor; watermark = state.watermark
|
cursor = state.cursor; watermark = state.watermark
|
||||||
} catch { throw new McpError("RESET_REQUIRED", "Курсор устарел или относится к другим фильтрам", 409) }
|
} catch { throw new McpError("RESET_REQUIRED", "Курсор устарел или относится к другим фильтрам", 409) }
|
||||||
}
|
} else filters = logFilters(input, context)
|
||||||
const result = await readLogPage(filters, { cursor, watermark, limit: Number(input.limit) })
|
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 }
|
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 +405,41 @@ for (const context of [false, true]) add(context ? "logs.context" : "logs.get",
|
|||||||
return context ? readLogContext(String(input.id)) : entry
|
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) => {
|
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
|
const { server } = await routerClient(Number(input.serverId)); void server
|
||||||
|
assertActive()
|
||||||
const release = subscribeLogDevice(Number(input.serverId)), until = Date.now() + Number(input.waitSeconds) * 1000
|
const release = subscribeLogDevice(Number(input.serverId)), until = Date.now() + Number(input.waitSeconds) * 1000
|
||||||
try {
|
try {
|
||||||
const filters = logFilters(input, context), generation = await accessGeneration()
|
const generation = await accessGeneration()
|
||||||
|
let filters: ReturnType<typeof logFilters>
|
||||||
let cursor = "0"
|
let cursor = "0"
|
||||||
if (input.cursor) {
|
if (input.cursor) {
|
||||||
try {
|
try {
|
||||||
const saved = JSON.parse(await decryptSecret(String(input.cursor)))
|
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")
|
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
|
cursor = saved.cursor
|
||||||
} catch { throw new McpError("RESET_REQUIRED", "Курсор follow устарел или относится к другим фильтрам", 409) }
|
} catch { throw new McpError("RESET_REQUIRED", "Курсор follow устарел или относится к другим фильтрам", 409) }
|
||||||
} else {
|
} 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
|
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) {
|
while (true) {
|
||||||
|
assertActive()
|
||||||
const current = await validatePrincipal(context.principal)
|
const current = await validatePrincipal(context.principal)
|
||||||
|
assertActive()
|
||||||
if (current.policy.version !== context.connection.policy.version) throw new McpError("RESET_REQUIRED", "Права или область доступа изменились; начните новое чтение", 409)
|
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
|
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 })) }
|
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 new Promise(resolve => setTimeout(resolve, 1000))
|
await sleep(1000, undefined, { signal: waitSignal })
|
||||||
}
|
}
|
||||||
} finally { release() }
|
} finally { release() }
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ try{
|
|||||||
await pool.query("INSERT INTO backup_storage_settings(id) VALUES(1) ON CONFLICT(id) DO NOTHING")
|
await pool.query("INSERT INTO backup_storage_settings(id) VALUES(1) ON CONFLICT(id) DO NOTHING")
|
||||||
await updateMcpSettings({enabled:true},actor)
|
await updateMcpSettings({enabled:true},actor)
|
||||||
app=await buildApp({startScheduler:false});await app.ready()
|
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("./routing-community-context-acceptance.js")).acceptRouteCommunityContextMcp(actor)
|
||||||
await (await import("../../test/mcp-acme-test.js")).verifyMcpAcmeTest(actor)
|
await (await import("../../test/mcp-acme-test.js")).verifyMcpAcmeTest(actor)
|
||||||
await (await import("../../test/mcp-acme-settings.js")).verifyMcpAcmeSettings(actor)
|
await (await import("../../test/mcp-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,6 +1,9 @@
|
|||||||
import assert from "node:assert/strict"
|
import assert from "node:assert/strict"
|
||||||
import { createHash, randomUUID } from "node:crypto"
|
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"
|
import { fingerprint, redact } from "./security.js"
|
||||||
|
|
||||||
const proof = async (value: unknown) => createHash("sha256").update(fingerprint(value)).digest("hex")
|
const proof = async (value: unknown) => createHash("sha256").update(fingerprint(value)).digest("hex")
|
||||||
@@ -25,6 +28,7 @@ function fixture() {
|
|||||||
return { rows }
|
return { rows }
|
||||||
} }
|
} }
|
||||||
return { load: () => load(client, operationId, owner), read: () => read(snapshot, operationId, owner), readSql,
|
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 }
|
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.tool, "bandwidth"); assert.equal(source.stageBudget, 1)
|
||||||
assert.equal(source.originalContext.identity.credentialId, f.credentialId)
|
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 type { ServerProbeJournalContext } from "../../services/server-probe-journal.js"
|
||||||
import { SERVER_PROBE_OPERATION } from "../../services/server-probe-authority.js"
|
import { SERVER_PROBE_OPERATION } from "../../services/server-probe-authority.js"
|
||||||
import { openServerProbeMcpBinding } from "./server-probe-actions.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 MAX_BYTES = 2097152
|
||||||
const uuid = z.string().uuid()
|
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. */
|
/** Test-only crypto seam. Never accept these dependencies from tool JSON. */
|
||||||
export const createDiagnosticCancellationSourceForTests = sourceLoader
|
export const createDiagnosticCancellationSourceForTests = sourceLoader
|
||||||
/** Historical authentication only; confers no execution/cancellation grant. */
|
/** 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
|
export const createReadServerProbeExecutionSourceForTests = readSourceLoader
|
||||||
/** Exact previously locked source projection for private history conversion. */
|
/** Exact previously locked source projection for private history conversion. */
|
||||||
export function authenticateServerProbeExecutionSourceRow(row: Record<string, unknown>, operationId: string,
|
export function authenticateServerProbeExecutionSourceRow(row: Record<string, unknown>, operationId: string,
|
||||||
|
|||||||
@@ -2889,6 +2889,53 @@ try {
|
|||||||
assert.ok(!result.isError)
|
assert.ok(!result.isError)
|
||||||
console.log("✓ Official SDK HTTP initialize, filtered tools/list and tools/call")
|
console.log("✓ Official SDK HTTP initialize, filtered tools/list and tools/call")
|
||||||
} finally { await client.close() }
|
} 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() }
|
||||||
|
}
|
||||||
|
await (await import("./logs-follow-sdk-cancellation-acceptance.js")).verifyLogsFollowSdkCancellation(app, endpoint, actor)
|
||||||
const stdio = new Client({ name: "stdio-integration", version: "1.0.0" })
|
const stdio = new Client({ name: "stdio-integration", version: "1.0.0" })
|
||||||
try {
|
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" }))
|
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 +2954,41 @@ try {
|
|||||||
await (await import("../../test/mcp-host-files.js")).verifyMcpHostFiles(actor,app)
|
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 readinessAccess = await createMcpConnection(mcpConnectionCreateSchema.parse({ ...input, name: "Actual readiness handshake", scope: { ...input.scope, operationIds: ["servers.list"] } }), actor)
|
||||||
const readinessHeaders = { authorization: "Bearer " + jwt }
|
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: {} })
|
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.statusCode, 200, ready.body)
|
||||||
assert.equal(ready.json().ok, true, ready.body); assert.equal(ready.json().checks.protocolHandshake, true)
|
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.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_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)
|
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: {} })
|
const duplicateReady = await app.inject({ method: "POST", url: `/api/mcp/connections/${readinessAccess.connection.id}/check`, headers: readinessHeaders, payload: {} })
|
||||||
assert.equal(duplicateReady.statusCode, 429)
|
assert.equal(duplicateReady.statusCode, 429)
|
||||||
await setConnectionStatus(readinessAccess.connection.id, "paused", actor)
|
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: {} })
|
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")
|
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 emptyGrantAccess = await createMcpConnection(mcpConnectionCreateSchema.parse({ ...input, name: "Readiness has no operation authority" }), actor)
|
||||||
const { withMcpProbeCredential } = await import("./readiness.js")
|
const { withMcpProbeCredential } = await import("./readiness.js")
|
||||||
let endedProbeToken = ""
|
let endedProbeToken = ""
|
||||||
@@ -2933,7 +3004,7 @@ try {
|
|||||||
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_grants WHERE connection_id=$1", [emptyGrantAccess.connection.id])).rows[0].n, 0)
|
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
|
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))
|
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 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 deviceHeaders = { authorization: "Bearer " + jwt }
|
||||||
const deviceCatalog = await app.inject({ url: "/api/mcp/devices/catalog?connectionId=" + deviceConnection.connection.id, headers: deviceHeaders })
|
const deviceCatalog = await app.inject({ url: "/api/mcp/devices/catalog?connectionId=" + deviceConnection.connection.id, headers: deviceHeaders })
|
||||||
|
|||||||
@@ -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,143 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { randomUUID } from "node:crypto"
|
||||||
|
import type { IncomingMessage, ServerResponse } from "node:http"
|
||||||
|
import { setTimeout as delay } from "node:timers/promises"
|
||||||
|
import type { FastifyInstance } from "fastify"
|
||||||
|
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"
|
||||||
|
import { mcpConnectionCreateSchema } from "@mmapp/contracts/mcp"
|
||||||
|
import { pool } from "../../db/index.js"
|
||||||
|
import type { AuthUser } from "../../lib/permissions.js"
|
||||||
|
import { createMcpConnection } from "./store.js"
|
||||||
|
|
||||||
|
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" }
|
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])
|
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)
|
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
|
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)
|
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])
|
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)
|
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[]) {
|
export async function managementEvidence(serverIds: number[]) {
|
||||||
try { return { requiresHuman: false, managementChannels: await Promise.all([...new Set(serverIds)].map(serverId => verifyManagementChannel(serverId))) } }
|
try { return { requiresHuman: false, managementChannels: await Promise.all([...new Set(serverIds)].map(serverId => verifyManagementChannel(serverId))) } }
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const creates = rosResources.filter(resource => !resource.readOnly && resource.c
|
|||||||
assert.equal(creates.length, 30)
|
assert.equal(creates.length, 30)
|
||||||
for (const resource of creates) {
|
for (const resource of creates) {
|
||||||
const action = findAction(`mm_${resource.id}_create_plan`)
|
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)
|
assert.equal(action.schema.safeParse({ serverId: 1, fields: {} }).success, true)
|
||||||
for (const id of ["*OLD", undefined, null, ""]) {
|
for (const id of ["*OLD", undefined, null, ""]) {
|
||||||
const rejected = action.schema.safeParse({ serverId: 1, fields: {}, id })
|
const rejected = action.schema.safeParse({ serverId: 1, fields: {}, id })
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ assert.throws(() => action.schema.parse({ serverId: 1, expectedServerProof: "for
|
|||||||
assert.throws(() => action.schema.parse({ serverId: 1, version: 1 }))
|
assert.throws(() => action.schema.parse({ serverId: 1, version: 1 }))
|
||||||
assert.equal(action.schema.parse({ serverId: 1 }).family, "ipv4")
|
assert.equal(action.schema.parse({ serverId: 1 }).family, "ipv4")
|
||||||
assert.equal(findAction("mm_routing_measurement_plan").operation.version, 2)
|
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_create_plan").operation.version, 5)
|
||||||
assert.equal(findAction("mm_ospf_template_update_plan").operation.version, 4)
|
assert.equal(findAction("mm_ospf_template_update_plan").operation.version, 4)
|
||||||
assert.equal(findAction("mm_ospf_template_delete_plan").operation.version, 3)
|
assert.equal(findAction("mm_ospf_template_delete_plan").operation.version, 3)
|
||||||
@@ -49,11 +49,11 @@ assert.equal(findAction("mm_wireguard_update_plan").operation.version, 3)
|
|||||||
assert.equal(findAction("mm_wireguard_interface_edit_plan").operation.version, 3)
|
assert.equal(findAction("mm_wireguard_interface_edit_plan").operation.version, 3)
|
||||||
assert.equal(findAction("mm_wireguard_interface_create_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_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_delete_plan").operation.version, 3)
|
||||||
assert.equal(findAction("mm_ipsec_peer_update_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)
|
assert.equal(findAction("mm_certificate_delete_plan").operation.version, 3)
|
||||||
for (const [tool, version] of [["mm_route_optimizer_plan", 7], ["mm_route_profile_save_plan", 4], ["mm_route_path_verify", 2], ["mm_operation_recovery_plan", 7], ["mm_ecmp_plan", 2], ["mm_rpf_plan", 2], ["mm_vrf_plan", 3], ["mm_firewall_rule_update_plan", 2]] as const) {
|
for (const [tool, version] of [["mm_route_optimizer_plan", 7], ["mm_route_profile_save_plan", 4], ["mm_route_path_verify", 2], ["mm_operation_recovery_plan", 8], ["mm_ecmp_plan", 2], ["mm_rpf_plan", 2], ["mm_vrf_plan", 4], ["mm_firewall_rule_update_plan", 2]] as const) {
|
||||||
const routed = findAction(tool)
|
const routed = findAction(tool)
|
||||||
assert.equal(routed.operation.version, version)
|
assert.equal(routed.operation.version, version)
|
||||||
assert.equal(findMcpOperation(routed.operation.id)?.version, version)
|
assert.equal(findMcpOperation(routed.operation.id)?.version, version)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict"
|
|||||||
import { createOspfOwnerSourceGuard, verifyOspfOwnerGraph, verifyOspfOwnerGraphAtPhase } from "./ospf-owner-graph.js"
|
import { createOspfOwnerSourceGuard, verifyOspfOwnerGraph, verifyOspfOwnerGraphAtPhase } from "./ospf-owner-graph.js"
|
||||||
import { fingerprint, McpError, redact } from "./security.js"
|
import { fingerprint, McpError, redact } from "./security.js"
|
||||||
import { createRosPrefix } from "./ros-prefix.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 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" }) })) }
|
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()
|
await sourceGuard()
|
||||||
}
|
}
|
||||||
const catalogRows = [{ ".id": "*A", name: "keep", disabled: "false" }]
|
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 = []
|
reads = []
|
||||||
const catalogPrefix = createRosPrefix([catalog], async (_id, _resource, beforeRead = async () => {}) => {
|
const catalogPrefix = createRosPrefix([catalog], async (_id, _resource, beforeRead = async () => {}) => {
|
||||||
// Match readRosSnapshot's normal three grant/source fences without polling a
|
// 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 const pathProofPurposeSchema = z.enum(["community", "control-path"])
|
||||||
export type PathProofPurpose = z.infer<typeof pathProofPurposeSchema>
|
export type PathProofPurpose = z.infer<typeof pathProofPurposeSchema>
|
||||||
export const PATH_PROOF_VERSION = 2
|
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 const pathProofTag = (purpose: PathProofPurpose) => "path-check:" + purpose + ":v" + PATH_PROOF_VERSION + ":"
|
||||||
export function isCommunityPathEvidence(value: Record<string, unknown>) {
|
export function isCommunityPathEvidence(value: Record<string, unknown>) {
|
||||||
return value.proofPurpose === "community" && value.proofVersion === PATH_PROOF_VERSION
|
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 { readRouteCommunityContext, routeContextAccess, type RouteContextOptions } from "./routing-community-context.js"
|
||||||
import type { add } from "./actions.js"
|
import type { add } from "./actions.js"
|
||||||
import { readControlPathContext } from "./control-path-context.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]) {
|
export function inspectControlPing(rows: Parameters<typeof parsePing>[0]) {
|
||||||
const parsed = parsePing(rows)
|
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 = {}) {
|
export async function requireRecentPath(profile: RouteAiProfile, candidateId: string, connectionId: string, options: RouteContextOptions = {}) {
|
||||||
// Compare the database timestamp with the database clock. The signed
|
// Compare the database timestamp with the database clock. The signed
|
||||||
// observedAt below is independently checked against the application clock.
|
// 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)
|
if (!row || row.status !== "passed" || row.database_fresh !== true) throw new McpError("MEASUREMENT_UNKNOWN", "Нет свежего подтверждения контрольного пути", 409)
|
||||||
const { proof, ...evidence } = row.evidence ?? {}
|
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 })
|
const current = await readRouteCommunityContext(profile, { ...options, selectedCandidateId: candidateId, expectedSources: evidence.sourceProofs })
|
||||||
if (!current.verifyEvidence(evidence, proof)) throw new McpError("MEASUREMENT_UNKNOWN", "Зависимости пути или сохранённое подтверждение изменились", 409)
|
if (!current.verifyEvidence(evidence, proof)) throw new McpError("MEASUREMENT_UNKNOWN", "Зависимости пути или сохранённое подтверждение изменились", 409)
|
||||||
return row.id as string
|
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 iso = (milliseconds: number) => new Date(Date.now() + milliseconds).toISOString()
|
||||||
const poll = (validUntil?: string) => preparedPlanExpiry("servers.poll", "servers", { validUntil })
|
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", () => {
|
test("Poll uses its existing sealed five-minute validity as the nominal window", () => {
|
||||||
const bound = iso(300000)
|
const bound = iso(300000)
|
||||||
assert.equal(poll(bound)?.toISOString(), bound)
|
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
|
/** Internal value calculation only; evidence comes from action.prepare, never
|
||||||
* caller JSON. Generic plans may outlive their preparation access credential.
|
* 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 {
|
export function preparedPlanExpiry(operationId: string, section: string, evidence: Pick<ChangeEvidence, "validUntil">): Date | null {
|
||||||
const now = Date.now(), ordinaryExpiry = now + (section === "network" ? 300000 : 900000)
|
const now = Date.now(), ordinaryExpiry = now + (section === "network" ? 300000 : 900000)
|
||||||
if (operationId !== "servers.poll") return new Date(ordinaryExpiry)
|
if (operationId !== "servers.poll" && operationId !== "routing.community.switch") return new Date(ordinaryExpiry)
|
||||||
const validity = z.string().datetime({ offset: true }).safeParse(evidence.validUntil)
|
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
|
const bound = validity.success ? Date.parse(validity.data) : NaN
|
||||||
if (!Number.isFinite(bound) || bound <= now) return null
|
if (!Number.isFinite(bound) || bound <= now) return null
|
||||||
return new Date(Math.min(ordinaryExpiry, bound))
|
return new Date(Math.min(ordinaryExpiry, bound))
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ test("failed/malformed/redirected and stalled protocol probes are bounded and ne
|
|||||||
try {
|
try {
|
||||||
for (const state of ["malformed", "redirect", "large", "announced", "stalled"]) {
|
for (const state of ["malformed", "redirect", "large", "announced", "stalled"]) {
|
||||||
mode = state
|
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.equal(result.status, "failed", state)
|
||||||
assert.ok(performance.now() - started < 3000, state)
|
assert.ok(performance.now() - started < 3000, state)
|
||||||
assert.ok(!JSON.stringify(result).includes("private-readiness-fixture"))
|
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)
|
assert.equal(forwarded, 0); assert.ok(requests >= 5)
|
||||||
} finally { server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())) }
|
} 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";
|
status: "passed" | "failed" | "skipped";
|
||||||
elapsedMs: number;
|
elapsedMs: number;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
credentialType: "temporary-no-operations";
|
credentialType: "temporary-no-operations" | "temporary-capabilities-read";
|
||||||
|
capabilitiesRead: "passed" | "failed" | "skipped";
|
||||||
clientCredentialsVerified: false;
|
clientCredentialsVerified: false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A temporary access credential may negotiate metadata, but its empty grant
|
const capabilitiesOperation = "capabilities.read"
|
||||||
* permits no tools. The ordinary connection credentials are never recovered. */
|
const capabilitiesTool = "mm_capabilities_get"
|
||||||
export async function withMcpProbeCredential<T>(connectionId: string, actor: AuthUser, work: (token: string) => Promise<T>): Promise<T> {
|
|
||||||
|
/** 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")
|
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])
|
await client.query("SELECT id FROM mcp_connections WHERE id=$1 FOR UPDATE", [connectionId])
|
||||||
const connection = await getConnection(connectionId, client)
|
const connection = await getConnection(connectionId, client)
|
||||||
assertConnectionVisible(connection, actor)
|
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)
|
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])
|
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 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_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 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 validatePrincipal({ connectionId, credentialId, credentialExpiresAt: expiresAt }, client)
|
||||||
await audit(client, actor.id, "connection.handshake.start", connectionId, { credentialId, expiresAt })
|
await audit(client, actor.id, "connection.handshake.start", connectionId, { credentialId, expiresAt })
|
||||||
|
return allowed
|
||||||
})
|
})
|
||||||
let completed = false
|
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 {
|
finally {
|
||||||
await transaction(async client => {
|
await transaction(async client => {
|
||||||
await client.query("DELETE FROM mcp_grants WHERE id=$1 AND connection_id=$2", [grantId, connectionId])
|
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. */
|
/** 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 started = performance.now(), controller = new AbortController()
|
||||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||||
const client = new Client({ name: "MikrotikManager readiness", version: "1.0.0" })
|
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 {
|
try {
|
||||||
const transport = new StreamableHTTPClientTransport(endpoint, { requestInit: { headers: { Authorization: `Bearer ${token}` }, redirect: "error" }, redirectPolicy: "follow",
|
const transport = new StreamableHTTPClientTransport(endpoint, { requestInit: { headers: { Authorization: `Bearer ${token}` }, redirect: "error" }, redirectPolicy: "follow",
|
||||||
fetch: async (url, init) => {
|
fetch: async (url, init) => {
|
||||||
@@ -67,13 +74,22 @@ export async function runMcpProtocolProbe(endpoint: URL, token: string, timeoutM
|
|||||||
} })
|
} })
|
||||||
await client.connect(transport, { timeout: timeoutMs })
|
await client.connect(transport, { timeout: timeoutMs })
|
||||||
const tools = await client.listTools({}, { 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")
|
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(() => {}) }
|
finally { clearTimeout(timer); controller.abort(); await client.close().catch(() => {}) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkMcpProtocol(connectionId: string, actor: AuthUser) {
|
export async function checkMcpProtocol(connectionId: string, actor: AuthUser) {
|
||||||
const endpoint = canonicalMcpUrl()
|
const endpoint = canonicalMcpUrl()
|
||||||
return withMcpProbeCredential(connectionId, actor, token => runMcpProtocolProbe(endpoint, token))
|
return withMcpProbeCredential(connectionId, actor, (token, readCapabilities) => runMcpProtocolProbe(endpoint, token, { readCapabilities }))
|
||||||
}
|
}
|
||||||
@@ -6,13 +6,14 @@ import { decryptSecret, fingerprint, McpError } from "./security.js"
|
|||||||
import { effectiveMcpConnection } from "./store.js"
|
import { effectiveMcpConnection } from "./store.js"
|
||||||
import { findMcpService } from "./service-registry.js"
|
import { findMcpService } from "./service-registry.js"
|
||||||
import { findMcpOperation } from "./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 { verifyRosStepSnapshots } from "./ros-step-verification.js"
|
||||||
import type { add } from "./actions.js"
|
import type { add } from "./actions.js"
|
||||||
import type { ActionContext, ChangeEvidence, ChangeStep } from "./action-types.js"
|
import type { ActionContext, ChangeEvidence, ChangeStep } from "./action-types.js"
|
||||||
import { loadScopedRecoverySource } from "./routing-community-scoped-recovery-source.js"
|
import { loadScopedRecoverySource } from "./routing-community-scoped-recovery-source.js"
|
||||||
import { prepareScopedRecovery, registerScopedRecoveryService } from "./routing-community-scoped-recovery.js"
|
import { prepareScopedRecovery, registerScopedRecoveryService } from "./routing-community-scoped-recovery.js"
|
||||||
import { reconcileVrfProductionStep } from "../../services/vrf-production-execution.js"
|
import { reconcileVrfProductionStep } from "../../services/vrf-production-execution.js"
|
||||||
|
import { needsBridgeVlanRecovery, prepareBridgeVlanRecovery } from "./bridge-vlan-recovery.js"
|
||||||
|
|
||||||
async function source(operationId: string, context: ActionContext, allowServices = false) {
|
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])
|
const { rows: [row] } = await pool.query("SELECT o.*,p.operation_id AS source_action,p.operation_version AS source_version,p.evidence FROM mcp_operations o JOIN mcp_plans p ON p.id=o.plan_id WHERE o.id=$1 AND o.connection_id=$2", [operationId, context.connection.id])
|
||||||
@@ -86,7 +87,7 @@ export function registerRecoveryActions(register: typeof add) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
register("operations.recover", "mm_operation_recovery_plan", "Подготовить отдельный обратный план сетевой операции", "network", "mm:network:write", "write", "servers", z.object({ operationId: z.string().uuid() }).strict(), {
|
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) => {
|
target: async (input, context) => {
|
||||||
const { evidence } = await source(String(input.operationId), context, true)
|
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)
|
if (evidence.steps.some(step => step.kind === "ros" && step.step.resource === "vrf")) throw new McpError("UNSUPPORTED_CAPABILITY", "VRF не имеет проверенной обратной ревизии; используйте read-only сверку и отдельный approved план", 409)
|
||||||
@@ -106,6 +107,7 @@ export function registerRecoveryActions(register: typeof add) {
|
|||||||
const checkpoints = (await pool.query("SELECT step_index,before_sealed,after_sealed FROM mcp_operation_checkpoints WHERE operation_id=$1 ORDER BY step_index DESC", [row.id])).rows
|
const 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
|
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 (!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 snapshots = new Map<string, RosSnapshot>(), steps: ChangeStep[] = [], objects: ChangeEvidence["objects"] = [...evidence.objects]
|
||||||
for (const checkpoint of checkpoints) {
|
for (const checkpoint of checkpoints) {
|
||||||
const original = evidence.steps[checkpoint.step_index]
|
const original = evidence.steps[checkpoint.step_index]
|
||||||
@@ -113,7 +115,7 @@ export function registerRecoveryActions(register: typeof add) {
|
|||||||
const step = original.step, resource = resourceById(step.resource)
|
const step = original.step, resource = resourceById(step.resource)
|
||||||
if (!context.connection.policy.scope.serverIds.includes(step.serverId)) throw new McpError("SERVER_DENIED", "Зависимость вне допуска")
|
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
|
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}`
|
const key = `${step.serverId}:${step.resource}`
|
||||||
if (!snapshots.has(key)) {
|
if (!snapshots.has(key)) {
|
||||||
const current = await readRosSnapshot(step.serverId, step.resource, undefined, after.sourceProof)
|
const current = await readRosSnapshot(step.serverId, step.resource, undefined, after.sourceProof)
|
||||||
@@ -143,7 +145,7 @@ export function registerRecoveryActions(register: typeof add) {
|
|||||||
const key = `${dependency.serverId}:${dependency.resource}`
|
const key = `${dependency.serverId}:${dependency.resource}`
|
||||||
if (snapshots.has(key)) continue
|
if (snapshots.has(key)) continue
|
||||||
const current = await readRosSnapshot(dependency.serverId, dependency.resource, undefined, dependency.sourceProof)
|
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)
|
snapshots.set(key, current)
|
||||||
}
|
}
|
||||||
objects.push(...[...new Set([...snapshots.values()].map(snapshot => snapshot.serverId))].map(serverId => ({ kind: "server", id: String(serverId), serverId })))
|
objects.push(...[...new Set([...snapshots.values()].map(snapshot => snapshot.serverId))].map(serverId => ({ kind: "server", id: String(serverId), serverId })))
|
||||||
|
|||||||
@@ -5,14 +5,19 @@ import { eq } from "drizzle-orm"
|
|||||||
import { db } from "../../db/index.js"
|
import { db } from "../../db/index.js"
|
||||||
import { servers } from "../../db/schema.js"
|
import { servers } from "../../db/schema.js"
|
||||||
import { MikrotikClient } from "../../services/mikrotik.js"
|
import { MikrotikClient } from "../../services/mikrotik.js"
|
||||||
|
import { readRouterOsSnapshotGet } from "../../services/routeros-snapshot-read.js"
|
||||||
import { McpError, fingerprint, privateSecretFingerprint } from "./security.js"
|
import { McpError, fingerprint, privateSecretFingerprint } from "./security.js"
|
||||||
import { readOspfEffectiveDisabled } from "../../services/ospf-effective-disabled.js"
|
import { readOspfEffectiveDisabled } from "../../services/ospf-effective-disabled.js"
|
||||||
import { greNativeFieldsSchema } from "../../services/gre-mutation-input.js"
|
import { greNativeFieldsSchema } from "../../services/gre-mutation-input.js"
|
||||||
import { canonicalVrfMembers } from "../../services/vrf-members.js"
|
import { canonicalVrfMembers } from "../../services/vrf-members.js"
|
||||||
|
import { canonicalBridgeVlanValue } from "../../services/bridge-vlan-values.js"
|
||||||
|
|
||||||
const text = z.string().max(1000)
|
const text = z.string().max(1000)
|
||||||
const name = z.string().trim().min(1).max(100)
|
const name = z.string().trim().min(1).max(100)
|
||||||
const bool = z.boolean()
|
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 integer = (min: number, max: number) => z.number().int().min(min).max(max)
|
||||||
const common = { name: name.optional(), comment: text.optional(), disabled: bool.optional() }
|
const common = { name: name.optional(), comment: text.optional(), disabled: bool.optional() }
|
||||||
const ipv4 = name.refine(value => isIP(value) === 4, "Требуется IPv4 router/area ID")
|
const ipv4 = name.refine(value => isIP(value) === 4, "Требуется IPv4 router/area ID")
|
||||||
@@ -31,13 +36,15 @@ const route = {
|
|||||||
export interface RosResourceSpec {
|
export interface RosResourceSpec {
|
||||||
id: string; path: string; title: string; permission: string; fields: z.ZodObject;
|
id: string; path: string; title: string; permission: string; fields: z.ZodObject;
|
||||||
create: boolean; remove: boolean; requiredOnCreate?: string[]; readOnly?: boolean
|
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[] = [
|
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: "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: "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_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: "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: "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: "ipv6_address", path: "/ipv6/address", title: "IPv6-адреса", permission: "mm:network", fields: z.object({ address: name.optional(), interface: name.optional(), advertise: bool.optional(), comment: text.optional(), disabled: bool.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["address", "interface"] },
|
||||||
@@ -70,6 +77,7 @@ export const rosResources: RosResourceSpec[] = [
|
|||||||
export type ConfigurationRow = Record<string, unknown>
|
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 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 ROS_SNAPSHOT_VERSION = 3
|
||||||
|
export const rosSnapshotVersion = (resource: string): number => ["bridge", "bridge_port", "bridge_vlan"].includes(resource) ? 4 : ROS_SNAPSHOT_VERSION
|
||||||
export interface RosSnapshot { serverId: number; resource: string; rows: ConfigurationRow[]; fingerprint: string; projectionVersion?: number; sourceProof?: string }
|
export interface RosSnapshot { serverId: number; resource: string; rows: ConfigurationRow[]; fingerprint: string; projectionVersion?: number; sourceProof?: string }
|
||||||
export function resourceById(id: string): RosResourceSpec {
|
export function resourceById(id: string): RosResourceSpec {
|
||||||
const resource = rosResources.find(item => item.id === id)
|
const resource = rosResources.find(item => item.id === id)
|
||||||
@@ -93,13 +101,21 @@ export function configurationRows(resource: RosResourceSpec, payload: unknown):
|
|||||||
// RouterOS creates/removes IPv6 link-local rows with interface runtime state.
|
// RouterOS creates/removes IPv6 link-local rows with interface runtime state.
|
||||||
// They cannot be configured independently and must not invalidate a sealed
|
// They cannot be configured independently and must not invalidate a sealed
|
||||||
// inverse plan when its own GRE is disabled. Explicit addresses remain 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 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.
|
// RouterOS 7.24.5 returns an explicit empty fib flag for non-FIB tables.
|
||||||
// Normalize only this observed field; missing values must remain unknown.
|
// Normalize only this observed field; missing values must remain unknown.
|
||||||
if (resource.id === "routing_table" && selected.fib === "") selected.fib = "false"
|
if (resource.id === "routing_table" && selected.fib === "") selected.fib = "false"
|
||||||
if (resource.id === "vrf" && selected.interfaces !== undefined) selected.interfaces = canonicalVrfMembers(selected.interfaces)
|
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
|
return selected
|
||||||
})
|
})
|
||||||
// Reordering an ordered catalog changes configuration even with unchanged rows.
|
// Reordering an ordered catalog changes configuration even with unchanged rows.
|
||||||
@@ -118,10 +134,10 @@ export async function readRosSnapshot(serverId: number, resourceId: string, befo
|
|||||||
const payload = resourceId === "ospf_template" ? await client.getOspfInterfaceTemplates(checkSource) : ["ospf_instance", "ospf_area", "bgp_instance", "bgp_connection"].includes(resourceId) ? await readOspfEffectiveDisabled(await client.get<unknown>(resource.path), async id => {
|
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()
|
await checkSource()
|
||||||
return client.post(resource.path + "/get", { ".id": id, "value-name": "disabled" })
|
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()
|
await checkSource()
|
||||||
const rows = configurationRows(resource, payload)
|
const rows = configurationRows(resource, payload)
|
||||||
return { serverId, resource: resourceId, rows, fingerprint: fingerprint(rows), projectionVersion: ROS_SNAPSHOT_VERSION, sourceProof }
|
return { serverId, resource: resourceId, rows, fingerprint: fingerprint(rows), projectionVersion: rosSnapshotVersion(resourceId), sourceProof }
|
||||||
}
|
}
|
||||||
export async function rosSnapshotSourceProof(server: Awaited<ReturnType<typeof routerClient>>["server"]): Promise<string> {
|
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")
|
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")
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { fingerprint, McpError } from "./security.js"
|
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 { verifyRosStepSnapshots } from "./ros-step-verification.js"
|
||||||
import { acknowledgedCreateId } from "./native-create-identity.js"
|
import { acknowledgedCreateId } from "./native-create-identity.js"
|
||||||
|
|
||||||
const key = (snapshot: Pick<RosSnapshot, "serverId" | "resource">) => `${snapshot.serverId}:${snapshot.resource}`
|
const key = (snapshot: Pick<RosSnapshot, "serverId" | "resource">) => `${snapshot.serverId}:${snapshot.resource}`
|
||||||
function valid(snapshot: RosSnapshot): boolean {
|
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])
|
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)
|
const ids = snapshot.rows.map(acknowledgedCreateId)
|
||||||
return ids.every(id => id !== undefined) && new Set(ids).size === ids.length
|
return ids.every(id => id !== undefined) && new Set(ids).size === ids.length
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { isDeepStrictEqual } from "node:util"
|
import { isDeepStrictEqual } from "node:util"
|
||||||
import { acknowledgedCreateId } from "./native-create-identity.js"
|
import { acknowledgedCreateId } from "./native-create-identity.js"
|
||||||
|
import { canonicalBridgeVlanValue } from "../../services/bridge-vlan-values.js"
|
||||||
import { isOrderedRosResource, resourceById, type ConfigurationRow, type RosResourceSpec, type RosSnapshot, type RosStep } from "./resources.js"
|
import { isOrderedRosResource, resourceById, type ConfigurationRow, type RosResourceSpec, type RosSnapshot, type RosStep } from "./resources.js"
|
||||||
|
|
||||||
function booleanValue(value: unknown): unknown {
|
function booleanValue(value: unknown): unknown {
|
||||||
@@ -23,6 +24,9 @@ function requested(resource: RosResourceSpec, fields: RosStep["fields"], row: Co
|
|||||||
const schema = resource.fields.shape[key]
|
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 || 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 (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
|
return String(row[key]) === value
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,34 +22,38 @@ import { compileManagedFilterRules } from "../../services/filter-rules-write-mod
|
|||||||
import { attestRoutingFixture } from "../../test/mcp-routing-fixture.js"
|
import { attestRoutingFixture } from "../../test/mcp-routing-fixture.js"
|
||||||
import { createMcpConnection, authenticateMcpToken, setConnectionStatus, effectiveMcpConnection } from "./store.js"
|
import { createMcpConnection, authenticateMcpToken, setConnectionStatus, effectiveMcpConnection } from "./store.js"
|
||||||
import { invokeMcpTool, approvePlan, applyPlan, publicPlan, loadPlan } from "./runtime.js"
|
import { invokeMcpTool, approvePlan, applyPlan, publicPlan, loadPlan } from "./runtime.js"
|
||||||
import { accessGeneration, decryptSecret, fingerprint, McpError } from "./security.js"
|
import { accessGeneration, decryptSecret, encryptSecret, fingerprint, McpError } from "./security.js"
|
||||||
import type { McpPrincipal } from "./store.js"
|
import type { McpPrincipal } from "./store.js"
|
||||||
import { loadScopedRecoverySource } from "./routing-community-scoped-recovery-source.js"
|
import { loadScopedRecoverySource } from "./routing-community-scoped-recovery-source.js"
|
||||||
import { findMcpService } from "./service-registry.js"
|
import { findMcpService } from "./service-registry.js"
|
||||||
import { prepareScopedCommunitySwitch } from "./routing-community-scoped-service.js"
|
import { prepareScopedCommunitySwitch } from "./routing-community-scoped-service.js"
|
||||||
import { resolveRoutingNetworkConfigurationContext, resolveRoutingNetworkConfigurationContextFromSharedContext, consumeRoutingNetworkConfigurationContext, closeRoutingNetworkConfigurationContext,
|
import { resolveRoutingNetworkConfigurationContext, consumeRoutingNetworkConfigurationContext, closeRoutingNetworkConfigurationContext } from "./routing-network-configuration-bridge.js"
|
||||||
resolveRoutingNetworkForwardRoundConfigurationContext, closeRoutingNetworkForwardRoundConfigurationContext } from "./routing-network-configuration-bridge.js"
|
|
||||||
import { createRoutingNetworkNativeSession } from "./routing-network-native-session.js"
|
import { createRoutingNetworkNativeSession } from "./routing-network-native-session.js"
|
||||||
import { requireRecentPath } from "./path-verification.js"
|
import { requireRecentPath } from "./path-verification.js"
|
||||||
import { registerManagementChannel } from "./management.js"
|
import { registerManagementChannel } from "./management.js"
|
||||||
import { isSchedulerJobsPaused, pauseSchedulerJobsAndWait, resumeSchedulerJobs } from "../../services/scheduler-running.js"
|
import { isSchedulerJobsPaused, pauseSchedulerJobsAndWait, resumeSchedulerJobs } from "../../services/scheduler-running.js"
|
||||||
|
|
||||||
/** Disposable SQL + loopback HTTP only. Does not access or configure CHR. */
|
/** Disposable SQL + loopback HTTP only. Does not access or configure CHR. */
|
||||||
export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options: { originalEffectsOnly?: boolean; originalCredentialRevocationOnly?: boolean; forwardRoundsOnly?: boolean; manualInverseChargeOnly?: boolean; manualInverseChargeCase?: "positive" | "quota" | "concurrent" | "unknown" | "revoked" | "nonpersistent" | "mixed-forward"; lateInverseAuthorityChange?: "edit" | "disable" | "grant" } = {}) {
|
export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options: { originalEffectsOnly?: boolean; controllerFailoverOnly?: boolean; originalCredentialRevocationOnly?: boolean; forwardRoundsOnly?: boolean; manualInverseChargeOnly?: boolean; manualInverseChargeCase?: "positive" | "quota" | "concurrent" | "unknown" | "revoked" | "nonpersistent" | "mixed-forward"; lateInverseAuthorityChange?: "edit" | "disable" | "grant" } = {}) {
|
||||||
const sid = options.lateInverseAuthorityChange ? { edit: 131, disable: 132, grant: 133 }[options.lateInverseAuthorityChange] : 131
|
const sid = options.lateInverseAuthorityChange ? { edit: 131, disable: 132, grant: 133 }[options.lateInverseAuthorityChange] : 131
|
||||||
const writer: AuthUser = { ...actor, permissions: [...new Set([...actor.permissions, "mm:filters:write"])] }
|
const writer: AuthUser = { ...actor, permissions: [...new Set([...actor.permissions, "mm:filters:write"])] }
|
||||||
const initial = compileManagedFilterRules([{ community: "65000:10", gateway: "192.0.2.1", outIface: "", action: "route", description: "" }, { community: "65000:20", gateway: "192.0.2.5", outIface: "", action: "route", description: "" }])
|
const initial = compileManagedFilterRules([{ community: "65000:10", gateway: "192.0.2.1", outIface: "", action: "route", description: "" }, { community: "65000:20", gateway: "192.0.2.5", outIface: "", action: "route", description: "" }])
|
||||||
const filters = ["a", "b"].map(key => ({ ".id": "*f" + key, chain: "custom-" + key, rule: initial, disabled: "false", comment: managedComment("NEVER_SCOPED_COMMENT_" + key), opaque: "NEVER_SCOPED_OPAQUE" }))
|
const filters = ["a", "b"].map(key => ({ ".id": "*f" + key, chain: "custom-" + key, rule: initial, disabled: "false", comment: managedComment("NEVER_SCOPED_COMMENT_" + key), opaque: "NEVER_SCOPED_OPAQUE" }))
|
||||||
let mode: "normal" | "ignored" | "lost" | "collateral" | "post-failed" | "post-unknown" = "normal", writes = 0, requests = 0
|
let mode: "normal" | "ignored" | "lost" | "collateral" | "post-failed" | "post-unknown" = "normal", writes = 0, requests = 0
|
||||||
|
let controllerProbeUnknown = false
|
||||||
|
let controllerFailoverProbeSources: string[] | undefined
|
||||||
|
let controllerFailoverReserveHealthy = true
|
||||||
let pendingPostCheckpoint = false, postCheckpointsObserved = 0
|
let pendingPostCheckpoint = false, postCheckpointsObserved = 0
|
||||||
let afterForwardWrite: (() => Promise<void>) | undefined
|
let afterForwardWrite: (() => Promise<void>) | undefined
|
||||||
let afterConfigurationCheckpoint: ((operationId: string) => Promise<void>) | undefined
|
let afterConfigurationCheckpoint: ((operationId: string) => Promise<void>) | undefined
|
||||||
let forwardRoundBulk = 0, afterRoundLogRead: (() => Promise<void>) | undefined
|
let forwardRoundBulk = 0, afterRoundLogRead: (() => Promise<void>) | undefined
|
||||||
let primaryDown = false, primaryControlRequests = 0, pendingInversePost = false, inversePostChecks = 0
|
let primaryDown = false, primaryControlRequests = 0, pendingInversePost = false, inversePostChecks = 0
|
||||||
const observedVrfs = new Set<string>()
|
const observedVrfs = new Set<string>()
|
||||||
// Only this separately selected SQL/localhost phase changes its wire data.
|
const paddedEffectResources = new Set(["/routing/route", "/routing/bgp/session", "/routing/bfd/session", "/log"])
|
||||||
// These are declared synthetic RouterOS responses, never actual CHR facts.
|
// Persistent fixtures need an original RIB selection. These are declared
|
||||||
|
// synthetic RouterOS responses, never actual CHR facts.
|
||||||
let originalEffectCase: "transition" | "no-op" | "ambiguous" = "transition"
|
let originalEffectCase: "transition" | "no-op" | "ambiguous" = "transition"
|
||||||
|
let exposeEffectCatalogue = false
|
||||||
const originalEffectWire: Array<{ method: string; path: string; afterPatch: boolean }> = []
|
const originalEffectWire: Array<{ method: string; path: string; afterPatch: boolean }> = []
|
||||||
const originalEffectChanged = () => originalEffectCase === "no-op" || filters[0].rule !== initial
|
const originalEffectChanged = () => originalEffectCase === "no-op" || filters[0].rule !== initial
|
||||||
const effectRoutes = () => {
|
const effectRoutes = () => {
|
||||||
@@ -63,25 +67,31 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options:
|
|||||||
const effectConnection = { ".id": "*ca", name: "peer-a", instance: "default", vrf: "main", "routing-table": "tenant-a", afi: "ip",
|
const effectConnection = { ".id": "*ca", name: "peer-a", instance: "default", vrf: "main", "routing-table": "tenant-a", afi: "ip",
|
||||||
"input.filter": "custom-a", "output.filter-chain": "", templates: "", disabled: "false", "use-bfd": "false",
|
"input.filter": "custom-a", "output.filter-chain": "", templates: "", disabled: "false", "use-bfd": "false",
|
||||||
"local.address": "198.18.0.1", "remote.address": "198.18.0.2", "remote.as": "65001" }
|
"local.address": "198.18.0.1", "remote.address": "198.18.0.2", "remote.as": "65001" }
|
||||||
|
const { vrf: effectTransportVrf, ...effectSessionFields } = effectConnection
|
||||||
|
const effectSession = { ...effectSessionFields, ".id": "*sa", "local.afi": "ip", "remote.afi": "ip", established: "true",
|
||||||
|
"local.address": `${effectConnection["local.address"]}@${effectTransportVrf}`, "remote.address": `${effectConnection["remote.address"]}@${effectTransportVrf}` }
|
||||||
const collections = (path: string): Record<string, unknown>[] => ({
|
const collections = (path: string): Record<string, unknown>[] => ({
|
||||||
"/system/resource": [{ version: "7.20.1" }],
|
"/system/resource": [{ version: "7.20.1" }],
|
||||||
...Object.fromEntries(["/routing/route", "/routing/bgp/session", "/routing/bfd/session", "/log"].map(resource => [resource,
|
"/routing/table": ["main", "tenant-a", "tenant-b", "probe-a", "probe-b"].map((name, index) => ({ ".id": "*t" + index, name, fib: "true", disabled: "false", invalid: "false" })),
|
||||||
Array.from({ length: forwardRoundBulk }, (_, index) => ({ ".id": "*" + index, synthetic: "bounded owned budget payload" }))])),
|
|
||||||
"/routing/table": ["main", "tenant-a", "tenant-b", "probe-a", "probe-b"].map((name, index) => ({ ".id": "*t" + index, name, fib: "true" })),
|
|
||||||
"/ip/vrf": ["a", "b"].map(key => ({ ".id": "*v" + key, name: "probe-" + key, interfaces: "wan-" + key, disabled: "false" })).concat([{ ".id": "*vm", name: "main", interfaces: "all", disabled: "false" }]),
|
"/ip/vrf": ["a", "b"].map(key => ({ ".id": "*v" + key, name: "probe-" + key, interfaces: "wan-" + key, disabled: "false" })).concat([{ ".id": "*vm", name: "main", interfaces: "all", disabled: "false" }]),
|
||||||
"/routing/bgp/connection": ["a", "b"].map(key => ({ ".id": "*c" + key, name: "peer-" + key, instance: "default", vrf: "main", "routing-table": "tenant-" + key, afi: "ip", "input.filter": "custom-" + key, "output.filter-chain": "", templates: "", disabled: "false" })),
|
"/routing/bgp/connection": [effectConnection, { ...effectConnection, ".id": "*cb", name: "peer-b", "routing-table": "tenant-b", "input.filter": "custom-b" }],
|
||||||
"/routing/bgp/instance": [{ ".id": "*i", name: "default", vrf: "main", "routing-table": "main", disabled: "false" }],
|
"/routing/bgp/instance": [{ ".id": "*i", name: "default", vrf: "main", "routing-table": "main", disabled: "false" }],
|
||||||
"/routing/bgp/template": [], "/routing/filter/rule": filters,
|
"/routing/bgp/template": [], "/routing/filter/rule": filters,
|
||||||
"/ip/route": [1, 9].map(value => ({ ".id": "*r" + value, "dst-address": "192.0.2." + value + "/32", "routing-table": "main", disabled: "false", comment: managedComment("recursive"), opaque: "NEVER_SCOPED_ROUTE" })),
|
"/ip/route": (controllerFailoverProbeSources ? [1, 9, 10, 11] : [1, 9]).map(value => ({ ".id": "*r" + value, "dst-address": "192.0.2." + value + "/32", "routing-table": "main", disabled: "false",
|
||||||
|
"immediate-gw": "203.0.113." + value + "%wan-a", comment: managedComment("recursive"), opaque: "NEVER_SCOPED_ROUTE" })),
|
||||||
"/interface": ["wan-a", "wan-b", "hop", "target"].map((name, index) => ({ ".id": "*if" + index, name, disabled: "false", running: "true" })),
|
"/interface": ["wan-a", "wan-b", "hop", "target"].map((name, index) => ({ ".id": "*if" + index, name, disabled: "false", running: "true" })),
|
||||||
"/ip/address": ["wan-a", "wan-b", "hop", "target"].map((name, index) => ({ ".id": "*ad" + index, interface: name, address: "198.51.100." + (index + 1) + "/32", disabled: "false", invalid: "false" })),
|
"/ip/address": [
|
||||||
|
...["wan-a", "wan-b", "hop", "target"].map((name, index) => ({ ".id": "*ad" + index, interface: name, address: "198.51.100." + (index + 1) + "/32", disabled: "false", invalid: "false" })),
|
||||||
|
...(controllerFailoverProbeSources ? [11, 12, 13].map(index => ({ ".id": "*ad" + index, interface: "wan-a", address: "198.51.100." + index + "/32", disabled: "false", invalid: "false" })) : []),
|
||||||
|
],
|
||||||
...(options.originalEffectsOnly ? {
|
...(options.originalEffectsOnly ? {
|
||||||
"/routing/table": ["main", "tenant-a", "tenant-b", "probe-a", "probe-b"].map((name, index) => ({ ".id": "*t" + index, name, fib: "", disabled: "false", invalid: "false" })),
|
"/routing/table": ["main", "tenant-a", "tenant-b", "probe-a", "probe-b"].map((name, index) => ({ ".id": "*t" + index, name, fib: "", disabled: "false", invalid: "false" })),
|
||||||
"/routing/route": effectRoutes(), "/routing/bgp/connection": [effectConnection],
|
"/routing/bgp/connection": [effectConnection],
|
||||||
"/routing/bgp/session": [{ ...effectConnection, ".id": "*sa", "local.afi": "ip", "remote.afi": "ip", established: "true" }],
|
"/log": [{ ".id": "*l1", topics: "bgp,route,info", message: "Declared synthetic localhost effect observation; interval coverage unknown" }],
|
||||||
"/routing/bfd/session": [], "/log": [{ ".id": "*l1", topics: "bgp,route,info", message: "Declared synthetic localhost effect observation; interval coverage unknown" }],
|
} : {}),
|
||||||
"/ip/route": [1, 9].map(value => ({ ".id": "*r" + value, "dst-address": "192.0.2." + value + "/32", "routing-table": "main", disabled: "false",
|
...(exposeEffectCatalogue || options.originalEffectsOnly ? {
|
||||||
"immediate-gw": "203.0.113." + value + "%wan-a", comment: managedComment("recursive"), opaque: "NEVER_SCOPED_ROUTE" })),
|
"/routing/route": effectRoutes(),
|
||||||
|
"/routing/bgp/session": [effectSession],
|
||||||
} : {}),
|
} : {}),
|
||||||
} as Record<string, Record<string, unknown>[]>)[path] ?? []
|
} as Record<string, Record<string, unknown>[]>)[path] ?? []
|
||||||
let requestFailure: { error: unknown } | undefined
|
let requestFailure: { error: unknown } | undefined
|
||||||
@@ -93,7 +103,7 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options:
|
|||||||
}
|
}
|
||||||
let raw = ""; for await (const chunk of req) raw += chunk
|
let raw = ""; for await (const chunk of req) raw += chunk
|
||||||
const args = raw ? JSON.parse(raw) : {}, path = decodeURIComponent((req.url ?? "").replace(/^\/rest/, ""))
|
const args = raw ? JSON.parse(raw) : {}, path = decodeURIComponent((req.url ?? "").replace(/^\/rest/, ""))
|
||||||
if (options.originalEffectsOnly) originalEffectWire.push({ method: req.method ?? "", path, afterPatch: filters[0].rule !== initial })
|
if (options.originalEffectsOnly || options.forwardRoundsOnly) originalEffectWire.push({ method: req.method ?? "", path, afterPatch: filters[0].rule !== initial })
|
||||||
if (req.socket.localAddress === "127.0.0.1") {
|
if (req.socket.localAddress === "127.0.0.1") {
|
||||||
if (/^\/(routing|tool)\//.test(path) || path === "/system/license") primaryControlRequests++
|
if (/^\/(routing|tool)\//.test(path) || path === "/system/license") primaryControlRequests++
|
||||||
if (primaryDown) { req.socket.destroy(); return }
|
if (primaryDown) { req.socket.destroy(); return }
|
||||||
@@ -102,28 +112,36 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options:
|
|||||||
if (req.method === "GET") {
|
if (req.method === "GET") {
|
||||||
const payload = collections(path)
|
const payload = collections(path)
|
||||||
if (path === "/log" && afterRoundLogRead) { const hook = afterRoundLogRead; afterRoundLogRead = undefined; await hook() }
|
if (path === "/log" && afterRoundLogRead) { const hook = afterRoundLogRead; afterRoundLogRead = undefined; await hook() }
|
||||||
res.end(JSON.stringify(payload)); return
|
// Force the cumulative 10,000-row budget without exceeding the native
|
||||||
|
// cap of 1,000 rows on any individual response.
|
||||||
|
const padding = forwardRoundBulk && paddedEffectResources.has(path)
|
||||||
|
? Array.from({ length: 1000 - payload.length }, (_, index) => ({ ".id": "*padding" + index })) : []
|
||||||
|
res.end(JSON.stringify(padding.length ? [...payload, ...padding] : payload)); return
|
||||||
}
|
}
|
||||||
if (req.method === "POST" && path.endsWith("/get")) { const row = collections(path.slice(0, -4)).find(row => row[".id"] === args[".id"]), value = row?.[args["value-name"]]; res.end(JSON.stringify(value === undefined ? [] : { ret: value })); return }
|
if (req.method === "POST" && path.endsWith("/get")) { const row = collections(path.slice(0, -4)).find(row => row[".id"] === args[".id"]), value = row?.[args["value-name"]]; res.end(JSON.stringify(value === undefined ? [] : { ret: value })); return }
|
||||||
if (req.method === "POST" && path === "/tool/ping") {
|
if (req.method === "POST" && path === "/tool/ping") {
|
||||||
if (pendingInversePost) {
|
if (pendingInversePost) {
|
||||||
const intents = (await pool.query("SELECT source_operation_id FROM mcp_scoped_recovery_intents WHERE state='intent'")).rows
|
assert.ok(connectionId)
|
||||||
|
const intents = (await pool.query("SELECT i.source_operation_id FROM mcp_scoped_recovery_intents i JOIN mcp_operations o ON o.id=i.recovery_operation_id WHERE i.state='intent' AND o.connection_id=$1", [connectionId])).rows
|
||||||
assert.equal(intents.length, 1, "Post-inverse probe must follow one durable single-use inverse intent")
|
assert.equal(intents.length, 1, "Post-inverse probe must follow one durable single-use inverse intent")
|
||||||
pendingInversePost = false; inversePostChecks++
|
pendingInversePost = false; inversePostChecks++
|
||||||
}
|
}
|
||||||
if (pendingPostCheckpoint) {
|
if (pendingPostCheckpoint) {
|
||||||
const checkpoint = (await pool.query("SELECT c.configuration_sealed,c.configuration_proof FROM mcp_operation_checkpoints c JOIN mcp_operations o ON o.id=c.operation_id WHERE o.state='running' AND c.configuration_sealed IS NOT NULL")).rows
|
assert.ok(connectionId)
|
||||||
|
const checkpoint = (await pool.query("SELECT c.configuration_sealed,c.configuration_proof FROM mcp_operation_checkpoints c JOIN mcp_operations o ON o.id=c.operation_id WHERE o.state='running' AND c.configuration_sealed IS NOT NULL AND o.connection_id=$1", [connectionId])).rows
|
||||||
assert.equal(checkpoint.length, 1, "Exact CONFIG_APPLIED must commit before the post-change network probe")
|
assert.equal(checkpoint.length, 1, "Exact CONFIG_APPLIED must commit before the post-change network probe")
|
||||||
const packet = JSON.parse(await decryptSecret(checkpoint[0].configuration_sealed))
|
const packet = JSON.parse(await decryptSecret(checkpoint[0].configuration_sealed))
|
||||||
assert.equal(packet.phase, "CONFIG_APPLIED"); assert.equal(packet.originalRule, initial)
|
assert.equal(packet.phase, "CONFIG_APPLIED"); assert.equal(packet.originalRule, initial)
|
||||||
assert.equal(packet.originalCandidateId, "a"); assert.equal(packet.desiredCandidateId, "b")
|
assert.equal(packet.originalCandidateId, "a"); assert.equal(packet.desiredCandidateId, controllerFailoverProbeSources ? "d" : "b")
|
||||||
assert.equal(packet.desiredRule, filters.find(row => row[".id"] === packet.nativeId)!.rule)
|
assert.equal(packet.desiredRule, filters.find(row => row[".id"] === packet.nativeId)!.rule)
|
||||||
assert.ok(checkpoint[0].configuration_proof); pendingPostCheckpoint = false; postCheckpointsObserved++
|
assert.ok(checkpoint[0].configuration_proof); pendingPostCheckpoint = false; postCheckpointsObserved++
|
||||||
const sharedCheck = afterConfigurationCheckpoint; afterConfigurationCheckpoint = undefined
|
const sharedCheck = afterConfigurationCheckpoint; afterConfigurationCheckpoint = undefined
|
||||||
await sharedCheck?.(packet.operationId)
|
await sharedCheck?.(packet.operationId)
|
||||||
}
|
}
|
||||||
observedVrfs.add(args.vrf)
|
observedVrfs.add(args.vrf)
|
||||||
res.end(JSON.stringify(mode === "post-unknown" && writes ? [{ message: "unsupported fixture output" }] : [{ sent: "3", received: mode === "post-failed" && writes ? "0" : "3", "packet-loss": mode === "post-failed" && writes ? "100" : "0", "avg-rtt": "1ms" }]))
|
if (controllerFailoverProbeSources) controllerFailoverProbeSources.push(args["src-address"])
|
||||||
|
const probeFailed = controllerFailoverProbeSources && (!controllerFailoverReserveHealthy || args["src-address"] !== "198.51.100.13") || mode === "post-failed" && writes
|
||||||
|
res.end(JSON.stringify(controllerProbeUnknown || mode === "post-unknown" && writes ? [{ message: "unsupported fixture output" }] : [{ sent: "3", received: probeFailed ? "0" : "3", "packet-loss": probeFailed ? "100" : "0", "avg-rtt": "1ms" }]))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (req.method === "POST" && path === "/tool/traceroute") { observedVrfs.add(args.vrf); res.end(JSON.stringify([3, 4].map(index => ({ address: "198.51.100." + index, sent: "1", loss: "0" })))); return }
|
if (req.method === "POST" && path === "/tool/traceroute") { observedVrfs.add(args.vrf); res.end(JSON.stringify([3, 4].map(index => ({ address: "198.51.100." + index, sent: "1", loss: "0" })))); return }
|
||||||
@@ -181,15 +199,16 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options:
|
|||||||
} while(!workerLocked)
|
} while(!workerLocked)
|
||||||
await pool.query("INSERT INTO servers(id,name,host,port,use_ssl,password) VALUES($1,'scoped-route','127.0.0.1',$2,false,'NEVER_SCOPED_PASSWORD')", [sid, (router.address() as { port: number }).port])
|
await pool.query("INSERT INTO servers(id,name,host,port,use_ssl,password) VALUES($1,'scoped-route','127.0.0.1',$2,false,'NEVER_SCOPED_PASSWORD')", [sid, (router.address() as { port: number }).port])
|
||||||
insertedServer = true
|
insertedServer = true
|
||||||
const access = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Scoped manual", mode: "approval", expiresAt: new Date(Date.now() + 1800000).toISOString(), permissions: ["mm:network:read", "mm:network:write", "mm:filters:write"], scope: { serverIds: [sid], operationIds: ["routing.analyze", "routing.path.verify", "routing.community.switch", "operations.reconcile", "operations.recover"], allowGlobal: false }, limits: { callsPerMinute: 1000, mutationsPerHour: 300 } }), writer)
|
const access = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Scoped manual", mode: "approval", expiresAt: new Date(Date.now() + 1800000).toISOString(), permissions: ["mm:network:read", "mm:network:write", "mm:filters:write"], scope: { serverIds: [sid], operationIds: ["routing.analyze", "routing.path.verify", "routing.community.switch", "routing.profiles.save", "operations.reconcile", "operations.recover"], allowGlobal: false }, limits: { callsPerMinute: 1000, mutationsPerHour: 300 } }), writer)
|
||||||
connectionId = access.connection.id; const principal = await authenticateMcpToken(access.token)
|
connectionId = access.connection.id; const principal = await authenticateMcpToken(access.token)
|
||||||
const profile = (key: string) => routeAiProfileSchema.parse({ name: "independent " + key, maxLossPct: 5, serverId: sid, family: "ipv4", community: "65000:10", table: "tenant-" + key, probeVrf: "probe-" + key, bgpContext: { connectionId: "*c" + key, instance: "default", vrf: "main", inputChain: "custom-" + key }, candidates: [1, 9].map(value => ({ id: value === 1 ? "a" : "b", jumpHostId: sid, exitNodeId: sid, wanInterface: "wan-" + key, downstreamInterface: "hop", gateway: "192.0.2." + value, gatewayRef: { resource: "ipv4_route", id: "*r" + value }, controlProbe: { targetInterface: "target", expectedHops: [{ serverId: sid, interface: "hop" }, { serverId: sid, interface: "target" }] } })) })
|
const profile = (key: string) => routeAiProfileSchema.parse({ name: "independent " + key, maxLossPct: 5, serverId: sid, family: "ipv4", community: "65000:10", table: "tenant-" + key, probeVrf: "probe-" + key, bgpContext: { connectionId: "*c" + key, instance: "default", vrf: "main", inputChain: "custom-" + key }, candidates: [1, 9].map(value => ({ id: value === 1 ? "a" : "b", jumpHostId: sid, exitNodeId: sid, wanInterface: "wan-" + key, downstreamInterface: "hop", gateway: "192.0.2." + value, gatewayRef: { resource: "ipv4_route", id: "*r" + value }, controlProbe: { targetInterface: "target", sourceAddress: key === "a" ? "198.51.100.1" : "198.51.100.2", expectedHops: [{ serverId: sid, interface: "hop" }, { serverId: sid, interface: "target" }] } })) })
|
||||||
for (const key of ["a", "b"]) for (const iface of ["wan-" + key, "hop"]) {
|
for (const key of ["a", "b"]) for (const iface of ["wan-" + key, "hop"]) {
|
||||||
const id = 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',$4,1,0,100,100,now(),now(),true,'fixture')", [id, sid, iface, "tenant-" + key]); await attestRoutingFixture(id)
|
const id = 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',$4,1,0,100,100,now(),now(),true,'fixture')", [id, sid, iface, "tenant-" + key]); await attestRoutingFixture(id)
|
||||||
}
|
}
|
||||||
await pool.query("INSERT INTO filter_rules(server_id,sort_order,community,community_name,action,gateway,gateway_tunnel_id,description) VALUES($1,77,'65000:10','NEVER_SCOPED_SQL_NAME','route','203.0.113.99','NEVER_SCOPED_SQL_TUNNEL','NEVER_SCOPED_SQL_DESCRIPTION')", [sid])
|
await pool.query("INSERT INTO filter_rules(server_id,sort_order,community,community_name,action,gateway,gateway_tunnel_id,description) VALUES($1,77,'65000:10','NEVER_SCOPED_SQL_NAME','route','203.0.113.99','NEVER_SCOPED_SQL_TUNNEL','NEVER_SCOPED_SQL_DESCRIPTION')", [sid])
|
||||||
const dbBefore = (await pool.query("SELECT * FROM filter_rules WHERE server_id=$1 ORDER BY id", [sid])).rows
|
const dbBefore = (await pool.query("SELECT * FROM filter_rules WHERE server_id=$1 ORDER BY id", [sid])).rows
|
||||||
const prepare = async (key = "a", persistent?: { id: string; version: number }, exactProfile?: ReturnType<typeof profile>, assertApprovalRequired = false) => {
|
const prepare = async (key = "a", persistent?: { id: string; version: number }, exactProfile?: ReturnType<typeof profile>, assertApprovalRequired = false, approver: AuthUser | null = writer) => {
|
||||||
|
exposeEffectCatalogue = !!persistent
|
||||||
const current = exactProfile === undefined ? profile(key) : routeAiProfileSchema.parse(exactProfile)
|
const current = exactProfile === undefined ? profile(key) : routeAiProfileSchema.parse(exactProfile)
|
||||||
assert.equal((await invokeMcpTool(principal, "mm_route_path_verify", { profile: current, candidateId: "b" }) as { status: string }).status, "passed")
|
assert.equal((await invokeMcpTool(principal, "mm_route_path_verify", { profile: current, candidateId: "b" }) as { status: string }).status, "passed")
|
||||||
const plan = await invokeMcpTool(principal, "mm_route_optimizer_plan", { profile: current, candidateId: "b" }, undefined,
|
const plan = await invokeMcpTool(principal, "mm_route_optimizer_plan", { profile: current, candidateId: "b" }, undefined,
|
||||||
@@ -210,15 +229,20 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options:
|
|||||||
await assert.rejects(applyPlan(principal, plan.planId, randomUUID()), (error: unknown) => error instanceof McpError && error.code === "APPROVAL_REQUIRED")
|
await assert.rejects(applyPlan(principal, plan.planId, randomUUID()), (error: unknown) => error instanceof McpError && error.code === "APPROVAL_REQUIRED")
|
||||||
assert.deepEqual({ writes, requests }, beforeApproval)
|
assert.deepEqual({ writes, requests }, beforeApproval)
|
||||||
}
|
}
|
||||||
await approvePlan(plan.planId, writer, true); return { plan, input }
|
if (approver) await approvePlan(plan.planId, approver, true)
|
||||||
|
return { plan, input, evidence }
|
||||||
}
|
}
|
||||||
const execute = async (planId: string, executingPrincipal: McpPrincipal = principal) => {
|
const waitForOperation = async (operationId: string) => {
|
||||||
const op = await applyPlan(executingPrincipal, planId, randomUUID()); let state = ""
|
let state = ""
|
||||||
const deadline = Date.now() + 140000
|
const deadline = Date.now() + 140000
|
||||||
do { state = (await pool.query("SELECT state FROM mcp_operations WHERE id=$1", [op.id])).rows[0].state; if (!["pending", "running"].includes(state)) break; await delay(20) } while (Date.now() < deadline)
|
do { state = (await pool.query("SELECT state FROM mcp_operations WHERE id=$1", [operationId])).rows[0].state; if (!["pending", "running"].includes(state)) break; await delay(20) } while (Date.now() < deadline)
|
||||||
if(requestFailure)throw requestFailure.error
|
if(requestFailure)throw requestFailure.error
|
||||||
assert.ok(!["pending", "running"].includes(state), "The bounded executor must reach a terminal state before assertions or fixture cleanup")
|
assert.ok(!["pending", "running"].includes(state), "The bounded executor must reach a terminal state before assertions or fixture cleanup")
|
||||||
return { ...op, state }
|
return state
|
||||||
|
}
|
||||||
|
const execute = async (planId: string, executingPrincipal: McpPrincipal = principal) => {
|
||||||
|
const op = await applyPlan(executingPrincipal, planId, randomUUID())
|
||||||
|
return { ...op, state: await waitForOperation(op.id) }
|
||||||
}
|
}
|
||||||
const reconcile = async (operationId: string) => (await invokeMcpTool(principal, "mm_operation_reconcile", { operationId }) as { results: Array<{ matchesDesiredState: boolean; configurationMatchesDesired: boolean; configurationCheckpointAvailable: boolean }> }).results[0]
|
const reconcile = async (operationId: string) => (await invokeMcpTool(principal, "mm_operation_reconcile", { operationId }) as { results: Array<{ matchesDesiredState: boolean; configurationMatchesDesired: boolean; configurationCheckpointAvailable: boolean }> }).results[0]
|
||||||
const reset = () => { filters.forEach(row => { row.rule = initial; row.opaque = "NEVER_SCOPED_OPAQUE" }); mode = "normal"; pendingPostCheckpoint = false; pendingInversePost = false; primaryDown = false }
|
const reset = () => { filters.forEach(row => { row.rule = initial; row.opaque = "NEVER_SCOPED_OPAQUE" }); mode = "normal"; pendingPostCheckpoint = false; pendingInversePost = false; primaryDown = false }
|
||||||
@@ -226,8 +250,8 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options:
|
|||||||
await pool.query("UPDATE mcp_routing_observations SET ping_at=now(),bandwidth_at=now(),loss_pct=0 WHERE src_server_id=$1", [sid])
|
await pool.query("UPDATE mcp_routing_observations SET ping_at=now(),bandwidth_at=now(),loss_pct=0 WHERE src_server_id=$1", [sid])
|
||||||
for (const row of (await pool.query("SELECT id FROM mcp_routing_observations WHERE src_server_id=$1", [sid])).rows) await attestRoutingFixture(row.id)
|
for (const row of (await pool.query("SELECT id FROM mcp_routing_observations WHERE src_server_id=$1", [sid])).rows) await attestRoutingFixture(row.id)
|
||||||
}
|
}
|
||||||
// Persistent enabled rows here are synthetic authority fixtures only. The
|
// Seed authority for fault cases. The public save/approval path is
|
||||||
// public scoped controller enable guard is not removed or exercised.
|
// exercised separately below without inserting a profile directly.
|
||||||
const persistentFixture = async (config = profile("a")) => {
|
const persistentFixture = async (config = profile("a")) => {
|
||||||
const id = randomUUID(), current = await effectiveMcpConnection(principal)
|
const id = randomUUID(), current = await effectiveMcpConnection(principal)
|
||||||
const expiry = new Date(Math.min(Date.now() + 600000, current.owner_valid_until.getTime(), Date.parse(principal.credentialExpiresAt), Date.parse(current.policy.expiresAt)))
|
const expiry = new Date(Math.min(Date.now() + 600000, current.owner_valid_until.getTime(), Date.parse(principal.credentialExpiresAt), Date.parse(current.policy.expiresAt)))
|
||||||
@@ -242,33 +266,197 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options:
|
|||||||
return { id, version: 1 }
|
return { id, version: 1 }
|
||||||
}
|
}
|
||||||
const disablePersistent = (id: string) => pool.query("UPDATE mcp_routing_profiles SET enabled=false WHERE id=$1", [id])
|
const disablePersistent = (id: string) => pool.query("UPDATE mcp_routing_profiles SET enabled=false WHERE id=$1", [id])
|
||||||
|
const executeWithCheckpointObservation = async (planId: string, check: (operationId: string) => Promise<void>) => {
|
||||||
|
const observer = observeCommittedScopedCheckpoint(pool, check, {
|
||||||
|
databaseUrl: process.env.DATABASE_URL ?? "", stateDir: process.env.MCP_STATE_DIR ?? "",
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const operation = await execute(planId)
|
||||||
|
observer.assertCompleted()
|
||||||
|
return operation
|
||||||
|
} finally { observer.close() }
|
||||||
|
}
|
||||||
|
const acceptControllerFailover = async () => {
|
||||||
|
reset(); await refresh(); exposeEffectCatalogue = true; originalEffectCase = "transition"
|
||||||
|
controllerFailoverProbeSources = []
|
||||||
|
const profileId = randomUUID(), initialWrites = writes
|
||||||
|
persistentProfileIds.push(profileId)
|
||||||
|
try {
|
||||||
|
const configured = profile("a"), original = configured.candidates[0]
|
||||||
|
configured.candidates = [1, 10, 11, 9].map((gateway, index) => ({ ...original,
|
||||||
|
id: ["a", "b", "c", "d"][index], gateway: "192.0.2." + gateway,
|
||||||
|
gatewayRef: { resource: "ipv4_route", id: "*r" + gateway },
|
||||||
|
controlProbe: { ...original.controlProbe!, sourceAddress: "198.51.100." + [1, 11, 12, 13][index] },
|
||||||
|
}))
|
||||||
|
const analysis = await invokeMcpTool(principal, "mm_route_optimizer_analyze", { profile: configured }) as { currentCandidateId: string; ranking: Array<{ eligible: boolean; candidate: { id: string } }> }
|
||||||
|
assert.equal(analysis.currentCandidateId, "a")
|
||||||
|
assert.deepEqual(analysis.ranking.filter(row => row.eligible).map(row => row.candidate.id), ["a", "b", "c", "d"])
|
||||||
|
const enrollment = await invokeMcpTool(principal, "mm_route_profile_save_plan", {
|
||||||
|
profileId, profile: configured, enabled: true, expiresAt: new Date(Date.now() + 600000).toISOString(),
|
||||||
|
}) as { planId: string }
|
||||||
|
await approvePlan(enrollment.planId, writer, true)
|
||||||
|
assert.equal((await execute(enrollment.planId)).state, "applied")
|
||||||
|
const readController = async () => (await pool.query("SELECT enabled,state FROM mcp_routing_profiles WHERE id=$1", [profileId])).rows[0]
|
||||||
|
const failures = new Set<string>()
|
||||||
|
let switchPlanId: string | undefined
|
||||||
|
for (const [index, alternate] of [12, 13, 13, 12, 13].entries()) {
|
||||||
|
// Drive the normal scheduled entry point without waiting a minute.
|
||||||
|
await pool.query("UPDATE mcp_routing_profiles SET state=state-'probedAt' WHERE id=$1", [profileId])
|
||||||
|
controllerFailoverProbeSources.length = 0
|
||||||
|
// The previously passing reserve must be rechecked, and its prior
|
||||||
|
// pass cannot authorize failover if the current check fails.
|
||||||
|
controllerFailoverReserveHealthy = index !== 2
|
||||||
|
await tickRoutingProfiles()
|
||||||
|
const row = await readController()
|
||||||
|
assert.equal(row.enabled, true, String(row.state.pausedReason))
|
||||||
|
assert.deepEqual(controllerFailoverProbeSources, [1, 11, alternate].map(last => "198.51.100." + last))
|
||||||
|
failures.add(row.state.failureKey)
|
||||||
|
assert.equal(failures.size, index + 1, "Each counted failure must come from a distinct path check")
|
||||||
|
assert.equal(writes, initialWrites)
|
||||||
|
if (index < 4) {
|
||||||
|
assert.equal(row.state.failureCount, index + 1)
|
||||||
|
assert.equal(row.state.planId, undefined, "Earlier healthy reserve evidence cannot replace a fresh passing probe")
|
||||||
|
} else {
|
||||||
|
assert.equal(row.state.candidateId, "d")
|
||||||
|
switchPlanId = row.state.planId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(switchPlanId)
|
||||||
|
assert.equal((await loadPlan(switchPlanId)).state, "awaiting-approval")
|
||||||
|
await tickRoutingProfiles()
|
||||||
|
assert.equal(writes, initialWrites)
|
||||||
|
await approvePlan(switchPlanId, writer, true)
|
||||||
|
await tickRoutingProfiles()
|
||||||
|
const operationId: string = (await readController()).state.operationId
|
||||||
|
assert.ok(operationId)
|
||||||
|
assert.equal(await waitForOperation(operationId), "applied")
|
||||||
|
await tickRoutingProfiles()
|
||||||
|
const completed = await readController()
|
||||||
|
assert.equal(completed.enabled, true, String(completed.state.pausedReason))
|
||||||
|
assert.equal(completed.state.baseline.packet.candidateId, "d")
|
||||||
|
assert.equal(completed.state.baseline.packet.transition.operationId, operationId)
|
||||||
|
assert.equal(completed.state.failoverReserveCursor, undefined)
|
||||||
|
assert.equal(writes, initialWrites + 1)
|
||||||
|
const result = { accepted: true, stableRanking: ["a", "b", "c", "d"], selectedReserve: "d", probeCallsPerTick: 3,
|
||||||
|
distinctFailuresBeforePlan: failures.size, priorPassedProbeNotReused: true, humanApproval: true, nativeWrites: 1 }
|
||||||
|
console.log("Bounded controller reserve SQL/HTTP:", JSON.stringify(result))
|
||||||
|
return result
|
||||||
|
} finally { await disablePersistent(profileId); controllerFailoverProbeSources = undefined; controllerFailoverReserveHealthy = true }
|
||||||
|
}
|
||||||
|
if (options.controllerFailoverOnly) return { ...await acceptControllerFailover(), persistentAuthorityCases: 0 }
|
||||||
if (options.originalEffectsOnly) {
|
if (options.originalEffectsOnly) {
|
||||||
const cases: Array<Record<string, unknown>> = []
|
const cases: Array<Record<string, unknown>> = []
|
||||||
let publicManualRevision2: Record<string, unknown> | undefined
|
let publicManualRevision2: Record<string, unknown> | undefined
|
||||||
for (const scenario of ["transition", "no-op", "ambiguous", "public-manual-rev2"] as const) {
|
const delegatedAdmin: AuthUser = { ...writer, id: "scoped-delegated-approval-admin", permissions: [...new Set([...writer.permissions, "mm:mcp:admin", "mm:mcp:approve"])] }
|
||||||
|
const snapshotApprovalAndAdmission = async (planId: string) => (await pool.query(`SELECT
|
||||||
|
(SELECT count(*)::int FROM mcp_operations WHERE plan_id=$1) AS operations,
|
||||||
|
(SELECT count(*)::int FROM mcp_routing_forward_charges WHERE source_plan_id=$1) AS forward_charges,
|
||||||
|
(SELECT COALESCE(jsonb_object_agg(bucket,used),'{}'::jsonb) FROM mcp_budget_buckets WHERE connection_id=$2 AND kind='mutations') AS mutation_buckets,
|
||||||
|
(SELECT count(*)::int FROM mcp_audit WHERE connection_id=$2 AND action='plan.approve' AND payload->>'planId'=$1::text) AS approval_audit`, [planId, connectionId])).rows[0]
|
||||||
|
const assertOwnerApproval = async (planId: string) => {
|
||||||
|
const nativeBefore = { requests, writes }
|
||||||
|
const before = await snapshotApprovalAndAdmission(planId)
|
||||||
|
assert.equal(before.operations, 0); assert.equal(before.forward_charges, 0); assert.equal(before.approval_audit, 0)
|
||||||
|
await assert.rejects(approvePlan(planId, delegatedAdmin, true), (error: unknown) => error instanceof McpError && error.code === "PERMISSION_DENIED")
|
||||||
|
const denied = await loadPlan(planId)
|
||||||
|
assert.equal(denied.state, "awaiting-approval"); assert.equal(denied.approved_by, null)
|
||||||
|
assert.deepEqual(await snapshotApprovalAndAdmission(planId), before)
|
||||||
|
// Simulate a delegated approval stored by an older deployment.
|
||||||
|
await pool.query("UPDATE mcp_plans SET state='approved',approved_by=$2,approved_at=now() WHERE id=$1", [planId, delegatedAdmin.id])
|
||||||
|
try {
|
||||||
|
await assert.rejects(applyPlan(principal, planId, randomUUID()), (error: unknown) => error instanceof McpError && error.code === "APPROVAL_REQUIRED")
|
||||||
|
const stale = await loadPlan(planId)
|
||||||
|
assert.equal(stale.state, "approved"); assert.equal(stale.approved_by, delegatedAdmin.id)
|
||||||
|
assert.deepEqual(await snapshotApprovalAndAdmission(planId), before)
|
||||||
|
assert.deepEqual({ requests, writes }, nativeBefore)
|
||||||
|
} finally {
|
||||||
|
await pool.query("UPDATE mcp_plans SET state='awaiting-approval',approved_by=NULL,approved_at=NULL WHERE id=$1", [planId])
|
||||||
|
}
|
||||||
|
await approvePlan(planId, writer, true)
|
||||||
|
}
|
||||||
|
for (const scenario of ["transition", "no-op", "ambiguous", "baseline-drift", "public-manual-rev2"] as const) {
|
||||||
const manual = scenario === "public-manual-rev2"
|
const manual = scenario === "public-manual-rev2"
|
||||||
const effectCase = manual ? "transition" : scenario
|
const effectCase = manual || scenario === "baseline-drift" ? "transition" : scenario
|
||||||
reset(); await refresh(); originalEffectCase = effectCase
|
reset(); await refresh(); originalEffectCase = effectCase
|
||||||
const config = profile("a")
|
const config = profile("a")
|
||||||
for (const candidate of config.candidates) candidate.controlProbe!.sourceAddress = "198.51.100.1"
|
|
||||||
assert.equal(config.autoRecovery, false)
|
assert.equal(config.autoRecovery, false)
|
||||||
// The baseline and persistent authority are issued by the SAME actual
|
// The baseline and persistent authority are issued by the SAME actual
|
||||||
// fixed readers/CAS used by existing tests, not hand-written proofs.
|
// fixed readers/CAS used by existing tests, not hand-written proofs.
|
||||||
const persistent = manual ? undefined : await persistentFixture(config)
|
const persistent = manual ? undefined : await persistentFixture(config)
|
||||||
try {
|
try {
|
||||||
const { plan, input } = await prepare("a", persistent, config, manual)
|
const { plan, input, evidence } = await prepare("a", persistent, config, manual, manual ? delegatedAdmin : scenario === "transition" ? null : writer)
|
||||||
|
if (scenario === "transition") await assertOwnerApproval(plan.planId)
|
||||||
assert.equal(input.manualEffectPolicy, manual ? "observational-v1" : undefined)
|
assert.equal(input.manualEffectPolicy, manual ? "observational-v1" : undefined)
|
||||||
if (manual) assert.equal(input.persistentProfileAuthority, undefined)
|
if (manual) assert.equal(input.persistentProfileAuthority, undefined)
|
||||||
const sealedSelection = originalEffectSelectionSchema.parse(input.originalEffectSelection)
|
const sealedSelection = originalEffectSelectionSchema.parse(input.originalEffectSelection)
|
||||||
assert.equal(sealedSelection.status, effectCase === "ambiguous" ? "unknown" : "prepared")
|
assert.equal(sealedSelection.status, effectCase === "ambiguous" ? "unknown" : "prepared")
|
||||||
const storedPlan = await loadPlan(plan.planId)
|
const storedPlan = await loadPlan(plan.planId)
|
||||||
assert.equal(storedPlan.approved_by, writer.id)
|
assert.equal(storedPlan.approved_by, manual ? delegatedAdmin.id : writer.id)
|
||||||
|
if (manual) assert.equal(evidence.validUntil, undefined, "Manual scoped plans keep their existing preparation lifetime")
|
||||||
|
if (scenario === "transition") {
|
||||||
|
type ProbeRow = { created_at: Date; evidence: { observedAt: string } }
|
||||||
|
const probe: ProbeRow = (await pool.query<ProbeRow>(
|
||||||
|
"SELECT created_at,evidence FROM mcp_path_checks WHERE id=$1 AND connection_id=$2", [input.pathCheckId, connectionId])).rows[0]
|
||||||
|
const probeDeadline = Math.min(probe.created_at.getTime(), Date.parse(probe.evidence.observedAt)) + 90_000
|
||||||
|
const authorityDeadline = Date.parse(input.persistentProfileAuthority.packet.validUntil)
|
||||||
|
assert(probeDeadline < authorityDeadline, "The fixture must exercise the probe cap, not the authority cap")
|
||||||
|
assert.equal(evidence.validUntil, new Date(probeDeadline).toISOString())
|
||||||
|
assert.equal(storedPlan.expires_at.getTime(), probeDeadline)
|
||||||
|
|
||||||
|
// A newer successful observation cannot extend an already approved plan.
|
||||||
|
const newerProbe = await invokeMcpTool(principal, "mm_route_path_verify", { profile: config, candidateId: "b" }) as { id: string; status: string; observedAt: string }
|
||||||
|
assert.equal(newerProbe.status, "passed")
|
||||||
|
assert.notEqual(newerProbe.id, input.pathCheckId)
|
||||||
|
assert(Date.parse(newerProbe.observedAt) + 90_000 > probeDeadline + 1)
|
||||||
|
const nativeBefore = { requests, writes }, admissionBefore = await snapshotApprovalAndAdmission(plan.planId)
|
||||||
|
const refuseAfterProbeDeadline = async (code: string) => {
|
||||||
|
const realNow = Date.now
|
||||||
|
try {
|
||||||
|
Date.now = () => probeDeadline + 1
|
||||||
|
await assert.rejects(applyPlan(principal, plan.planId, randomUUID()), (error: unknown) => error instanceof McpError && error.code === code)
|
||||||
|
} finally { Date.now = realNow }
|
||||||
|
assert.deepEqual(await snapshotApprovalAndAdmission(plan.planId), admissionBefore)
|
||||||
|
assert.deepEqual({ requests, writes }, nativeBefore)
|
||||||
|
assert.equal((await loadPlan(plan.planId)).state, "approved")
|
||||||
|
}
|
||||||
|
await refuseAfterProbeDeadline("PLAN_EXPIRED")
|
||||||
|
|
||||||
|
// The old deployment stored only the authority deadline in evidence.
|
||||||
|
const legacyValidity = input.persistentProfileAuthority.packet.validUntil as string
|
||||||
|
const legacyEvidence = { sealed: await encryptSecret(JSON.stringify({ ...evidence, validUntil: legacyValidity })) }
|
||||||
|
await pool.query("UPDATE mcp_plans SET expires_at=$2,evidence=$3::jsonb WHERE id=$1", [plan.planId, legacyValidity, JSON.stringify(legacyEvidence)])
|
||||||
|
try {
|
||||||
|
await refuseAfterProbeDeadline("MEASUREMENT_UNKNOWN")
|
||||||
|
} finally {
|
||||||
|
await pool.query("UPDATE mcp_plans SET expires_at=$2,evidence=$3::jsonb WHERE id=$1", [plan.planId, storedPlan.expires_at, JSON.stringify(storedPlan.evidence)])
|
||||||
|
}
|
||||||
|
cases.push({ case: "expired-approved-probe", newerProbeCannotRenew: true, scopedPlanCode: "PLAN_EXPIRED", legacyPlanCode: "MEASUREMENT_UNKNOWN", nativeRequests: 0, nativeWrites: 0, admissions: 0 })
|
||||||
|
}
|
||||||
const publicProjection = JSON.stringify(await publicPlan(storedPlan))
|
const publicProjection = JSON.stringify(await publicPlan(storedPlan))
|
||||||
assert(!publicProjection.includes("originalEffectSelection"))
|
assert(!publicProjection.includes("originalEffectSelection"))
|
||||||
assert(!publicProjection.includes("manualEffectPolicy"))
|
assert(!publicProjection.includes("manualEffectPolicy"))
|
||||||
const beforeWrites = writes, wireStart = originalEffectWire.length
|
const beforeWrites = writes, wireStart = originalEffectWire.length
|
||||||
|
const configurationIntents = async () => (await pool.query(`SELECT count(*)::int AS n FROM mcp_audit
|
||||||
|
WHERE connection_id=$1 AND action='configuration.intent'`, [connectionId])).rows[0].n
|
||||||
|
const intentsBefore = await configurationIntents()
|
||||||
|
if (scenario === "baseline-drift") originalEffectCase = "no-op"
|
||||||
const operation = await execute(plan.planId)
|
const operation = await execute(plan.planId)
|
||||||
assert.equal(operation.state, "applied", "Optional effect unknown must preserve genuine manual configuration/probe acceptance")
|
if (scenario === "ambiguous" || scenario === "baseline-drift") {
|
||||||
|
assert.equal(operation.state, "unknown")
|
||||||
|
const { rows: [refusal] } = await pool.query("SELECT error_code FROM mcp_operations WHERE id=$1", [operation.id])
|
||||||
|
assert.equal(refusal.error_code, "MEASUREMENT_UNKNOWN")
|
||||||
|
assert.equal(writes, beforeWrites)
|
||||||
|
assert.equal(filters[0].rule, initial)
|
||||||
|
assert.equal(await configurationIntents(), intentsBefore)
|
||||||
|
const receiptCount: number = (await pool.query<{ n: number }>(`SELECT count(*)::int AS n FROM mcp_audit
|
||||||
|
WHERE connection_id=$1 AND action='routing.scoped.attempt' AND payload->>'receiptId'=$2`, [connectionId, input.receiptId])).rows[0].n
|
||||||
|
assert.equal(receiptCount, 0)
|
||||||
|
const runningWire = originalEffectWire.slice(wireStart)
|
||||||
|
assert.equal(runningWire.filter(row => row.method === "GET" && row.path === "/routing/route").length, scenario === "baseline-drift" ? 1 : 0)
|
||||||
|
cases.push({ case: scenario, state: operation.state, errorCode: refusal.error_code, nativeWrites: 0, attemptReceipts: 0 })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
assert.equal(operation.state, "applied")
|
||||||
assert.equal(writes - beforeWrites, 1)
|
assert.equal(writes - beforeWrites, 1)
|
||||||
const rows: Array<{ created_at: Date; state: string; operation_id: string; operation_version: number; step_state: string;
|
const rows: Array<{ created_at: Date; state: string; operation_id: string; operation_version: number; step_state: string;
|
||||||
result: { configurationVerified: boolean; controlProbeVerified: boolean; originalEffects: unknown } }> = (await pool.query(`SELECT o.created_at,o.state,p.operation_id,p.operation_version,s.state AS step_state,s.result
|
result: { configurationVerified: boolean; controlProbeVerified: boolean; originalEffects: unknown } }> = (await pool.query(`SELECT o.created_at,o.state,p.operation_id,p.operation_version,s.state AS step_state,s.result
|
||||||
@@ -294,8 +482,8 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options:
|
|||||||
console.log("Owned original effect diagnostic:", JSON.stringify({ case: scenario, state: operation.state,
|
console.log("Owned original effect diagnostic:", JSON.stringify({ case: scenario, state: operation.state,
|
||||||
status: report.status, reason: report.reason, rounds: report.rounds.length, budget: report.budget ?? null,
|
status: report.status, reason: report.reason, rounds: report.rounds.length, budget: report.budget ?? null,
|
||||||
ribBefore, ribAfter, runtimeNativeRequests: runningWire.length, lastNativePaths: runningWire.slice(-8).map(row => row.path) }))
|
ribBefore, ribAfter, runtimeNativeRequests: runningWire.length, lastNativePaths: runningWire.slice(-8).map(row => row.path) }))
|
||||||
assert.equal(ribBefore, effectCase === "ambiguous" ? 0 : 1)
|
assert.equal(ribBefore, 1)
|
||||||
assert.equal(ribAfter, effectCase === "ambiguous" ? 0 : 2)
|
assert.equal(ribAfter, 2)
|
||||||
assert.equal(report.status, effectCase === "transition" ? "observed-expected" : "unknown")
|
assert.equal(report.status, effectCase === "transition" ? "observed-expected" : "unknown")
|
||||||
assert.equal(report.switchEffectObserved, effectCase === "transition")
|
assert.equal(report.switchEffectObserved, effectCase === "transition")
|
||||||
assert.equal(report.logCoverage, "unknown"); assert.equal(report.routeOriginVerified, false)
|
assert.equal(report.logCoverage, "unknown"); assert.equal(report.routeOriginVerified, false)
|
||||||
@@ -354,24 +542,95 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options:
|
|||||||
assert.equal(repeated.state, "applied")
|
assert.equal(repeated.state, "applied")
|
||||||
assert.equal(Number((await pool.query("SELECT count(*) AS n FROM mcp_operations WHERE plan_id=$1", [plan.planId])).rows[0].n), 1)
|
assert.equal(Number((await pool.query("SELECT count(*) AS n FROM mcp_operations WHERE plan_id=$1", [plan.planId])).rows[0].n), 1)
|
||||||
assert.equal(writes - beforeWrites, 1); assert.equal(requests, repeatReads, "Completed approved plan cannot repeat native work")
|
assert.equal(writes - beforeWrites, 1); assert.equal(requests, repeatReads, "Completed approved plan cannot repeat native work")
|
||||||
// Preserve the existing scoped-controller activation guard. A real
|
// Link the applied operation as the controller does when it issues
|
||||||
// tick pauses HERE before effect dispatch; never call this an
|
// a plan. This checks both outcomes at the real worker boundary.
|
||||||
// effect-triggered scheduler pause or enable that unsupported lane.
|
await pool.query("UPDATE mcp_routing_profiles SET state=state || $2::jsonb WHERE id=$1", [persistent.id, JSON.stringify({ planId: plan.planId, candidateId: "b" })])
|
||||||
await tickRoutingProfiles()
|
await tickRoutingProfiles()
|
||||||
const paused = (await pool.query("SELECT enabled,state FROM mcp_routing_profiles WHERE id=$1", [persistent.id])).rows[0]
|
const completed = (await pool.query("SELECT enabled,state FROM mcp_routing_profiles WHERE id=$1", [persistent.id])).rows[0]
|
||||||
assert.equal(paused.enabled, false); assert.equal(typeof paused.state.pausedReason, "string")
|
if (effectCase === "transition") {
|
||||||
assert.deepEqual(paused.state.baseline, row.state.baseline)
|
assert.equal(completed.enabled, true)
|
||||||
|
assert.equal(completed.state.baseline.packet.transition.operationId, operation.id)
|
||||||
|
assert.equal(completed.state.expectedCandidateId, "b")
|
||||||
|
} else {
|
||||||
|
assert.equal(completed.enabled, false)
|
||||||
|
assert.equal(completed.state.pausedReason, "STATE_CONFLICT")
|
||||||
|
assert.deepEqual(completed.state.baseline, row.state.baseline)
|
||||||
|
}
|
||||||
cases.push({ case: effectCase, manualState: operation.state, configurationCheckpoint: true, actualPrivateExecutionOrigin: true,
|
cases.push({ case: effectCase, manualState: operation.state, configurationCheckpoint: true, actualPrivateExecutionOrigin: true,
|
||||||
effectStatus: report.status, observedFibTransition: report.switchEffectObserved, nativeRibBefore: ribBefore, nativeRibAfter: ribAfter,
|
effectStatus: report.status, observedFibTransition: report.switchEffectObserved, nativeRibBefore: ribBefore, nativeRibAfter: ribAfter,
|
||||||
sharedBudget: report.budget ?? null, effectBaselineAdvanceAccepted: effectCase === "transition", effectGateNativeReads: effectCase === "transition" ? "fresh-native-checked" : 0,
|
sharedBudget: report.budget ?? null, effectBaselineAdvanceAccepted: effectCase === "transition", effectGateNativeReads: effectCase === "transition" ? "fresh-native-checked" : 0,
|
||||||
actualTickPausedByExistingScopedGuard: true, tickEffectPauseAccepted: false, publicManualRevision2EffectOwnerAccepted: false })
|
tickEffectPauseAccepted: effectCase !== "transition", publicManualRevision2EffectOwnerAccepted: false })
|
||||||
} finally { if (persistent) await disablePersistent(persistent.id) }
|
} finally { if (persistent) await disablePersistent(persistent.id) }
|
||||||
}
|
}
|
||||||
assert(publicManualRevision2)
|
assert(publicManualRevision2)
|
||||||
|
reset(); await refresh(); originalEffectCase = "transition"; exposeEffectCatalogue = true
|
||||||
|
const controllerConfig = profile("a")
|
||||||
|
controllerConfig.confirmationSamples = 1
|
||||||
|
controllerConfig.weights = { ping: 100, speed: 0, loss: 0 }
|
||||||
|
controllerConfig.candidates[1].downstreamInterface = "target"
|
||||||
|
const fast = randomUUID()
|
||||||
|
await pool.query("UPDATE mcp_routing_observations SET ping_ms=100 WHERE src_server_id=$1 AND routing_table='tenant-a' AND src_interface='hop'", [sid])
|
||||||
|
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,'target','peer','ipv4','tenant-a',1,0,100,100,now(),now(),true,'fixture')", [fast, sid])
|
||||||
|
await refresh()
|
||||||
|
const activeProfileId = randomUUID(), beforeControllerWrites = writes
|
||||||
|
persistentProfileIds.push(activeProfileId)
|
||||||
|
const saveProfile = (config: typeof controllerConfig) => invokeMcpTool(principal, "mm_route_profile_save_plan", {
|
||||||
|
profileId: activeProfileId, profile: config, enabled: true, expiresAt: new Date(Date.now() + 600000).toISOString(),
|
||||||
|
}) as Promise<{ planId: string }>
|
||||||
|
for (const invalid of [
|
||||||
|
{ ...controllerConfig, bgpContext: undefined },
|
||||||
|
{ ...controllerConfig, autoRecovery: true },
|
||||||
|
{ ...controllerConfig, candidates: controllerConfig.candidates.map(candidate => ({ ...candidate, controlProbe: { ...candidate.controlProbe!, sourceAddress: undefined } })) },
|
||||||
|
]) {
|
||||||
|
await assert.rejects(saveProfile(invalid), (error: unknown) => error instanceof McpError && error.code === "UNSUPPORTED_CAPABILITY")
|
||||||
|
}
|
||||||
|
const savedProfile = await saveProfile(controllerConfig)
|
||||||
|
assert.equal((await loadPlan(savedProfile.planId)).state, "awaiting-approval")
|
||||||
|
await approvePlan(savedProfile.planId, writer, true)
|
||||||
|
assert.equal((await execute(savedProfile.planId)).state, "applied")
|
||||||
|
const readController = async () => (await pool.query("SELECT enabled,state FROM mcp_routing_profiles WHERE id=$1", [activeProfileId])).rows[0]
|
||||||
|
const enrolled = await readController()
|
||||||
|
assert.equal(enrolled.enabled, true); assert.equal(enrolled.state.baseline.packet.candidateId, "a")
|
||||||
|
controllerProbeUnknown = true
|
||||||
|
try { await tickRoutingProfiles() } finally { controllerProbeUnknown = false }
|
||||||
|
const waitingForProbe = await readController()
|
||||||
|
assert.equal(waitingForProbe.enabled, true, String(waitingForProbe.state.pausedReason))
|
||||||
|
assert.equal(waitingForProbe.state.planId, undefined)
|
||||||
|
assert.equal(waitingForProbe.state.pendingCandidateId, "b", String(waitingForProbe.state.reason))
|
||||||
|
assert.equal(writes, beforeControllerWrites)
|
||||||
|
// Let the next tick run without waiting for the production polling interval.
|
||||||
|
await pool.query("UPDATE mcp_routing_profiles SET state=state-'probedAt' WHERE id=$1", [activeProfileId])
|
||||||
|
await tickRoutingProfiles()
|
||||||
|
const proposed = await readController()
|
||||||
|
assert.equal(proposed.enabled, true, String(proposed.state.pausedReason))
|
||||||
|
assert.equal(proposed.state.candidateId, "b", String(proposed.state.reason))
|
||||||
|
const switchPlanId: string = proposed.state.planId
|
||||||
|
assert.equal((await loadPlan(switchPlanId)).state, "awaiting-approval")
|
||||||
|
await tickRoutingProfiles()
|
||||||
|
assert.equal(writes, beforeControllerWrites)
|
||||||
|
assert.equal((await pool.query("SELECT count(*)::int AS n FROM mcp_operations WHERE plan_id=$1", [switchPlanId])).rows[0].n, 0)
|
||||||
|
await approvePlan(switchPlanId, writer, true)
|
||||||
|
await tickRoutingProfiles()
|
||||||
|
const pendingSwitch = await readController()
|
||||||
|
const switchOperationId: string = pendingSwitch.state.operationId
|
||||||
|
assert.ok(switchOperationId, String(pendingSwitch.state.pausedReason))
|
||||||
|
assert.equal(await waitForOperation(switchOperationId), "applied")
|
||||||
|
await tickRoutingProfiles()
|
||||||
|
const switched = await readController()
|
||||||
|
assert.equal(switched.enabled, true, String(switched.state.pausedReason))
|
||||||
|
assert.equal(switched.state.planId, undefined)
|
||||||
|
assert.equal(switched.state.expectedCandidateId, "b")
|
||||||
|
assert.equal(switched.state.baseline.packet.candidateId, "b")
|
||||||
|
assert.equal(switched.state.baseline.packet.previousProof, enrolled.state.baseline.proof)
|
||||||
|
assert.equal(switched.state.baseline.packet.transition.operationId, switchOperationId)
|
||||||
|
assert.equal(switched.state.baseline.packet.transition.planId, switchPlanId)
|
||||||
|
assert.equal(writes - beforeControllerWrites, 1)
|
||||||
|
await disablePersistent(activeProfileId)
|
||||||
|
const boundedReserveFailover = await acceptControllerFailover()
|
||||||
assert.deepEqual((await pool.query("SELECT * FROM filter_rules WHERE server_id=$1 ORDER BY id", [sid])).rows, dbBefore)
|
assert.deepEqual((await pool.query("SELECT * FROM filter_rules WHERE server_id=$1 ORDER BY id", [sid])).rows, dbBefore)
|
||||||
const result = { accepted: true, cases, publicManualRevision2, persistentAuthorityCases: 0, realMcpDispatcher: true, realSql: true, routerOsTransport: "declared-synthetic-localhost-http",
|
const result = { accepted: true, cases, publicManualRevision2, boundedReserveFailover, persistentAuthorityCases: 0, realMcpDispatcher: true, realSql: true, routerOsTransport: "declared-synthetic-localhost-http",
|
||||||
genuineApprovedRunningOrigin: true, actualConfigurationCheckpoint: true, signedEffectTamperRejected: true,
|
genuineApprovedRunningOrigin: true, actualConfigurationCheckpoint: true, signedEffectTamperRejected: true,
|
||||||
originalAutoRecoveryEnabled: false, fullRoutingAccepted: false, scopedControllerEnabled: false, nativeChrAccepted: false, forwardingVerified: false }
|
originalAutoRecoveryEnabled: false, fullRoutingAccepted: false, scopedControllerEnabled: true, publicScopedControllerLifecycle: true, nativeChrAccepted: false, forwardingVerified: false }
|
||||||
console.log("Original effect SQL/runtime component:", JSON.stringify(result))
|
console.log("Original effect SQL/runtime component:", JSON.stringify(result))
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -522,10 +781,20 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options:
|
|||||||
const recovery = await invokeMcpTool(principal, "mm_operation_recovery_plan", { operationId: source.id }) as { planId: string; state: string }
|
const recovery = await invokeMcpTool(principal, "mm_operation_recovery_plan", { operationId: source.id }) as { planId: string; state: string }
|
||||||
assert.equal(recovery.state, "awaiting-approval")
|
assert.equal(recovery.state, "awaiting-approval")
|
||||||
assert.equal((await loadPlan(recovery.planId)).operation_id, "operations.recover")
|
assert.equal((await loadPlan(recovery.planId)).operation_id, "operations.recover")
|
||||||
assert.equal((await loadPlan(recovery.planId)).operation_version, 7)
|
assert.equal((await loadPlan(recovery.planId)).operation_version, 8)
|
||||||
await assert.rejects(applyPlan(principal, recovery.planId, randomUUID()))
|
await assert.rejects(applyPlan(principal, recovery.planId, randomUUID()))
|
||||||
assert.deepEqual(await snapshot(), afterForward, "No approval means no debit/charge/native write")
|
assert.deepEqual(await snapshot(), afterForward, "No approval means no debit/charge/native write")
|
||||||
await approvePlan(recovery.planId, writer, true)
|
await approvePlan(recovery.planId, writer, true)
|
||||||
|
if (chargeCase === "positive" && !options.lateInverseAuthorityChange) {
|
||||||
|
await pool.query("UPDATE mcp_plans SET operation_version=7 WHERE id=$1", [recovery.planId])
|
||||||
|
try {
|
||||||
|
await assert.rejects(applyPlan(principal, recovery.planId, randomUUID()), { code: "PLAN_EXPIRED" })
|
||||||
|
assert.equal((await pool.query("SELECT id FROM mcp_operations WHERE plan_id=$1", [recovery.planId])).rowCount, 0)
|
||||||
|
assert.deepEqual(await snapshot(), afterForward, "Superseded approved inverse cannot create an operation, debit or native write")
|
||||||
|
} finally {
|
||||||
|
await pool.query("UPDATE mcp_plans SET operation_version=8 WHERE id=$1", [recovery.planId])
|
||||||
|
}
|
||||||
|
}
|
||||||
if (chargeCase === "mixed-forward") {
|
if (chargeCase === "mixed-forward") {
|
||||||
assert(competingForward)
|
assert(competingForward)
|
||||||
const attempts = [
|
const attempts = [
|
||||||
@@ -649,9 +918,14 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options:
|
|||||||
assert.equal(afterInverse.forward, persistent ? 1 : 0); assert.equal(afterInverse.manualInverse, persistent ? 1 : 0); assert.equal(afterInverse.total, persistent ? 2 : 0)
|
assert.equal(afterInverse.forward, persistent ? 1 : 0); assert.equal(afterInverse.manualInverse, persistent ? 1 : 0); assert.equal(afterInverse.total, persistent ? 2 : 0)
|
||||||
assert.equal(afterInverse.reserved, 0); assert.equal(afterInverse.legacy, 0)
|
assert.equal(afterInverse.reserved, 0); assert.equal(afterInverse.legacy, 0)
|
||||||
assert.equal(afterInverse.writes, 2); if (chargeCase !== "unknown") assert.equal(inversePostChecks, 1)
|
assert.equal(afterInverse.writes, 2); if (chargeCase !== "unknown") assert.equal(inversePostChecks, 1)
|
||||||
const charges = (await pool.query("SELECT recovery_operation_id,recovery_plan_id,source_operation_id,profile_id,charge_units FROM mcp_routing_manual_inverse_charges WHERE source_operation_id=$1", [source.id])).rows
|
const charges = (await pool.query("SELECT recovery_operation_id,recovery_plan_id,source_operation_id,profile_id,charge_units,binding_sealed FROM mcp_routing_manual_inverse_charges WHERE source_operation_id=$1", [source.id])).rows
|
||||||
assert.equal(charges.length, persistent ? 1 : 0);
|
assert.equal(charges.length, persistent ? 1 : 0);
|
||||||
if (persistent) { assert.equal(charges[0].recovery_operation_id, inverse.id); assert.equal(charges[0].recovery_plan_id, inverse.plan_id); assert.equal(charges[0].profile_id,id); assert.equal(charges[0].charge_units,1) }
|
if (persistent) {
|
||||||
|
assert.equal(charges[0].recovery_operation_id, inverse.id); assert.equal(charges[0].recovery_plan_id, inverse.plan_id); assert.equal(charges[0].profile_id,id); assert.equal(charges[0].charge_units,1)
|
||||||
|
const binding = JSON.parse(await decryptSecret(charges[0].binding_sealed))
|
||||||
|
assert.equal(binding.operationVersion, 8)
|
||||||
|
assert.equal(binding.sourceVersion, 7)
|
||||||
|
}
|
||||||
const intent = (await pool.query("SELECT state,verified_sealed,recovery_operation_id,authority_profile_id,authority_profile_version,reservation_id FROM mcp_scoped_recovery_intents WHERE source_operation_id=$1 AND source_step_index=0", [source.id])).rows
|
const intent = (await pool.query("SELECT state,verified_sealed,recovery_operation_id,authority_profile_id,authority_profile_version,reservation_id FROM mcp_scoped_recovery_intents WHERE source_operation_id=$1 AND source_step_index=0", [source.id])).rows
|
||||||
assert.equal(intent.length, 1); assert.equal(intent[0].state, chargeCase === "unknown" ? "unknown" : "verified"); assert.equal(intent[0].recovery_operation_id, inverse.id)
|
assert.equal(intent.length, 1); assert.equal(intent[0].state, chargeCase === "unknown" ? "unknown" : "verified"); assert.equal(intent[0].recovery_operation_id, inverse.id)
|
||||||
assert.equal(intent[0].authority_profile_id, null); assert.equal(intent[0].authority_profile_version, persistent ? 1 : null); assert.equal(intent[0].reservation_id, null)
|
assert.equal(intent[0].authority_profile_id, null); assert.equal(intent[0].authority_profile_version, persistent ? 1 : null); assert.equal(intent[0].reservation_id, null)
|
||||||
@@ -703,156 +977,59 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options:
|
|||||||
if (options.forwardRoundsOnly) {
|
if (options.forwardRoundsOnly) {
|
||||||
const accepted: string[] = []
|
const accepted: string[] = []
|
||||||
for (const scenario of ["two-rounds", "native-drift", "checkpoint-tamper", "late-revoke", "row-budget"] as const) {
|
for (const scenario of ["two-rounds", "native-drift", "checkpoint-tamper", "late-revoke", "row-budget"] as const) {
|
||||||
console.log("Routing forward rounds stage:", scenario, "prepare")
|
reset(); await refresh()
|
||||||
filters.forEach(row => { row.rule = initial; row.opaque = "NEVER_SCOPED_OPAQUE" })
|
const persistent = await persistentFixture(), { plan, input } = await prepare("a", persistent)
|
||||||
pendingPostCheckpoint = false; pendingInversePost = false; forwardRoundBulk = 0
|
const beforeWrites = writes, wireStart = originalEffectWire.length
|
||||||
await pool.query("UPDATE mcp_routing_observations SET ping_at=now(),bandwidth_at=now(),loss_pct=0 WHERE src_server_id=$1", [sid])
|
let faultReached = false
|
||||||
for (const row of (await pool.query("SELECT id FROM mcp_routing_observations WHERE src_server_id=$1", [sid])).rows) await attestRoutingFixture(row.id)
|
|
||||||
// Same synthetic persistent authority fixture used by the original
|
|
||||||
// acceptance. CONFIG_APPLIED itself is produced ONLY by real runtime.
|
|
||||||
const profileId = randomUUID(), current = await effectiveMcpConnection(principal)
|
|
||||||
const expiry = new Date(Math.min(Date.now() + 600000, current.owner_valid_until.getTime(), Date.parse(principal.credentialExpiresAt), Date.parse(current.policy.expiresAt)))
|
|
||||||
// Explicit trusted test-host enrollment only. Public nonmain activation
|
|
||||||
// stays disabled. Real human-principal fixed native issuer and normal CAS
|
|
||||||
// supply the private baseline; no MAC/checkpoint is manufactured here.
|
|
||||||
await withApplicationMutationLock(() => withConfigMutationLock([sid], async () => {
|
|
||||||
const baseline=await prepareRoutingControllerBaseline(profileId,1,profile("a"),expiry.toISOString(),{principal,connection:current})
|
|
||||||
const saved=await saveControllerProfileCas(pool,{profileId,connectionId,expectedVersion:null,profile:profile("a"),enabled:true,expiresAt:expiry.toISOString(),credentialId:principal.credentialId,generation:await accessGeneration(),policyVersion:current.policy.version,baseline})
|
|
||||||
assert.equal(saved?.version,1)
|
|
||||||
}))
|
|
||||||
persistentProfileIds.push(profileId)
|
|
||||||
const { plan } = await prepare("a", { id: profileId, version: 1 }), beforeWrites = writes
|
|
||||||
let checked = false, callbackStage = "not-entered"
|
|
||||||
const startedAt = Date.now()
|
|
||||||
const sourceDiagnostic = async (operationId: string, stage: string) => {
|
|
||||||
const row = (await pool.query(`SELECT o.state,o.error_code,s.state AS step_state,s.result IS NULL AS result_null,
|
|
||||||
p.operation_id,p.operation_version,p.approved_by IS NOT NULL AS approved,
|
|
||||||
o.created_at,p.expires_at FROM mcp_operations o JOIN mcp_plans p ON p.id=o.plan_id
|
|
||||||
JOIN mcp_operation_steps s ON s.operation_id=o.id AND s.step_index=0 WHERE o.id=$1`, [operationId])).rows[0]
|
|
||||||
console.log("Routing forward rounds source:", JSON.stringify({ scenario, stage, elapsedMs: Date.now() - startedAt, ...row }))
|
|
||||||
return row
|
|
||||||
}
|
|
||||||
const observeConfiguration = async (operationId: string) => {
|
|
||||||
callbackStage = "outside-http-before-constructor"
|
|
||||||
const source = await sourceDiagnostic(operationId, callbackStage)
|
|
||||||
const action = { principal, connection: await effectiveMcpConnection(principal) }
|
|
||||||
const session = await createRoutingNetworkNativeSession({ sourceOperationId: operationId, context: action,
|
|
||||||
authorize: async () => {}, deadline: Math.min(source.created_at.getTime() + 115000, Date.now() + 90000), manifest: { servers: [sid], probes: [] } })
|
|
||||||
await sourceDiagnostic(operationId, "after-constructor")
|
|
||||||
let configuration: Awaited<ReturnType<typeof resolveRoutingNetworkForwardRoundConfigurationContext>> | undefined
|
|
||||||
const saved = (await pool.query("SELECT configuration_proof FROM mcp_operation_checkpoints WHERE operation_id=$1", [operationId])).rows[0]
|
|
||||||
const originalRule = filters[0].rule
|
|
||||||
try {
|
try {
|
||||||
callbackStage = "baseline-snapshot"
|
|
||||||
await session.readSnapshot("before")
|
|
||||||
callbackStage = "resolve-configuration-capability"
|
|
||||||
configuration = await resolveRoutingNetworkForwardRoundConfigurationContext(session.sharedContext, action)
|
|
||||||
await assert.rejects(resolveRoutingNetworkForwardRoundConfigurationContext(session.sharedContext, { ...action, principal: { ...principal, connectionId: randomUUID() } }))
|
|
||||||
// The previous mismatched-principal admission does not own/close
|
|
||||||
// this session; the genuine capability is still the sole owner.
|
|
||||||
if (scenario === "row-budget") forwardRoundBulk = 1000
|
if (scenario === "row-budget") forwardRoundBulk = 1000
|
||||||
callbackStage = "first-bound-round"
|
const operation = await executeWithCheckpointObservation(plan.planId, async operationId => {
|
||||||
const first = await session.readConfigurationBoundForwardSnapshot(configuration)
|
const reads = requests, connection = await effectiveMcpConnection(principal)
|
||||||
assert.equal(first.round, 1); assert.equal(first.securityProof, false); assert.equal(first.forwardingVerified, false)
|
await assert.rejects(resolveRoutingNetworkConfigurationContext(operationId, {
|
||||||
assert.equal(first.logCoverage, "unknown"); assert.equal(first.configurationReadbacks.before.operationId, operationId)
|
principal: { ...principal, connectionId: randomUUID() }, connection,
|
||||||
assert.equal(first.configurationReadbacks.after.sharedNetworkBudgetVerified, true)
|
}))
|
||||||
assert.equal(first.budget.calls, session.readBudget().calls); assert.equal(first.budget.rows, session.readBudget().rows)
|
assert.equal(requests, reads, "A mismatched principal cannot read native state")
|
||||||
if (["native-drift", "checkpoint-tamper", "late-revoke"].includes(scenario)) afterRoundLogRead = async () => {
|
if (scenario !== "two-rounds") afterRoundLogRead = async () => {
|
||||||
|
faultReached = true
|
||||||
if (scenario === "native-drift") filters[0].rule = "reject;"
|
if (scenario === "native-drift") filters[0].rule = "reject;"
|
||||||
if (scenario === "checkpoint-tamper") await pool.query("UPDATE mcp_operation_checkpoints SET configuration_proof=$2 WHERE operation_id=$1", [operationId, "0".repeat(64)])
|
if (scenario === "checkpoint-tamper") await pool.query("UPDATE mcp_operation_checkpoints SET configuration_proof=$2 WHERE operation_id=$1", [operationId, "0".repeat(64)])
|
||||||
if (scenario === "late-revoke") await pool.query("UPDATE mcp_routing_profiles SET enabled=false WHERE id=$1", [profileId])
|
if (scenario === "late-revoke") await disablePersistent(persistent.id)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
assert.equal(faultReached, scenario !== "two-rounds")
|
||||||
|
const wire = originalEffectWire.slice(wireStart)
|
||||||
|
const originalRib = wire.filter(row => row.method === "GET" && row.path === "/routing/route" && !row.afterPatch)
|
||||||
|
const forwardRib = wire.filter(row => row.method === "GET" && row.path === "/routing/route" && row.afterPatch)
|
||||||
|
assert.equal(originalRib.length, 1); assert.equal(writes, beforeWrites + 1)
|
||||||
|
const { rows: [terminal] } = await pool.query("SELECT o.error_code,s.result FROM mcp_operations o JOIN mcp_operation_steps s ON s.operation_id=o.id AND s.step_index=0 WHERE o.id=$1", [operation.id])
|
||||||
if (scenario === "two-rounds" || scenario === "row-budget") {
|
if (scenario === "two-rounds" || scenario === "row-budget") {
|
||||||
callbackStage = "second-bound-round"
|
assert.equal(operation.state, "applied"); assert.equal(forwardRib.length, 2)
|
||||||
const second = await session.readConfigurationBoundForwardSnapshot(configuration)
|
const report = originalEffectReportSchema.parse(terminal.result.originalEffects)
|
||||||
assert.equal(second.round, 2); assert(second.budget.calls > first.budget.calls)
|
const network = await loadScopedNetworkCheckpoint(input, await assertScopedCommunityRequest(input))
|
||||||
assert.equal(second.budget.calls, session.readBudget().calls); assert.equal(second.budget.rows, session.readBudget().rows)
|
assert(network); assert.equal(network.status, "passed"); assert.deepEqual(network.originalEffects, report)
|
||||||
if (scenario === "row-budget") {
|
assert.equal(report.semanticProof, false); assert.equal(report.forwardingVerified, false); assert.equal(report.logCoverage, "unknown")
|
||||||
await assert.rejects(session.readConfigurationBoundForwardSnapshot(configuration), (error: unknown) => (error as { code?: string }).code === "LIMIT_EXCEEDED")
|
if (scenario === "two-rounds") {
|
||||||
assert(session.readBudget().rows > 10000)
|
assert.equal(report.status, "observed-expected"); assert.equal(report.rounds.length, 2)
|
||||||
|
assert(report.budget && report.budget.calls > 0 && report.budget.calls <= 256 && report.budget.rows <= 10000)
|
||||||
|
} else {
|
||||||
|
assert.equal(report.status, "unknown"); assert.equal(report.rounds.length, 0)
|
||||||
|
assert.equal(report.reason, "original-effect-rounds-unavailable:LIMIT_EXCEEDED")
|
||||||
|
assert.equal(report.budget, undefined)
|
||||||
}
|
}
|
||||||
} else await assert.rejects(session.readConfigurationBoundForwardSnapshot(configuration))
|
} else {
|
||||||
if (scenario !== "two-rounds") {
|
assert.equal(operation.state, "unknown"); assert.equal(forwardRib.length, 1)
|
||||||
assert.equal(session.readBudget().closed, true)
|
assert.equal(terminal.error_code, "MUTATION_OUTCOME_UNKNOWN")
|
||||||
const stopped = requests
|
|
||||||
await assert.rejects(session.readSnapshot("after-forward")); assert.equal(requests, stopped)
|
|
||||||
}
|
}
|
||||||
assert.equal(writes, beforeWrites + 1); checked = true; callbackStage = "checked"
|
const readsBeforeReplay = requests
|
||||||
|
const replay = await applyPlan(principal, plan.planId, randomUUID())
|
||||||
|
assert.equal(replay.id, operation.id); assert.equal(requests, readsBeforeReplay); assert.equal(writes, beforeWrites + 1)
|
||||||
|
accepted.push(scenario)
|
||||||
|
console.log(JSON.stringify({ scenario, state: operation.state, errorCode: terminal.error_code, forwardRibReads: forwardRib.length, nativeWrites: 1 }))
|
||||||
} finally {
|
} finally {
|
||||||
afterRoundLogRead = undefined; forwardRoundBulk = 0
|
afterRoundLogRead = undefined; forwardRoundBulk = 0
|
||||||
filters[0].rule = originalRule
|
await disablePersistent(persistent.id)
|
||||||
await pool.query("UPDATE mcp_operation_checkpoints SET configuration_proof=$2 WHERE operation_id=$1", [operationId, saved.configuration_proof])
|
|
||||||
await pool.query("UPDATE mcp_routing_profiles SET enabled=true WHERE id=$1", [profileId])
|
|
||||||
if (configuration) closeRoutingNetworkForwardRoundConfigurationContext(configuration)
|
|
||||||
session.close()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Capture only a real row lock inside the post-CONFIG_APPLIED HTTP
|
|
||||||
// callback, then return its response immediately. The long acquisition
|
|
||||||
// runs outside HTTP while the genuine generic runtime UPDATE waits.
|
|
||||||
let blocker: PoolClient | undefined, blockerPid: number | undefined, capturedOperationId: string | undefined, admissionError: unknown
|
|
||||||
afterConfigurationCheckpoint = async operationId => {
|
|
||||||
try {
|
|
||||||
blocker = await pool.connect()
|
|
||||||
await blocker.query("BEGIN"); await blocker.query("SET LOCAL lock_timeout='3s'")
|
|
||||||
const held = await blocker.query(`SELECT s.operation_id FROM mcp_operation_steps s JOIN mcp_operations o ON o.id=s.operation_id
|
|
||||||
WHERE s.operation_id=$1 AND s.step_index=0 AND o.state='running' AND s.state='intent' AND s.result IS NULL FOR UPDATE OF s`, [operationId])
|
|
||||||
assert.equal(held.rowCount, 1, "Only the genuine running intent with null result can be held")
|
|
||||||
blockerPid = Number((await blocker.query("SELECT pg_backend_pid() AS pid")).rows[0].pid)
|
|
||||||
capturedOperationId = operationId; callbackStage = "row-lock-captured-http-returning"
|
|
||||||
console.log("Routing forward rounds stage:", scenario, callbackStage, "elapsedMs", Date.now() - startedAt)
|
|
||||||
} catch (error) { admissionError = error }
|
|
||||||
}
|
|
||||||
console.log("Routing forward rounds stage:", scenario, "apply")
|
|
||||||
let operationOutcome: { ok: true; value: Awaited<ReturnType<typeof execute>> } | { ok: false; error: unknown } | undefined
|
|
||||||
const execution = execute(plan.planId).then(value => ({ ok: true as const, value }), error => ({ ok: false as const, error }))
|
|
||||||
let settled = false
|
|
||||||
void execution.then(() => { settled = true })
|
|
||||||
try {
|
|
||||||
const admissionDeadline = Date.now() + 60000
|
|
||||||
while (true) {
|
|
||||||
if (admissionError) throw admissionError
|
|
||||||
if (settled) {
|
|
||||||
const terminal = await execution
|
|
||||||
if (!terminal.ok) throw terminal.error
|
|
||||||
await sourceDiagnostic(terminal.value.id, "terminated-before-row-lock-observation")
|
|
||||||
assert.fail("Original operation ended before its genuine running intent could be observed")
|
|
||||||
}
|
|
||||||
if (capturedOperationId && blockerPid !== undefined) {
|
|
||||||
const waiters = await pool.query(`SELECT pid FROM pg_stat_activity WHERE datname=current_database() AND usename='mcp_test'
|
|
||||||
AND state='active' AND wait_event_type='Lock' AND query ~* '^UPDATE mcp_operation_steps SET state='
|
|
||||||
AND $1::int=ANY(pg_blocking_pids(pid))`, [blockerPid])
|
|
||||||
if (waiters.rowCount) break
|
|
||||||
}
|
|
||||||
assert(Date.now() < admissionDeadline, "Generic runtime did not reach its real source-step UPDATE behind the owned row lock")
|
|
||||||
await delay(20)
|
|
||||||
}
|
|
||||||
assert(capturedOperationId)
|
|
||||||
await sourceDiagnostic(capturedOperationId, "generic-step-update-blocked-no-http-pending")
|
|
||||||
await observeConfiguration(capturedOperationId)
|
|
||||||
} finally {
|
|
||||||
afterConfigurationCheckpoint = undefined
|
|
||||||
// A row lock is only an observation fixture. It never changes a
|
|
||||||
// state/result or renews authority, deadline, intent or checkpoint.
|
|
||||||
try {
|
|
||||||
if (blocker) { try { await blocker.query("ROLLBACK") } finally { blocker.release() } }
|
|
||||||
} finally { operationOutcome = await execution }
|
|
||||||
}
|
|
||||||
assert(operationOutcome)
|
|
||||||
if (!operationOutcome.ok) throw operationOutcome.error
|
|
||||||
const operation = operationOutcome.value
|
|
||||||
const diagnostic = (await pool.query(`SELECT o.state,o.error_code,o.verification,
|
|
||||||
EXISTS(SELECT 1 FROM mcp_operation_checkpoints c WHERE c.operation_id=o.id AND c.configuration_sealed IS NOT NULL) AS configuration_checkpoint,
|
|
||||||
ARRAY(SELECT s.state FROM mcp_operation_steps s WHERE s.operation_id=o.id ORDER BY s.step_index) AS step_states
|
|
||||||
FROM mcp_operations o WHERE o.id=$1`, [operation.id])).rows[0]
|
|
||||||
// Fixed stage labels, counters and generic persisted status only. Never
|
|
||||||
// log native bodies, endpoint credentials or encrypted plan contents.
|
|
||||||
console.log("Routing forward rounds terminal:", JSON.stringify({ scenario, callbackStage, checked,
|
|
||||||
writesSinceApply: writes - beforeWrites, ...diagnostic }))
|
|
||||||
assert.equal(checked, true); assert.equal(operation.state, "applied"); assert.equal(writes, beforeWrites + 1)
|
|
||||||
await pool.query("UPDATE mcp_routing_profiles SET enabled=false WHERE id=$1", [profileId])
|
|
||||||
accepted.push(scenario)
|
|
||||||
}
|
|
||||||
console.log(JSON.stringify({ forwardRoundCases: accepted, actualGenuineConfigApplied: true, sharedBudget: true,
|
console.log(JSON.stringify({ forwardRoundCases: accepted, actualGenuineConfigApplied: true, sharedBudget: true,
|
||||||
securityProof: false, logCoverage: "unknown", forwardingVerified: false }))
|
securityProof: false, logCoverage: "unknown", forwardingVerified: false }))
|
||||||
return { accepted: true, forwardRoundCases: accepted, securityProof: false, forwardingVerified: false }
|
return { accepted: true, forwardRoundCases: accepted, securityProof: false, forwardingVerified: false }
|
||||||
@@ -1092,45 +1269,60 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options:
|
|||||||
reset(); await refresh()
|
reset(); await refresh()
|
||||||
{
|
{
|
||||||
const persistent = await persistentFixture(), { plan, input } = await prepare("a", persistent), before = writes
|
const persistent = await persistentFixture(), { plan, input } = await prepare("a", persistent), before = writes
|
||||||
const op = await execute(plan.planId); assert.equal(op.state, "applied"); assert.equal(writes, before + 1)
|
const checkBridge = async (operationId: string) => {
|
||||||
|
const action = { principal, connection: await effectiveMcpConnection(principal) }
|
||||||
|
const bridge = await resolveRoutingNetworkConfigurationContext(operationId, action)
|
||||||
|
const current = await consumeRoutingNetworkConfigurationContext(bridge)
|
||||||
|
assert.equal(current.operationId, operationId); assert.equal(current.securityProof, false); assert.equal(current.sharedNetworkBudgetVerified, false)
|
||||||
|
assert.equal(current.configuration.nativeId, input.nativeId); assert.equal(current.configuration.dependencyIdentity, input.dependencyProof)
|
||||||
|
const afterReads = requests
|
||||||
|
await assert.rejects(consumeRoutingNetworkConfigurationContext(bridge))
|
||||||
|
await assert.rejects(consumeRoutingNetworkConfigurationContext(JSON.parse(JSON.stringify(bridge))))
|
||||||
|
await assert.rejects(resolveRoutingNetworkConfigurationContext(operationId, action), /source already issued/)
|
||||||
|
assert.equal(requests, afterReads); assert.equal(writes, before + 1)
|
||||||
|
}
|
||||||
|
// The production forward rounds use the same one-time bridge issuance.
|
||||||
|
// Exercise the independent bridge at the committed checkpoint first.
|
||||||
|
const op = await executeWithCheckpointObservation(plan.planId, checkBridge)
|
||||||
|
assert.equal(op.state, "applied"); assert.equal(writes, before + 1)
|
||||||
const row = (await pool.query("SELECT c.configuration_sealed,s.result FROM mcp_operation_checkpoints c JOIN mcp_operation_steps s USING(operation_id,step_index) WHERE c.operation_id=$1", [op.id])).rows[0]
|
const row = (await pool.query("SELECT c.configuration_sealed,s.result FROM mcp_operation_checkpoints c JOIN mcp_operation_steps s USING(operation_id,step_index) WHERE c.operation_id=$1", [op.id])).rows[0]
|
||||||
const packet = JSON.parse(await decryptSecret(row.configuration_sealed))
|
const packet = JSON.parse(await decryptSecret(row.configuration_sealed))
|
||||||
assert.equal(packet.revision, 2); assert.equal(row.result.proofRevision, 3)
|
assert.equal(packet.revision, 2); assert.equal(row.result.proofRevision, 3)
|
||||||
assert.equal(fingerprint(packet.persistentProfileAuthority), fingerprint(input.persistentProfileAuthority))
|
assert.equal(fingerprint(packet.persistentProfileAuthority), fingerprint(input.persistentProfileAuthority))
|
||||||
assert.equal(packet.persistentProfileAuthority.packet.credentialId, principal.credentialId)
|
assert.equal(packet.persistentProfileAuthority.packet.credentialId, principal.credentialId)
|
||||||
assert.equal((await reconcile(op.id)).matchesDesiredState, true)
|
assert.equal((await reconcile(op.id)).matchesDesiredState, true)
|
||||||
const bridge = await resolveRoutingNetworkConfigurationContext(op.id, { principal, connection: await effectiveMcpConnection(principal) })
|
|
||||||
const current = await consumeRoutingNetworkConfigurationContext(bridge)
|
|
||||||
assert.equal(current.operationId, op.id); assert.equal(current.securityProof, false); assert.equal(current.sharedNetworkBudgetVerified, false)
|
|
||||||
assert.equal(current.configuration.nativeId, packet.nativeId); assert.equal(current.configuration.dependencyIdentity, packet.dependencyProof)
|
|
||||||
const afterReads = requests
|
|
||||||
await assert.rejects(consumeRoutingNetworkConfigurationContext(bridge))
|
|
||||||
await assert.rejects(consumeRoutingNetworkConfigurationContext(JSON.parse(JSON.stringify(bridge))))
|
|
||||||
assert.equal(requests, afterReads); assert.equal(writes, before + 1)
|
|
||||||
persistentCases.push("genuine-configuration-bridge-one-consume-no-clone-grant")
|
persistentCases.push("genuine-configuration-bridge-one-consume-no-clone-grant")
|
||||||
await disablePersistent(persistent.id); persistentCases.push("persistent-forward-request3-checkpoint2")
|
await disablePersistent(persistent.id); persistentCases.push("persistent-forward-request3-checkpoint2")
|
||||||
}
|
}
|
||||||
for (const change of ["closed", "disabled", "checkpoint-tamper"] as const) {
|
for (const change of ["closed", "disabled", "checkpoint-tamper"] as const) {
|
||||||
reset(); await refresh()
|
reset(); await refresh()
|
||||||
const persistent = await persistentFixture(), { plan } = await prepare("a", persistent), before = writes
|
const persistent = await persistentFixture(), { plan } = await prepare("a", persistent), before = writes
|
||||||
const op = await execute(plan.planId); assert.equal(op.state, "applied"); assert.equal(writes, before + 1)
|
const op = await executeWithCheckpointObservation(plan.planId, async operationId => {
|
||||||
const bridge = await resolveRoutingNetworkConfigurationContext(op.id, { principal, connection: await effectiveMcpConnection(principal) })
|
const bridge = await resolveRoutingNetworkConfigurationContext(operationId, { principal, connection: await effectiveMcpConnection(principal) })
|
||||||
|
const { rows: [checkpoint] } = await pool.query("SELECT configuration_proof FROM mcp_operation_checkpoints WHERE operation_id=$1", [operationId])
|
||||||
|
try {
|
||||||
if (change === "closed") closeRoutingNetworkConfigurationContext(bridge)
|
if (change === "closed") closeRoutingNetworkConfigurationContext(bridge)
|
||||||
if (change === "disabled") await disablePersistent(persistent.id)
|
if (change === "disabled") await disablePersistent(persistent.id)
|
||||||
if (change === "checkpoint-tamper") await pool.query("UPDATE mcp_operation_checkpoints SET configuration_proof=$2 WHERE operation_id=$1", [op.id, "f".repeat(64)])
|
if (change === "checkpoint-tamper") await pool.query("UPDATE mcp_operation_checkpoints SET configuration_proof=$2 WHERE operation_id=$1", [operationId, "f".repeat(64)])
|
||||||
const beforeReads = requests
|
const beforeReads = requests
|
||||||
await assert.rejects(consumeRoutingNetworkConfigurationContext(bridge))
|
await assert.rejects(consumeRoutingNetworkConfigurationContext(bridge))
|
||||||
assert.equal(requests, beforeReads); assert.equal(writes, before + 1)
|
assert.equal(requests, beforeReads); assert.equal(writes, before + 1)
|
||||||
|
} finally {
|
||||||
|
if (change === "disabled") await pool.query("UPDATE mcp_routing_profiles SET enabled=true WHERE id=$1", [persistent.id])
|
||||||
|
if (change === "checkpoint-tamper") await pool.query("UPDATE mcp_operation_checkpoints SET configuration_proof=$2 WHERE operation_id=$1", [operationId, checkpoint.configuration_proof])
|
||||||
|
closeRoutingNetworkConfigurationContext(bridge)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
assert.equal(op.state, "applied"); assert.equal(writes, before + 1)
|
||||||
await disablePersistent(persistent.id)
|
await disablePersistent(persistent.id)
|
||||||
persistentCases.push("genuine-configuration-bridge-denies:" + change)
|
persistentCases.push("genuine-configuration-bridge-denies:" + change)
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
reset(); await refresh()
|
reset(); await refresh()
|
||||||
const persistent = await persistentFixture(), { plan } = await prepare("a", persistent), before = writes
|
const persistent = await persistentFixture(), { plan } = await prepare("a", persistent), before = writes
|
||||||
let sharedChecks = 0
|
// The production effect reader already owns this operation's native
|
||||||
// This is an out-of-band running-checkpoint check, not RouterOS ping work.
|
// session. A second reader must fail before any native request.
|
||||||
// Do not put two full SQL/native reads inside the timed HTTP response.
|
const op = await executeWithCheckpointObservation(plan.planId, async operationId => {
|
||||||
const checkpointObserver = observeCommittedScopedCheckpoint(pool, async operationId => {
|
|
||||||
assert.equal((await pool.query("SELECT plan_id FROM mcp_operations WHERE id=$1", [operationId])).rows[0]?.plan_id, plan.planId)
|
assert.equal((await pool.query("SELECT plan_id FROM mcp_operations WHERE id=$1", [operationId])).rows[0]?.plan_id, plan.planId)
|
||||||
if(process.env.MMAPP_OWN_ROUTING_WORKER_REPRO==="true") {
|
if(process.env.MMAPP_OWN_ROUTING_WORKER_REPRO==="true") {
|
||||||
await (await import("./workers.js")).tickMcpWorkers()
|
await (await import("./workers.js")).tickMcpWorkers()
|
||||||
@@ -1139,23 +1331,22 @@ export async function acceptScopedRouteCommunitySwitch(actor: AuthUser, options:
|
|||||||
console.log("Worker exclusion preserved exact running CONFIG_APPLIED profile")
|
console.log("Worker exclusion preserved exact running CONFIG_APPLIED profile")
|
||||||
}
|
}
|
||||||
const action = { principal, connection: await effectiveMcpConnection(principal) }
|
const action = { principal, connection: await effectiveMcpConnection(principal) }
|
||||||
const session = await createRoutingNetworkNativeSession({ sourceOperationId: operationId, context: action,
|
// Reuse the approved plan's original deadline: now +90s would extend
|
||||||
authorize: async () => {}, deadline: Date.now() + 90000, manifest: { servers: [sid], probes: [] } })
|
// its preparation-time probe horizon and mask the duplicate-owner guard.
|
||||||
try {
|
const deadline = (await loadPlan(plan.planId)).expires_at.getTime()
|
||||||
const context = await resolveRoutingNetworkConfigurationContextFromSharedContext(session.sharedContext, action)
|
const reads = requests
|
||||||
const result = await consumeRoutingNetworkConfigurationContext(context)
|
await assert.rejects(createRoutingNetworkNativeSession({ sourceOperationId: operationId, context: action,
|
||||||
assert.equal(result.operationId, operationId); assert.equal(result.sharedNetworkBudgetVerified, true); assert.equal(result.securityProof, false)
|
authorize: async () => {}, deadline, manifest: { servers: [sid], probes: [] } }), /Source native session уже создавалась/)
|
||||||
const budget = session.readBudget()
|
assert.equal(requests, reads); assert.equal(writes, before + 1)
|
||||||
assert(budget.calls > 0 && budget.calls <= 256); assert(budget.bytes > 0 && budget.rows > 0)
|
})
|
||||||
assert.equal(budget.closed, false); assert.equal(writes, before + 1)
|
assert.equal(op.state, "applied"); assert.equal(writes, before + 1)
|
||||||
sharedChecks++
|
const { rows: [step] } = await pool.query("SELECT result FROM mcp_operation_steps WHERE operation_id=$1 AND step_index=0", [op.id])
|
||||||
} finally { session.close() }
|
const effects = originalEffectReportSchema.parse(step.result.originalEffects)
|
||||||
}, { databaseUrl: process.env.DATABASE_URL ?? "", stateDir: process.env.MCP_STATE_DIR ?? "" })
|
assert.equal(effects.status, "observed-expected")
|
||||||
try {
|
assert.equal(effects.rounds.length, 2)
|
||||||
const op = await execute(plan.planId)
|
assert.equal(effects.semanticProof, false)
|
||||||
checkpointObserver.assertCompleted()
|
assert(effects.budget && effects.budget.calls > 0 && effects.budget.calls <= 256)
|
||||||
assert.equal(op.state, "applied"); assert.equal(sharedChecks, 1); assert.equal(writes, before + 1)
|
assert(effects.budget.bytes > 0 && effects.budget.rows > 0)
|
||||||
} finally { checkpointObserver.close() }
|
|
||||||
await disablePersistent(persistent.id)
|
await disablePersistent(persistent.id)
|
||||||
persistentCases.push("genuine-running-config-bridge-uses-one-shared-native-owner")
|
persistentCases.push("genuine-running-config-bridge-uses-one-shared-native-owner")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
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. */
|
/** One request domain for forward execution and trusted recovery resolution. */
|
||||||
export async function assertScopedCommunityRequest(input: Record<string, unknown>) {
|
export async function assertScopedCommunityRequest(input: Record<string, unknown>) {
|
||||||
const { requestProof: _proof, ...request } = input
|
const { requestProof: _proof, ...request } = input
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createRoutingNetworkSessionForCurrentExecution } from "./routing-network-execution-session.js"
|
import { createRoutingNetworkSessionForCurrentExecution } from "./routing-network-execution-session.js"
|
||||||
import { readRoutingNetworkScopedCommunityContext } from "./routing-network-scoped-context.js"
|
import { readRoutingNetworkScopedCommunityContext } from "./routing-network-scoped-context.js"
|
||||||
import { resolveRoutingNetworkForwardRoundConfigurationContext, closeRoutingNetworkForwardRoundConfigurationContext } from "./routing-network-configuration-bridge.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 { randomUUID } from "node:crypto"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { routeAiProfileSchema } from "@mmapp/contracts/mcp-routing"
|
import { routeAiProfileSchema } from "@mmapp/contracts/mcp-routing"
|
||||||
@@ -12,7 +12,7 @@ import { withApplicationMutationLock, withConfigMutationLock } from "../../servi
|
|||||||
import { effectiveMcpConnection, audit } from "./store.js"
|
import { effectiveMcpConnection, audit } from "./store.js"
|
||||||
import { accessGeneration, decryptSecret, encryptSecret, fingerprint, McpError, privateSecretFingerprint, redact } from "./security.js"
|
import { accessGeneration, decryptSecret, encryptSecret, fingerprint, McpError, privateSecretFingerprint, redact } from "./security.js"
|
||||||
import { saveScopedConfigurationCheckpoint, loadScopedConfigurationCheckpoint, saveScopedNetworkCheckpoint, loadScopedNetworkCheckpoint } from "./routing-community-scoped-checkpoint.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 { prepareManagementTransportBinding } from "./management-transport.js"
|
||||||
import { readRouteCommunityContext } from "./routing-community-context.js"
|
import { readRouteCommunityContext } from "./routing-community-context.js"
|
||||||
import { candidateMatchesGateway } from "./routing-community-context-model.js"
|
import { candidateMatchesGateway } from "./routing-community-context-model.js"
|
||||||
@@ -37,12 +37,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)
|
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 })
|
const publicRule = (rule: { community: string; gateway: string; outIface: string }) => ({ community: rule.community, action: "route", gateway: rule.gateway, gatewayTunnelId: rule.outIface })
|
||||||
type ObservationPhase = "admission" | "configuration"
|
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") {
|
async function access(input: Record<string, unknown>, signal?: AbortSignal, verified = false, phase: ObservationPhase = "admission") {
|
||||||
if (signal?.aborted) throw fail("Операция отменена", "READ_CANCELLED")
|
if (signal?.aborted) throw fail("Операция отменена", "READ_CANCELLED")
|
||||||
if (verified) await assertVerifiedConfigurationAccess()
|
if (verified) await assertVerifiedConfigurationAccess()
|
||||||
@@ -58,7 +52,7 @@ async function access(input: Record<string, unknown>, signal?: AbortSignal, veri
|
|||||||
const ranked = rankRoutingCandidates(profile, await readRoutingObservations(ids))
|
const ranked = rankRoutingCandidates(profile, await readRoutingObservations(ids))
|
||||||
if (!ranked.find(row => row.candidate.id === input.candidateId)?.eligible) throw fail("Нет свежего SLA выбранного пути", "MEASUREMENT_UNKNOWN")
|
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 }
|
return { principal, current, profile, ids }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +111,8 @@ export async function prepareScopedCommunitySwitch(input: Record<string, unknown
|
|||||||
await access(request, action.signal)
|
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 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!
|
const value = await observe(withPath, action.signal), owned = value.context.ownedTarget!
|
||||||
if (!value.candidate.controlProbe) throw fail("Scoped переключение требует свежий control probe", "MEASUREMENT_UNKNOWN")
|
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 })
|
await requireRecentPath(value.profile, value.candidate.id, action.connection.id, { authorize: value.authorize, signal: action.signal })
|
||||||
@@ -149,9 +144,12 @@ export async function prepareScopedCommunitySwitch(input: Record<string, unknown
|
|||||||
sealed.requestProof = await privateSecretFingerprint(fingerprint(sealed), `route-ai-scoped-request-v${sealed.proofRevision}`)
|
sealed.requestProof = await privateSecretFingerprint(fingerprint(sealed), `route-ai-scoped-request-v${sealed.proofRevision}`)
|
||||||
await value.context.fence()
|
await value.context.fence()
|
||||||
await access(sealed, action.signal)
|
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,
|
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",
|
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.", "Неизвестный результат не повторяется; сверка только читает состояние."] }
|
warnings: ["Одна запись owned фильтра; SQL метаданные фильтров сохраняются.", "SLA и свежая preflight-проба обязательны до отправки PATCH. После записи подтверждение конфигурации сохраняется независимо от новых SLA-измерений; контрольная проба и её результат проверяются отдельно.", "Подтверждается конфигурация и control probe; сквозной и обратный трафик требуют отдельной приёмки.", managementBindingCiphertext ? "Заранее закреплён независимый транспорт для отдельного обратного плана; автоматический inverse не включён." : "Независимый transport не закреплён; отдельный обратный план потребует доступного primary.", "Неизвестный результат не повторяется; сверка только читает состояние."] }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +164,9 @@ export function registerScopedCommunityService() {
|
|||||||
}
|
}
|
||||||
await original(input, true)
|
await original(input, true)
|
||||||
if (await receiptExists(input, "routing.scoped.attempt")) throw fail("Повтор PATCH запрещён", "MUTATION_OUTCOME_UNKNOWN")
|
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!
|
const before = await original(input, true), owned = before.context.ownedTarget!
|
||||||
let effectSession: Awaited<ReturnType<typeof createRoutingNetworkSessionForCurrentExecution>> | undefined
|
let effectSession: Awaited<ReturnType<typeof createRoutingNetworkSessionForCurrentExecution>> | undefined
|
||||||
let effectBaseline: Parameters<typeof originalEffectBaselineMatches>[1] | undefined
|
let effectBaseline: Parameters<typeof originalEffectBaselineMatches>[1] | undefined
|
||||||
@@ -174,8 +174,8 @@ export function registerScopedCommunityService() {
|
|||||||
const effectSelection = originalEffectSelectionSchema.safeParse(input.originalEffectSelection)
|
const effectSelection = originalEffectSelectionSchema.safeParse(input.originalEffectSelection)
|
||||||
if (effectSelection.success) originalEffects = unknownOriginalEffects(effectSelection.data.status === "unknown" ? effectSelection.data.reason : "effect-acquisition-unavailable")
|
if (effectSelection.success) originalEffects = unknownOriginalEffects(effectSelection.data.status === "unknown" ? effectSelection.data.reason : "effect-acquisition-unavailable")
|
||||||
try {
|
try {
|
||||||
// Optional original-effect vector is never an extra write/recovery grant.
|
// The persistent controller needs a baseline it can advance after the
|
||||||
// Old manual configuration+probe acceptance remains authoritative.
|
// 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") {
|
if (context?.phase === "forward" && (input.proofRevision === 3 || input.proofRevision === 2 && input.manualEffectPolicy === "observational-v1") && effectSelection.success && effectSelection.data.status === "prepared") {
|
||||||
try {
|
try {
|
||||||
const selected = effectSelection.data
|
const selected = effectSelection.data
|
||||||
@@ -188,6 +188,11 @@ export function registerScopedCommunityService() {
|
|||||||
}
|
}
|
||||||
} catch { effectSession?.close(); effectSession = undefined }
|
} 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 () => {
|
await executeGuardedConfigurationWrite(before.profile.serverId, "PATCH", "/routing/filter/rule/" + owned.nativeId, async () => {
|
||||||
// This runs AFTER the durable configuration.intent, immediately before transport.
|
// This runs AFTER the durable configuration.intent, immediately before transport.
|
||||||
const fresh = await original(input, true); await before.context.fence()
|
const fresh = await original(input, true); await before.context.fence()
|
||||||
@@ -208,8 +213,7 @@ export function registerScopedCommunityService() {
|
|||||||
roundContext = await resolveRoutingNetworkForwardRoundConfigurationContext(effectSession.sharedContext, action)
|
roundContext = await resolveRoutingNetworkForwardRoundConfigurationContext(effectSession.sharedContext, action)
|
||||||
const first = await effectSession.readConfigurationBoundForwardSnapshot(roundContext)
|
const first = await effectSession.readConfigurationBoundForwardSnapshot(roundContext)
|
||||||
const second = await effectSession.readConfigurationBoundForwardSnapshot(roundContext)
|
const second = await effectSession.readConfigurationBoundForwardSnapshot(roundContext)
|
||||||
const observed = await inspectOriginalEffectRounds(effectSelection.data, [first, second], effectBaseline), budget = effectSession.readBudget()
|
originalEffects = await inspectOriginalEffectRounds(effectSelection.data, [first, second], effectBaseline)
|
||||||
originalEffects = { ...observed, budget: { deadline: budget.deadline, calls: budget.calls, bytes: budget.bytes, rows: budget.rows } }
|
|
||||||
} catch (error) { originalEffects = effectReadFailure("original-effect-rounds-unavailable", error) }
|
} catch (error) { originalEffects = effectReadFailure("original-effect-rounds-unavailable", error) }
|
||||||
finally { if (roundContext) closeRoutingNetworkForwardRoundConfigurationContext(roundContext) }
|
finally { if (roundContext) closeRoutingNetworkForwardRoundConfigurationContext(roundContext) }
|
||||||
}
|
}
|
||||||
@@ -221,8 +225,6 @@ export function registerScopedCommunityService() {
|
|||||||
const published = await readRoutingNetworkScopedCommunityContext(effectSession.sharedContext)
|
const published = await readRoutingNetworkScopedCommunityContext(effectSession.sharedContext)
|
||||||
if (!matches(input, published).configurationMatchesDesired) throw fail("Состояние изменилось после effect-check")
|
if (!matches(input, published).configurationMatchesDesired) throw fail("Состояние изменилось после effect-check")
|
||||||
await published.fence()
|
await published.fence()
|
||||||
const budget = effectSession.readBudget()
|
|
||||||
originalEffects = { ...originalEffects!, budget: { deadline: budget.deadline, calls: budget.calls, bytes: budget.bytes, rows: budget.rows } }
|
|
||||||
sharedPublication = true
|
sharedPublication = true
|
||||||
} catch (error) { originalEffects = effectReadFailure("effect-publication-revalidation-unavailable", error) }
|
} catch (error) { originalEffects = effectReadFailure("effect-publication-revalidation-unavailable", error) }
|
||||||
}
|
}
|
||||||
@@ -235,12 +237,16 @@ export function registerScopedCommunityService() {
|
|||||||
await final.context.fence()
|
await final.context.fence()
|
||||||
}
|
}
|
||||||
if (effectSession && originalEffects) {
|
if (effectSession && originalEffects) {
|
||||||
const budget = effectSession.readBudget()
|
const { deadline, calls, bytes, rows } = effectSession.readBudget()
|
||||||
originalEffects = { ...originalEffects, budget: { deadline: budget.deadline, calls: budget.calls, bytes: budget.bytes, rows: budget.rows } }
|
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)
|
await saveScopedNetworkCheckpoint(input, await sealedInput(input), { ...check, status: check.status }, originalEffects)
|
||||||
return verifiedResult(input)
|
return verifiedResult(input)
|
||||||
} catch { throw fail("Результат переключения не подтверждён; повтор запрещён, требуется сверка", "MUTATION_OUTCOME_UNKNOWN") }
|
} catch (error) {
|
||||||
|
if (!attemptRecorded) throw error
|
||||||
|
throw fail("Результат переключения не подтверждён; повтор запрещён, требуется сверка", "MUTATION_OUTCOME_UNKNOWN")
|
||||||
|
}
|
||||||
finally { effectSession?.close() }
|
finally { effectSession?.close() }
|
||||||
})),
|
})),
|
||||||
verify: async (input, result) => {
|
verify: async (input, result) => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { readFile } from "node:fs/promises"
|
|||||||
import { randomUUID } from "node:crypto"
|
import { randomUUID } from "node:crypto"
|
||||||
import ts from "typescript"
|
import ts from "typescript"
|
||||||
import { z } from "zod"
|
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"
|
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> }
|
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", {
|
const profileModule = await load("./routing-profiles.ts", {
|
||||||
"./routing-controller-overlap.js": overlap, "./security.js": { fingerprint, McpError, accessGeneration: async () => "g" },
|
"./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-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) },
|
"./service-registry.js": { registerMcpService: (name: string, handler: Handler) => services.set(name, handler) },
|
||||||
"./store.js": { audit: async () => {}, validatePrincipal: async () => connection }, "./routing-model.js": { decideRoutingSwitch: () => ({}) },
|
"./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-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") } },
|
"./routing-reservation-store.js": { createRoutingReservationStore: () => { throw new Error("Overlap-only fixture must not read or mutate routing charges") } },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ export function registerRoutingControls(register: typeof add) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
register("routing.vrf.update", "mm_vrf_plan", "Проверенный состав существующего VRF", "network", "mm:network:write", "write", "servers", z.object({ serverId, id: objectId, interfaces: z.array(text).min(1).max(100).refine(values => new Set(values).size === values.length) }).strict(), {
|
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) => {
|
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)
|
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 приложения")
|
if (!current || current.name === "main" || !hasManagedCommentPrefix(String(current.comment ?? ""))) throw new McpError("PROTECTED_OBJECT", "Изменять состав можно только у существующего VRF приложения")
|
||||||
|
|||||||
@@ -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 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 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 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 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};
|
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;
|
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};
|
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'));});
|
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=[
|
const mutations=[
|
||||||
['missing charge',f=>f.noCharge()],['duplicate charge',f=>f.duplicateCharge()],['reserved overlap',f=>f.reserved()],['ambiguous source',f=>f.ambiguousSource()],
|
['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'],
|
['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'],
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ function cursor(rows:unknown[],onFetch?:()=>void) {
|
|||||||
test("historical cursor authenticates more than two pages of genuine nonpersistent classifications",async()=>{
|
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
|
const dir=await mkdtemp(join(tmpdir(),"mm-manual-history-unit-")),previous=process.env.MCP_STATE_DIR;process.env.MCP_STATE_DIR=dir
|
||||||
try {
|
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)
|
const f=cursor(rows)
|
||||||
await assertLegacyManualHistoryWithinOwner(f.db,randomUUID(),at,()=>{})
|
await assertLegacyManualHistoryWithinOwner(f.db,randomUUID(),at,()=>{})
|
||||||
assert.equal(f.calls.filter(sql=>sql.startsWith("FETCH")).length,3)
|
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",()=>{})
|
await assertLegacyManualHistoryWithinOwner(cursor([f.row]).db,f.profileId,"2026-11-01T13:01:00.001Z",()=>{})
|
||||||
const expiredTamper={...f.row,source_evidence:null}
|
const expiredTamper={...f.row,source_evidence:null}
|
||||||
await assert.rejects(assertLegacyManualHistoryWithinOwner(cursor([expiredTamper]).db,f.profileId,"2026-11-01T14:00:00.000Z",()=>{}))
|
await assert.rejects(assertLegacyManualHistoryWithinOwner(cursor([expiredTamper]).db,f.profileId,"2026-11-01T14:00:00.000Z",()=>{}))
|
||||||
const current={...f.row,recovery_version:7}
|
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/)
|
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
|
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,()=>{}))
|
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"}
|
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})}
|
}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() {
|
async function retentionFixture(version = 7) {
|
||||||
const f=await fixture(true,7), authority=f.input.persistentProfileAuthority as {packet:{profileVersion:number;scope:number[]};proof:string}
|
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 payload=await encryptSecret(JSON.stringify({MODEL:true}))
|
||||||
const row={...f.row,recovery_payload:payload,authority_profile_version:1}
|
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,
|
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,
|
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,
|
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),
|
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})
|
recoveryPayloadProof:fingerprint(payload),scope:authority.packet.scope,createdAt:at,chargeUnits:1})
|
||||||
const receipt={binding,proof:await sign(binding,"routing-manual-inverse-charge-v1")}
|
const receipt={binding,proof:await sign(binding,"routing-manual-inverse-charge-v1")}
|
||||||
@@ -153,7 +155,8 @@ function retentionOwner(f:Awaited<ReturnType<typeof retentionFixture>>,present=t
|
|||||||
test('retention MODEL intent2 authenticates SQL clock after parent GC; unknown noReplay anchor survives',async()=>{
|
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
|
const dir=await mkdtemp(join(tmpdir(),'mm-intent2-retention-')),previous=process.env.MCP_STATE_DIR;process.env.MCP_STATE_DIR=dir
|
||||||
try {
|
try {
|
||||||
const f=await retentionFixture(),owner=retentionOwner(f),before=fingerprint(f.row)
|
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.
|
// SQL aging and the transaction owner below are MODELS, never genuine SQL.
|
||||||
await assertLegacyManualHistoryWithinOwner(owner.db,f.profileId,'2026-11-03T12:00:00.000Z',()=>{})
|
await assertLegacyManualHistoryWithinOwner(owner.db,f.profileId,'2026-11-03T12:00:00.000Z',()=>{})
|
||||||
assert.deepEqual(await retainManualInverseChargesWithinOwner(owner.db,1),{deleted:1})
|
assert.deepEqual(await retainManualInverseChargesWithinOwner(owner.db,1),{deleted:1})
|
||||||
@@ -164,6 +167,7 @@ test('retention MODEL intent2 authenticates SQL clock after parent GC; unknown n
|
|||||||
assert(candidate.indexOf("state IN('pending','running')")<candidate.indexOf('LIMIT 100'))
|
assert(candidate.indexOf("state IN('pending','running')")<candidate.indexOf('LIMIT 100'))
|
||||||
assert(candidate.includes('r.authority_profile_version IS NOT NULL'))
|
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))
|
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})}
|
}finally{if(previous===undefined)delete process.env.MCP_STATE_DIR;else process.env.MCP_STATE_DIR=previous;await rm(dir,{recursive:true,force:true})}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -182,6 +186,11 @@ test('retention MODEL refuses recent/future/malformed copy, present corrupt pare
|
|||||||
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,1),/MAC/);assert.equal(t.deleted.length,0)
|
||||||
const legacy=await fixture(true,7)
|
const legacy=await fixture(true,7)
|
||||||
await assert.rejects(assertLegacyManualHistoryWithinOwner(cursor([legacy.row]).db,legacy.profileId,'2026-11-03T12:00:00.000Z',()=>{}),/lacks durable/)
|
await assert.rejects(assertLegacyManualHistoryWithinOwner(cursor([legacy.row]).db,legacy.profileId,'2026-11-03T12:00:00.000Z',()=>{}),/lacks durable/)
|
||||||
|
const wrongVersion=await retentionFixture(7);wrongVersion.row.recovery_version=8
|
||||||
|
const mismatched=retentionOwner(wrongVersion)
|
||||||
|
await assert.rejects(assertLegacyManualHistoryWithinOwner(mismatched.db,wrongVersion.profileId,'2026-11-03T12:00:00.000Z',()=>{}),/signed allocation/)
|
||||||
|
await assert.rejects(retainManualInverseChargesWithinOwner(mismatched.db,1),/hint/)
|
||||||
|
assert.equal(mismatched.deleted.length,0)
|
||||||
// Revision2 payload proof cannot be replaced by an authentic stamp from a different payload.
|
// Revision2 payload proof cannot be replaced by an authentic stamp from a different payload.
|
||||||
const payload=await retentionFixture();payload.row.recovery_payload=await encryptSecret('{}')
|
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/)
|
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) ||
|
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_connection_id!==intent.connectionId || row.recovery_connection_id!==intent.connectionId ||
|
||||||
row.source_action!=="routing.community.switch" || row.source_version!==7 ||
|
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")
|
!["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 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]
|
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
|
// Authenticate any durable inverse row before treating this old manual
|
||||||
// identity as represented by the normal profile ledger.
|
// identity as represented by the normal profile ledger.
|
||||||
const {authenticateManualInverseHistoryWithinOwner}=await import("./routing-manual-inverse-store.js")
|
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(row.recovery_version!==6) throw fail("Current persistent manual inverse lacks durable charge")
|
||||||
if(classified.authority.packet.profileId!==profileId) continue
|
if(classified.authority.packet.profileId!==profileId) continue
|
||||||
// Historical v6 sealed evidence.validUntil bounded admission; source and
|
// Historical v6 sealed evidence.validUntil bounded admission; source and
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { effectiveMcpConnection } from "./store.js"
|
|||||||
import { decryptSecretExisting, encryptSecret, fingerprint, McpError, privateFingerprinterExisting, privateSecretFingerprint } from "./security.js"
|
import { decryptSecretExisting, encryptSecret, fingerprint, McpError, privateFingerprinterExisting, privateSecretFingerprint } from "./security.js"
|
||||||
import { routingProfileAuthorityBindingSchema } from "./routing-profile-authority.js"
|
import { routingProfileAuthorityBindingSchema } from "./routing-profile-authority.js"
|
||||||
import type { RoutingSwitchLedgerEntry } from "./routing-switch-ledger-contract.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 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()
|
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,
|
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(),
|
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,
|
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,
|
sourceRequestProof: proof, recoveryRequestProof: proof, sourceEvidenceProof: proof,
|
||||||
recoveryEvidenceProof: proof, recoveryPayloadProof: proof,
|
recoveryEvidenceProof: proof, recoveryPayloadProof: proof,
|
||||||
scope: z.array(z.number().int().positive().safe()).min(1).max(1000), createdAt: stamp, chargeUnits: z.literal(1) }).strict()
|
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
|
if (plan.operation_id!=="operations.recover") return undefined
|
||||||
const step=evidence.steps[0]
|
const step=evidence.steps[0]
|
||||||
if (evidence.steps.length!==1 || step.kind!=="service" || step.service!=="routing.community.scoped.recover") return undefined
|
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 { loadScopedManualRecoveryAdmissionWithinOwner } = await import("./routing-community-scoped-recovery.js")
|
||||||
const loaded = await loadScopedManualRecoveryAdmissionWithinOwner(step.input, actor, db)
|
const loaded = await loadScopedManualRecoveryAdmissionWithinOwner(step.input, actor, db)
|
||||||
const { source } = loaded
|
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,
|
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,
|
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,
|
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,
|
sourceRequestProof:selected.input.requestProof,recoveryRequestProof:selected.recoveryInput.requestProof,
|
||||||
sourceEvidenceProof:selected.sourceEvidenceProof,recoveryEvidenceProof:fingerprint(plan.evidence.sealed),
|
sourceEvidenceProof:selected.sourceEvidenceProof,recoveryEvidenceProof:fingerprint(plan.evidence.sealed),
|
||||||
recoveryPayloadProof:fingerprint(plan.payload.sealed),scope:authority.scope,createdAt:observedAt,chargeUnits:1})
|
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.
|
/** A committed charge is required before the existing manual native intent.
|
||||||
* This authenticates identity only; existing live/native authority still runs. */
|
* This authenticates identity only; existing live/native authority still runs. */
|
||||||
export async function assertManualInverseChargeWithinOwner(db:PoolClient,operationId:string,input:Record<string,unknown>,actor:McpPrincipal) {
|
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")
|
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 plan=rows[0], evidence=JSON.parse(await decryptSecretExisting(plan.evidence.sealed)) as ChangeEvidence
|
||||||
const step=evidence.steps[0]
|
const step=evidence.steps[0]
|
||||||
@@ -154,7 +156,7 @@ export async function assertManualInverseChargeWithinOwner(db:PoolClient,operati
|
|||||||
export async function authenticateManualInverseHistoryWithinOwner(db:PoolClient,
|
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},
|
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,
|
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 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
|
const copy = intent.revision === 2 ? await authenticateCopy(intent.manualInverseCharge, observedAt) : undefined
|
||||||
if (!rows.length && !copy) return false
|
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.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.connectionId!==intent.connectionId || value.ownerId!==intent.ownerId || value.sourceProof!==intent.sourceProof ||
|
||||||
value.recoveryRequestProof!==intent.requestSeal || value.sourceEvidenceProof!==identity.sourceEvidenceProof ||
|
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
|
// Only revision2 needs this extra one-row bounded projection; preserve the
|
||||||
// existing cursor page payload ceiling and legacy protocol untouched.
|
// 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 : []
|
const payload = copy ? (await db.query<{sealed:string|null}>("SELECT CASE WHEN octet_length(payload::text)<=1048576 THEN payload->>'sealed' END AS sealed FROM mcp_plans WHERE id=$1 LIMIT 2",[identity.recoveryPlanId])).rows : []
|
||||||
@@ -201,11 +204,11 @@ export async function retainManualInverseChargesWithinOwner(db: PoolClient, rete
|
|||||||
" WHERE r.mode='manual' AND r.source_operation_id=$1 AND r.source_step_index=0 AND r.recovery_operation_id=$2",[candidate.source_operation_id,candidate.recovery_operation_id])).rows
|
" 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")
|
if(rows.length!==1)throw fail("Retention intent/source pair disappeared")
|
||||||
const row=rows[0],classified=await classifyManualInverseHistory(row,observedAt)
|
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)
|
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")
|
throw fail("Retention hint is not an authenticated persistent intent2")
|
||||||
if(!await authenticateManualInverseHistoryWithinOwner(db,classified.intent,classified.authority,observedAt,{
|
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")
|
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])
|
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")
|
if(result.rowCount!==1)throw fail("Retention parent delete was not confirmed")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import assert from "node:assert/strict"
|
import assert from "node:assert/strict"
|
||||||
import { test } from "node:test"
|
import { test } from "node:test"
|
||||||
import { routeAiProfileSchema, type RouteObservation } from "@mmapp/contracts/mcp-routing"
|
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 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: [
|
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())
|
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", () => {
|
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")!
|
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)
|
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 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 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
|
const pathHealthFreshnessSec = 90
|
||||||
export function decideRoutingSwitch(profile: RouteAiProfile, state: ControllerState, ranking: ReturnType<typeof rankRoutingCandidates>, currentCandidateId: string | null, now = Date.now(), health: PathHealth[] = [], chargedSwitchesPerHour?: number) {
|
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) }
|
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
|
||||||
|
}
|
||||||
@@ -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.equal(basename(resolve(process.env.MCP_STATE_DIR)), "state")
|
||||||
assert(basename(dirname(resolve(process.env.MCP_STATE_DIR))).startsWith("mm-mcp-test-"))
|
assert(basename(dirname(resolve(process.env.MCP_STATE_DIR))).startsWith("mm-mcp-test-"))
|
||||||
const originalEffects = process.env.MMAPP_OWN_ROUTING_ORIGINAL_EFFECTS === "true"
|
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")
|
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_REQUIRED = "true"
|
||||||
process.env.AUTH_JWT_SECRET = "owned-routing-forward-rounds-acceptance-secret"
|
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))
|
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,
|
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)
|
assert.equal(result.accepted, true)
|
||||||
} finally {
|
} finally {
|
||||||
await (await import("../../services/scheduler.js")).stopSchedulerForDatabaseOperation()
|
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")
|
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", () => {
|
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
|
const f = fixture(), raw = structuredClone(f.bgp) as unknown as RawMutation
|
||||||
raw.payload[0].established = "false"
|
raw.payload[0].established = "false"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { isIP } from "node:net"
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { canonicalBgpPolicyPrefix } from "../../services/bgp-community-policy-model.js"
|
import { canonicalBgpPolicyPrefix } from "../../services/bgp-community-policy-model.js"
|
||||||
import { captureRoutingNetworkManifest, type RoutingNetworkRound } from "./routing-network-verifier-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
|
/** Pure decoded-response parsers, NOT source authentication or completeness
|
||||||
* proof. Only the explicit RouterOS7.20–7.26 field subset below is recognized.
|
* 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]
|
return canonicalBgpPolicyPrefix(text + "/" + (family === 4 ? 32 : 128)).split("/")[0]
|
||||||
}
|
}
|
||||||
function endpoint(value: unknown) {
|
function endpoint(value: unknown) {
|
||||||
const text = literal(value), match = /^([^%@/,\s]+)(?:\/(\d+))?(?:%([^%@/,\s]+))?(?:@([^%@/,\s]+))?$/.exec(text)
|
const parsed = parseRoutingEndpoint(value)
|
||||||
if (!match) return fail("unsupported-endpoint")
|
return { ...parsed, address: canonicalAddress(parsed.address) }
|
||||||
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] }
|
|
||||||
}
|
}
|
||||||
function decodedRows(payload: unknown, maximum = 1000, ids = true): Row[] {
|
function decodedRows(payload: unknown, maximum = 1000, ids = true): Row[] {
|
||||||
let nodes = 0
|
let nodes = 0
|
||||||
@@ -144,7 +142,7 @@ export function captureRoutingNetworkNativeParser(rawManifest: unknown) {
|
|||||||
if (instance !== wanted.instance) continue
|
if (instance !== wanted.instance) continue
|
||||||
if (local.address !== wanted.localAddress || remote.address !== wanted.remoteAddress) 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 (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)
|
relevant.push(row)
|
||||||
}
|
}
|
||||||
if (relevant.length !== 1) return unknown("missing-or-ambiguous-bgp-session")
|
if (relevant.length !== 1) return unknown("missing-or-ambiguous-bgp-session")
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ const require=createRequire(new URL('../../../package.json',import.meta.url)),ts
|
|||||||
const hash=value=>createHash('sha256').update(JSON.stringify(value)).digest('hex'),sign=async(value,purpose)=>hash([purpose,value]);
|
const hash=value=>createHash('sha256').update(JSON.stringify(value)).digest('hex'),sign=async(value,purpose)=>hash([purpose,value]);
|
||||||
const code=ts.transpileModule(await readFile(new URL('./routing-controller-baseline.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText;
|
const code=ts.transpileModule(await readFile(new URL('./routing-controller-baseline.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText;
|
||||||
const effectCode=ts.transpileModule(await readFile(new URL('./routing-original-effects.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText,effectExports={};
|
const effectCode=ts.transpileModule(await readFile(new URL('./routing-original-effects.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText,effectExports={};
|
||||||
new Function('exports','require',effectCode)(effectExports,name=>{if(name==='node:net')return require('node:net');if(name==='zod')return{z};if(name==='./security.js')return{fingerprint:hash};if(name==='./routing-community-context-model.js')return{};throw Error(name);});
|
const endpointCode=ts.transpileModule(await readFile(new URL('./routing-native-endpoint.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText,endpointExports={};
|
||||||
|
new Function('exports','require',endpointCode)(endpointExports,require);
|
||||||
|
new Function('exports','require',effectCode)(effectExports,name=>{if(name==='node:net')return require('node:net');if(name==='zod')return{z};if(name==='./security.js')return{fingerprint:hash};if(name==='./routing-community-context-model.js')return{};if(name==='./routing-native-endpoint.js')return endpointExports;throw Error(name);});
|
||||||
const preparedValue={status:'prepared',revision:1,serverId:1,routeSelectionScope:'table-afi-community-observation',candidateId:'b',family:'ipv4',table:'tenant',community:'65000:10',expectedGateway:'192.0.2.9',expectedImmediate:{address:'198.51.100.2',interface:'ether2'},binding:{connectionId:'*c',sessionId:'*s',instance:'default',table:'tenant',vrf:'main',localAddress:'192.0.2.1',remoteAddress:'192.0.2.2',remoteAsn:65001,afi:'ip'},bfd:{applicability:'not-configured',evidence:'explicit-use-bfd-false'},routes:[{nativeId:'*r',prefix:'203.0.113.0/24',originalGateway:'192.0.2.8'}],probe:{key:'b',serverId:1,destination:'198.51.100.20',source:'198.51.100.10',interface:'wan',probeVrf:'probe',expectedHops:['198.51.100.20']}};
|
const preparedValue={status:'prepared',revision:1,serverId:1,routeSelectionScope:'table-afi-community-observation',candidateId:'b',family:'ipv4',table:'tenant',community:'65000:10',expectedGateway:'192.0.2.9',expectedImmediate:{address:'198.51.100.2',interface:'ether2'},binding:{connectionId:'*c',sessionId:'*s',instance:'default',table:'tenant',vrf:'main',localAddress:'192.0.2.1',remoteAddress:'192.0.2.2',remoteAsn:65001,afi:'ip'},bfd:{applicability:'not-configured',evidence:'explicit-use-bfd-false'},routes:[{nativeId:'*r',prefix:'203.0.113.0/24',originalGateway:'192.0.2.8'}],probe:{key:'b',serverId:1,destination:'198.51.100.20',source:'198.51.100.10',interface:'wan',probeVrf:'probe',expectedHops:['198.51.100.20']}};
|
||||||
async function fixture(selection='absent',effectStatus='observed-expected',switched=true){
|
async function fixture(selection='absent',effectStatus='observed-expected',switched=true){
|
||||||
const principal={connectionId:randomUUID(),credentialId:randomUUID(),credentialExpiresAt:new Date(Date.now()+120000).toISOString()},profileId=randomUUID(),planId=randomUUID(),operationId=randomUUID(),grant=new Date(Date.now()+60000).toISOString();
|
const principal={connectionId:randomUUID(),credentialId:randomUUID(),credentialExpiresAt:new Date(Date.now()+120000).toISOString()},profileId=randomUUID(),planId=randomUUID(),operationId=randomUUID(),grant=new Date(Date.now()+60000).toISOString();
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import { isIP } from 'node:net';
|
|||||||
import { randomUUID, createHash } from 'node:crypto';
|
import { randomUUID, createHash } from 'node:crypto';
|
||||||
const require=createRequire(new URL('../../../package.json',import.meta.url)), ts=require('typescript'),{z}=require('zod');
|
const require=createRequire(new URL('../../../package.json',import.meta.url)), ts=require('typescript'),{z}=require('zod');
|
||||||
const source=await readFile(new URL('./routing-original-effects.ts',import.meta.url),'utf8');
|
const source=await readFile(new URL('./routing-original-effects.ts',import.meta.url),'utf8');
|
||||||
const exports={};
|
const exports={}, endpointExports={};
|
||||||
|
const endpointSource=await readFile(new URL('./routing-native-endpoint.ts',import.meta.url),'utf8');
|
||||||
|
new Function('exports','require',ts.transpileModule(endpointSource,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText)(endpointExports,require);
|
||||||
const gateway=value=>{const match=/^([^@%\s,]+)(?:%([^@%\s,]+))?(?:@([^@%\s,]+))?$/.exec(value);if(!match||!isIP(match[1]))throw Error();return {address:match[1],family:isIP(match[1]),interface:match[2]??null,resolverTable:match[3]??'main'};};
|
const gateway=value=>{const match=/^([^@%\s,]+)(?:%([^@%\s,]+))?(?:@([^@%\s,]+))?$/.exec(value);if(!match||!isIP(match[1]))throw Error();return {address:match[1],family:isIP(match[1]),interface:match[2]??null,resolverTable:match[3]??'main'};};
|
||||||
const providers={ 'node:net':{isIP},'node:crypto':{randomUUID},zod:{z},'./routing-community-context-model.js':{routeGatewayContext:gateway},'./security.js':{fingerprint:value=>createHash('sha256').update(JSON.stringify(value)).digest('hex')},
|
const providers={ 'node:net':{isIP},'node:crypto':{randomUUID},zod:{z},'./routing-community-context-model.js':{routeGatewayContext:gateway},'./routing-native-endpoint.js':endpointExports,'./security.js':{fingerprint:value=>createHash('sha256').update(JSON.stringify(value)).digest('hex')},
|
||||||
'./path-verification.js':{inspectControlPing:rows=>{const row=rows.at(-1),sent=Number(row.sent),received=Number(row.received);return {confirmedFailure:sent===3&&received===0,ping:{measured:sent===3&&Number.isInteger(received)&&received>=0&&received<=3,loss:(sent-received)/sent*100}};},
|
'./path-verification.js':{inspectControlPing:rows=>{const row=rows.at(-1),sent=Number(row.sent),received=Number(row.received);return {confirmedFailure:sent===3&&received===0,ping:{measured:sent===3&&Number.isInteger(received)&&received>=0&&received<=3,loss:(sent-received)/sent*100}};},
|
||||||
inspectTrace:(rows,destination,expected)=>({verified:Array.isArray(rows)&&rows.at(-1)?.address===destination&&expected.every(x=>rows.some(r=>r.address===x))})}};
|
inspectTrace:(rows,destination,expected)=>({verified:Array.isArray(rows)&&rows.at(-1)?.address===destination&&expected.every(x=>rows.some(r=>r.address===x))})}};
|
||||||
new Function('exports','require',ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText)(exports,name=>{assert.ok(providers[name],name);return providers[name];});
|
new Function('exports','require',ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText)(exports,name=>{assert.ok(providers[name],name);return providers[name];});
|
||||||
@@ -26,11 +28,33 @@ function fixture(){
|
|||||||
}
|
}
|
||||||
test('actual captured predicate seals BGP destinations separately from static resolver ID',()=>{const f=fixture();assert.equal(f.selection.status,'prepared');assert.equal(f.selection.routes[0].nativeId,'*r');assert.notEqual(f.selection.routes[0].nativeId,f.raw.candidate['.id']);assert.equal(f.selection.binding.sessionId,'*s');assert.equal('sessionKey'in f.selection.routes[0],false);});
|
test('actual captured predicate seals BGP destinations separately from static resolver ID',()=>{const f=fixture();assert.equal(f.selection.status,'prepared');assert.equal(f.selection.routes[0].nativeId,'*r');assert.notEqual(f.selection.routes[0].nativeId,f.raw.candidate['.id']);assert.equal(f.selection.binding.sessionId,'*s');assert.equal('sessionKey'in f.selection.routes[0],false);});
|
||||||
test('two real effect captures can observe expected FIB/adjacency/probe while logcoverage and stronger claims stay false',async()=>{const f=fixture(),result=await f.inspect([f.sample(),f.sample()]);assert.equal(result.status,'observed-expected');assert.equal(result.logCoverage,'unknown');assert.equal(result.routeOriginVerified,false);assert.equal(result.recursiveChainVerified,false);assert.equal(result.forwardingVerified,false);assert.equal(result.autoRecoveryAllowed,false);assert.equal(result.rounds[0].relatedTopicRows,1);});
|
test('two real effect captures can observe expected FIB/adjacency/probe while logcoverage and stronger claims stay false',async()=>{const f=fixture(),result=await f.inspect([f.sample(),f.sample()]);assert.equal(result.status,'observed-expected');assert.equal(result.logCoverage,'unknown');assert.equal(result.routeOriginVerified,false);assert.equal(result.recursiveChainVerified,false);assert.equal(result.forwardingVerified,false);assert.equal(result.autoRecoveryAllowed,false);assert.equal(result.rounds[0].relatedTopicRows,1);});
|
||||||
|
function qualifyRuntimeSession(row) {
|
||||||
|
delete row.vrf;
|
||||||
|
row['local.address'] += '@main';
|
||||||
|
row['remote.address'] += '@main';
|
||||||
|
}
|
||||||
|
test('BGP session uses exact runtime endpoint qualifiers when VRF is absent and the installation table is separate',async()=>{
|
||||||
|
const f=fixture();
|
||||||
|
qualifyRuntimeSession(f.raw.sessions[0]);
|
||||||
|
const selection=f.prepare();
|
||||||
|
assert.equal(selection.status,'prepared');
|
||||||
|
assert.equal(selection.binding.vrf,'main');
|
||||||
|
assert.equal(selection.binding.table,'tenant');
|
||||||
|
const samples=[f.sample(),f.sample()];
|
||||||
|
const report=await exports.inspectOriginalEffectRounds(selection,samples,f.baseline);
|
||||||
|
assert.equal(report.status,'observed-expected');
|
||||||
|
assert.equal(report.routeOriginVerified,false);
|
||||||
|
});
|
||||||
for(const [name,change]of[
|
for(const [name,change]of[
|
||||||
['missing affected BGP route',f=>{f.raw.routes=[];}],['ambiguous prefix',f=>{f.raw.routes.push({...f.raw.routes[0],'.id':'*r2'});}],
|
['missing affected BGP route',f=>{f.raw.routes=[];}],['ambiguous prefix',f=>{f.raw.routes.push({...f.raw.routes[0],'.id':'*r2'});}],
|
||||||
['connected resolver is not affected BGP route',f=>{f.raw.routes[0].bgp='false';f.raw.routes[0].connect='true';}],
|
['connected resolver is not affected BGP route',f=>{f.raw.routes[0].bgp='false';f.raw.routes[0].connect='true';}],
|
||||||
['unresolved inheritance',f=>{f.raw.connections[0].templates='template';}],['BFD not explicitly known',f=>{delete f.raw.connections[0]['use-bfd'];}],
|
['unresolved inheritance',f=>{f.raw.connections[0].templates='template';}],['BFD not explicitly known',f=>{delete f.raw.connections[0]['use-bfd'];}],
|
||||||
['router-selected source retained as unsupported effect subset',f=>{delete f.raw.probe.source;}],['mixed session identity',f=>{f.raw.sessions.push({...f.raw.sessions[0],'.id':'*s2'});}],
|
['router-selected source retained as unsupported effect subset',f=>{delete f.raw.probe.source;}],['mixed session identity',f=>{f.raw.sessions.push({...f.raw.sessions[0],'.id':'*s2'});}],
|
||||||
|
['implicit VRF without both endpoint qualifiers',f=>{qualifyRuntimeSession(f.raw.sessions[0]);f.raw.sessions[0]['local.address']='192.0.2.1';}],
|
||||||
|
['qualified endpoints in the installation table',f=>{qualifyRuntimeSession(f.raw.sessions[0]);f.raw.sessions[0]['remote.address']='192.0.2.2@tenant';}],
|
||||||
|
['contradictory explicit VRF',f=>{qualifyRuntimeSession(f.raw.sessions[0]);f.raw.sessions[0].vrf='tenant';}],
|
||||||
|
['malformed peer ASN',f=>{f.raw.sessions[0]['remote.as']='0xfde9';}],
|
||||||
|
['duplicate native ID even with unrelated tuple',f=>{f.raw.sessions.push({...f.raw.sessions[0],instance:'unrelated'});}],
|
||||||
['noncanonical AFI prefix',f=>{f.raw.routes[0]['dst-address']='2001:db8::/64';}]
|
['noncanonical AFI prefix',f=>{f.raw.routes[0]['dst-address']='2001:db8::/64';}]
|
||||||
])test(`prepared ambiguity/unsupported remains unknown: ${name}`,()=>{const f=fixture();change(f);assert.equal(f.prepare().status,'unknown');});
|
])test(`prepared ambiguity/unsupported remains unknown: ${name}`,()=>{const f=fixture();change(f);assert.equal(f.prepare().status,'unknown');});
|
||||||
test('baseline binds saved route IDs/prefix/gateway and does not infer operation UUID',()=>{const f=fixture(),base=f.sample();base.servers[0].routes.payload=structuredClone(f.raw.routes);assert.equal(exports.originalEffectBaselineMatches(f.selection,base),true);base.servers[0].routes.payload[0]['.id']='*different';assert.equal(exports.originalEffectBaselineMatches(f.selection,base),false);});
|
test('baseline binds saved route IDs/prefix/gateway and does not infer operation UUID',()=>{const f=fixture(),base=f.sample();base.servers[0].routes.payload=structuredClone(f.raw.routes);assert.equal(exports.originalEffectBaselineMatches(f.selection,base),true);base.servers[0].routes.payload[0]['.id']='*different';assert.equal(exports.originalEffectBaselineMatches(f.selection,base),false);});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { isIP } from "node:net"
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import type { RouteAiProfile } from "@mmapp/contracts/mcp-routing"
|
import type { RouteAiProfile } from "@mmapp/contracts/mcp-routing"
|
||||||
import { routeGatewayContext } from "./routing-community-context-model.js"
|
import { routeGatewayContext } from "./routing-community-context-model.js"
|
||||||
|
import { matchesBgpSessionContext, parseRoutingEndpoint } from "./routing-native-endpoint.js"
|
||||||
import { fingerprint } from "./security.js"
|
import { fingerprint } from "./security.js"
|
||||||
|
|
||||||
type Row = Record<string, unknown>
|
type Row = Record<string, unknown>
|
||||||
@@ -43,10 +44,27 @@ export type OriginalEffectReport = z.infer<typeof originalEffectReportSchema>
|
|||||||
export const unknownOriginalEffects = (reason: string): OriginalEffectReport => ({ scope: "prepared-route-fib-adjacency-control-effects", status: "unknown", reason,
|
export const unknownOriginalEffects = (reason: string): OriginalEffectReport => ({ scope: "prepared-route-fib-adjacency-control-effects", status: "unknown", reason,
|
||||||
rounds: [], logSource: "routeros-rest-ring", logCoverage: "unknown", selectionIdentity: null, routeSelectionScope: "table-afi-community-observation", transitionAttribution: "unproven", switchEffectObserved: false, semanticProof: false, routeOriginVerified: false, recursiveChainVerified: false, forwardingVerified: false, autoRecoveryAllowed: false })
|
rounds: [], logSource: "routeros-rest-ring", logCoverage: "unknown", selectionIdentity: null, routeSelectionScope: "table-afi-community-observation", transitionAttribution: "unproven", switchEffectObserved: false, semanticProof: false, routeOriginVerified: false, recursiveChainVerified: false, forwardingVerified: false, autoRecoveryAllowed: false })
|
||||||
|
|
||||||
function tuple(row: Row, expected: Prepared["binding"], session: boolean) {
|
function connectionMatches(row: Row, expected: Prepared["binding"]) {
|
||||||
return row.instance === expected.instance && row["routing-table"] === expected.table && row.vrf === expected.vrf &&
|
return row.instance === expected.instance && row["routing-table"] === expected.table && row.vrf === expected.vrf &&
|
||||||
row["local.address"] === expected.localAddress && row["remote.address"] === expected.remoteAddress && Number(row["remote.as"]) === expected.remoteAsn &&
|
row["local.address"] === expected.localAddress && row["remote.address"] === expected.remoteAddress &&
|
||||||
(session ? row["local.afi"] === expected.afi && row["remote.afi"] === expected.afi : row.afi === expected.afi)
|
(row["remote.as"] === expected.remoteAsn || row["remote.as"] === String(expected.remoteAsn)) &&
|
||||||
|
row.afi === expected.afi
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectBgpSession(rows: Row[], expected: Prepared["binding"]) {
|
||||||
|
try {
|
||||||
|
let match: Row | undefined
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.instance !== expected.instance) continue
|
||||||
|
const local = parseRoutingEndpoint(row["local.address"]), remote = parseRoutingEndpoint(row["remote.address"])
|
||||||
|
if (local.address !== expected.localAddress || remote.address !== expected.remoteAddress) continue
|
||||||
|
if (match || local.interface !== undefined || remote.interface !== undefined || !matchesBgpSessionContext(row, expected, local, remote) ||
|
||||||
|
(row["remote.as"] !== expected.remoteAsn && row["remote.as"] !== String(expected.remoteAsn)) || row["local.afi"] !== expected.afi || row["remote.afi"] !== expected.afi ||
|
||||||
|
row.state !== undefined || flag(row.established) === undefined || !id.safeParse(row[".id"]).success) return undefined
|
||||||
|
match = row
|
||||||
|
}
|
||||||
|
return match && unique(rows, String(match[".id"])).length === 1 ? match : undefined
|
||||||
|
} catch { return undefined }
|
||||||
}
|
}
|
||||||
function selectedRows(rows: Row[], selection: Pick<Prepared, "table" | "family" | "community">) {
|
function selectedRows(rows: Row[], selection: Pick<Prepared, "table" | "family" | "community">) {
|
||||||
return rows.filter(row => row["routing-table"] === selection.table && family(row) === selection.family && flag(row.bgp) === true && communities(row).includes(selection.community))
|
return rows.filter(row => row["routing-table"] === selection.table && family(row) === selection.family && flag(row.bgp) === true && communities(row).includes(selection.community))
|
||||||
@@ -69,10 +87,10 @@ export function prepareOriginalEffectSelection(profile: RouteAiProfile, candidat
|
|||||||
const peer = connection[0], afi = profile.family === "ipv4" ? "ip" : "ipv6"
|
const peer = connection[0], afi = profile.family === "ipv4" ? "ip" : "ipv6"
|
||||||
const wanted = tupleSchema.parse({ connectionId: peer[".id"], sessionId: "*0", instance: binding.instance, table: binding.table, vrf: binding.vrf,
|
const wanted = tupleSchema.parse({ connectionId: peer[".id"], sessionId: "*0", instance: binding.instance, table: binding.table, vrf: binding.vrf,
|
||||||
localAddress: peer["local.address"], remoteAddress: peer["remote.address"], remoteAsn: Number(peer["remote.as"]), afi })
|
localAddress: peer["local.address"], remoteAddress: peer["remote.address"], remoteAsn: Number(peer["remote.as"]), afi })
|
||||||
if (!tuple(peer, wanted, false)) throw Error()
|
if (!connectionMatches(peer, wanted)) throw Error()
|
||||||
const sessions = raw.sessions.filter(row => tuple(row, wanted, true))
|
const session = selectBgpSession(raw.sessions, wanted)
|
||||||
if (sessions.length !== 1 || flag(sessions[0].established) === undefined) throw Error()
|
if (!session) throw Error()
|
||||||
wanted.sessionId = id.parse(sessions[0][".id"])
|
wanted.sessionId = id.parse(session[".id"])
|
||||||
const affected = selectedRows(raw.routes, profile)
|
const affected = selectedRows(raw.routes, profile)
|
||||||
if (!affected.length || affected.length > 32 || new Set(affected.map(row => row["dst-address"])).size !== affected.length || new Set(affected.map(row => row[".id"])).size !== affected.length) throw Error()
|
if (!affected.length || affected.length > 32 || new Set(affected.map(row => row["dst-address"])).size !== affected.length || new Set(affected.map(row => row[".id"])).size !== affected.length) throw Error()
|
||||||
for (const row of affected) {
|
for (const row of affected) {
|
||||||
@@ -130,10 +148,10 @@ export async function inspectOriginalEffectRounds(rawSelection: unknown, samples
|
|||||||
return { prefix: wanted.prefix, status }
|
return { prefix: wanted.prefix, status }
|
||||||
})
|
})
|
||||||
const connections = unique(source.bgp.connectionsPayload, selection.binding.connectionId)
|
const connections = unique(source.bgp.connectionsPayload, selection.binding.connectionId)
|
||||||
const sessions = source.bgp.payload.filter(row => tuple(row, selection.binding, true))
|
const session = selectBgpSession(source.bgp.payload, selection.binding)
|
||||||
let bgp: z.infer<typeof state> = "unknown"
|
let bgp: z.infer<typeof state> = "unknown"
|
||||||
if (connections.length === 1 && tuple(connections[0], selection.binding, false) && flag(connections[0].disabled) === false && flag(connections[0]["use-bfd"]) === false &&
|
if (connections.length === 1 && connectionMatches(connections[0], selection.binding) && flag(connections[0].disabled) === false && flag(connections[0]["use-bfd"]) === false &&
|
||||||
sessions.length === 1 && sessions[0][".id"] === selection.binding.sessionId && flag(sessions[0].established) !== undefined) bgp = flag(sessions[0].established) ? "observed-expected" : "observed-other"
|
session?.[".id"] === selection.binding.sessionId) bgp = flag(session.established) ? "observed-expected" : "observed-other"
|
||||||
const probe = sample.probes.find(row => row.key === selection.probe.key)
|
const probe = sample.probes.find(row => row.key === selection.probe.key)
|
||||||
let controlProbe: "passed" | "failed" | "unknown" = "unknown"
|
let controlProbe: "passed" | "failed" | "unknown" = "unknown"
|
||||||
if (probe) {
|
if (probe) {
|
||||||
|
|||||||
@@ -4,21 +4,22 @@ import { prepareRoutingControllerBaseline, assertRoutingControllerBaseline, rout
|
|||||||
import { publicRoutingControllerState } from "./routing-community-public.js"
|
import { publicRoutingControllerState } from "./routing-community-public.js"
|
||||||
import { randomUUID } from "node:crypto"
|
import { randomUUID } from "node:crypto"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { routeAiProfileSchema, type RouteAiProfile } from "@mmapp/contracts/mcp-routing"
|
import { routeAiControllerBlockReason, routeAiProfileSchema, usesScopedRouteAiContext, type RouteAiProfile } from "@mmapp/contracts/mcp-routing"
|
||||||
import { pool } from "../../db/index.js"
|
import { pool } from "../../db/index.js"
|
||||||
import { registerMcpService } from "./service-registry.js"
|
import { registerMcpService } from "./service-registry.js"
|
||||||
import { accessGeneration, McpError } from "./security.js"
|
import { accessGeneration, McpError } from "./security.js"
|
||||||
import { audit, validatePrincipal, type McpPrincipal } from "./store.js"
|
import { audit, validatePrincipal, type McpPrincipal } from "./store.js"
|
||||||
import { decideRoutingSwitch, type ControllerState, type PathHealth, type rankRoutingCandidates } from "./routing-model.js"
|
import { decideRoutingSwitch, selectFailoverReserveProbes, type ControllerState, type PathHealth, type rankRoutingCandidates } from "./routing-model.js"
|
||||||
import type { add } from "./actions.js"
|
import type { add } from "./actions.js"
|
||||||
import { routeContextExecutionReason } from "./routing-community-context-model.js"
|
import { COMMUNITY_PATH_MAX_AGE_MS } from "./path-proof-purpose.js"
|
||||||
import { controllerProfileCurrent, publishControllerState, saveControllerProfileCas, withControllerProfileLease } from "./routing-controller-fence.js"
|
import { controllerProfileCurrent, publishControllerState, saveControllerProfileCas, withControllerProfileLease } from "./routing-controller-fence.js"
|
||||||
import { assertIdentifiedRoutingCompletion, createRoutingReservationStore } from "./routing-reservation-store.js"
|
import { assertIdentifiedRoutingCompletion, createRoutingReservationStore } from "./routing-reservation-store.js"
|
||||||
|
|
||||||
const targetIds = (profile: RouteAiProfile) => [...new Set([profile.serverId, ...profile.candidates.flatMap(candidate => [candidate.jumpHostId, candidate.exitNodeId])])]
|
const targetIds = (profile: RouteAiProfile) => [...new Set([profile.serverId, ...profile.candidates.flatMap(candidate => [candidate.jumpHostId, candidate.exitNodeId])])]
|
||||||
const executionGuardMessage = (profile: RouteAiProfile) => profile.autoRecovery
|
function assertControllerCanBeEnabled(profile: RouteAiProfile) {
|
||||||
? "ROUTE_RECOVERY_EXECUTOR_UNAVAILABLE: автоматическое восстановление требует исполнителя с отдельным подтверждением состояния после переключения; профиль можно сохранить выключенным"
|
const reason = routeAiControllerBlockReason(profile)
|
||||||
: "Контроллер scoped table/VRF/input-chain пока недоступен; профиль можно сохранить выключенным"
|
if (reason) throw new McpError("UNSUPPORTED_CAPABILITY", reason, 409)
|
||||||
|
}
|
||||||
export function registerRoutingProfileActions(register: typeof add) {
|
export function registerRoutingProfileActions(register: typeof add) {
|
||||||
register("routing.profiles.read", "mm_route_profiles_list", "Профили и состояние контроллера Route AI", "network", "mm:network:read", "read", "connection", z.object({}).strict(), {
|
register("routing.profiles.read", "mm_route_profiles_list", "Профили и состояние контроллера Route AI", "network", "mm:network:read", "read", "connection", z.object({}).strict(), {
|
||||||
read: async (_input, context) => ({ profiles: (await pool.query("SELECT id,version,config,state,enabled,grant_expires_at,updated_at FROM mcp_routing_profiles WHERE connection_id=$1 ORDER BY updated_at DESC LIMIT 200", [context.connection.id])).rows.filter(row => targetIds(routeAiProfileSchema.parse(row.config)).every(id => context.connection.policy.scope.serverIds.includes(id))).map(row=>({...row,state:publicRoutingControllerState(row.state)})) }),
|
read: async (_input, context) => ({ profiles: (await pool.query("SELECT id,version,config,state,enabled,grant_expires_at,updated_at FROM mcp_routing_profiles WHERE connection_id=$1 ORDER BY updated_at DESC LIMIT 200", [context.connection.id])).rows.filter(row => targetIds(routeAiProfileSchema.parse(row.config)).every(id => context.connection.policy.scope.serverIds.includes(id))).map(row=>({...row,state:publicRoutingControllerState(row.state)})) }),
|
||||||
@@ -26,7 +27,7 @@ export function registerRoutingProfileActions(register: typeof add) {
|
|||||||
registerMcpService("routing.profile.save", {
|
registerMcpService("routing.profile.save", {
|
||||||
current: async input => (await pool.query("SELECT id,version,config,enabled,grant_expires_at FROM mcp_routing_profiles WHERE id=$1 AND connection_id=$2", [input.profileId, input.connectionId])).rows[0] ?? null,
|
current: async input => (await pool.query("SELECT id,version,config,enabled,grant_expires_at FROM mcp_routing_profiles WHERE id=$1 AND connection_id=$2", [input.profileId, input.connectionId])).rows[0] ?? null,
|
||||||
execute: async input => {
|
execute: async input => {
|
||||||
if (input.enabled && routeContextExecutionReason(routeAiProfileSchema.parse(input.profile))) throw new McpError("UNSUPPORTED_CAPABILITY", executionGuardMessage(routeAiProfileSchema.parse(input.profile)), 409)
|
if (input.enabled) assertControllerCanBeEnabled(routeAiProfileSchema.parse(input.profile))
|
||||||
if (input.expectedVersion !== null && (!Number.isSafeInteger(input.expectedVersion) || Number(input.expectedVersion) < 1)) throw new McpError("PLAN_EXPIRED", "План не содержит ожидаемую версию профиля", 409)
|
if (input.expectedVersion !== null && (!Number.isSafeInteger(input.expectedVersion) || Number(input.expectedVersion) < 1)) throw new McpError("PLAN_EXPIRED", "План не содержит ожидаемую версию профиля", 409)
|
||||||
if (input.enabled) {
|
if (input.enabled) {
|
||||||
const principal=await routingControllerBaselinePrincipal(input.baseline),connection=await validatePrincipal(principal)
|
const principal=await routingControllerBaselinePrincipal(input.baseline),connection=await validatePrincipal(principal)
|
||||||
@@ -43,7 +44,7 @@ export function registerRoutingProfileActions(register: typeof add) {
|
|||||||
target: input => ({ scope: "servers", serverIds: targetIds(routeAiProfileSchema.parse(input.profile)) }),
|
target: input => ({ scope: "servers", serverIds: targetIds(routeAiProfileSchema.parse(input.profile)) }),
|
||||||
prepare: async (input, context) => {
|
prepare: async (input, context) => {
|
||||||
const profile = routeAiProfileSchema.parse(input.profile), ids = targetIds(profile), profileId = input.profileId ?? randomUUID()
|
const profile = routeAiProfileSchema.parse(input.profile), ids = targetIds(profile), profileId = input.profileId ?? randomUUID()
|
||||||
if (input.enabled && routeContextExecutionReason(profile)) throw new McpError("UNSUPPORTED_CAPABILITY", executionGuardMessage(profile), 409)
|
if (input.enabled) assertControllerCanBeEnabled(profile)
|
||||||
const expires = Date.parse(String(input.expiresAt)), credentialExpires = Date.parse(context.principal.credentialExpiresAt)
|
const expires = Date.parse(String(input.expiresAt)), credentialExpires = Date.parse(context.principal.credentialExpiresAt)
|
||||||
if (expires <= Date.now() || expires > Math.min(Date.now() + 86400000, credentialExpires, Date.parse(context.connection.policy.expiresAt), context.connection.owner_valid_until.getTime())) throw new McpError("INVALID_EXPIRATION", "Допуск контроллера ограничен 24 часами и сроком подтверждения прав владельца", 400)
|
if (expires <= Date.now() || expires > Math.min(Date.now() + 86400000, credentialExpires, Date.parse(context.connection.policy.expiresAt), context.connection.owner_valid_until.getTime())) throw new McpError("INVALID_EXPIRATION", "Допуск контроллера ограничен 24 часами и сроком подтверждения прав владельца", 400)
|
||||||
const existing = (await pool.query("SELECT id,connection_id,version,config,enabled,grant_expires_at FROM mcp_routing_profiles WHERE id=$1", [profileId])).rows[0]
|
const existing = (await pool.query("SELECT id,connection_id,version,config,enabled,grant_expires_at FROM mcp_routing_profiles WHERE id=$1", [profileId])).rows[0]
|
||||||
@@ -60,7 +61,7 @@ export function registerRoutingProfileActions(register: typeof add) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PersistentController extends ControllerState { baseline?:RoutingControllerBaseline; planId?: string; candidateId?: string; expectedCandidateId?: string; reason?: string; checkedAt?: string; probedAt?: string }
|
interface PersistentController extends ControllerState { baseline?:RoutingControllerBaseline; planId?: string; candidateId?: string; expectedCandidateId?: string; reason?: string; checkedAt?: string; probedAt?: string; failoverReserveCursor?: string }
|
||||||
/** Also fenced per profile so direct/concurrent ticks cannot publish stale state. */
|
/** Also fenced per profile so direct/concurrent ticks cannot publish stale state. */
|
||||||
export async function tickRoutingProfiles() {
|
export async function tickRoutingProfiles() {
|
||||||
const { invokeMcpTool, applyPlan } = await import("./runtime.js")
|
const { invokeMcpTool, applyPlan } = await import("./runtime.js")
|
||||||
@@ -77,7 +78,8 @@ export async function tickRoutingProfiles() {
|
|||||||
}
|
}
|
||||||
const current = async () => { if (!await controllerProfileCurrent(lease, row)) throw new McpError("STATE_CONFLICT", "Допуск профиля изменился во время цикла", 409) }
|
const current = async () => { if (!await controllerProfileCurrent(lease, row)) throw new McpError("STATE_CONFLICT", "Допуск профиля изменился во время цикла", 409) }
|
||||||
try {
|
try {
|
||||||
if (routeContextExecutionReason(profile)) { await stop(executionGuardMessage(profile)); return }
|
const controllerBlock = routeAiControllerBlockReason(profile)
|
||||||
|
if (controllerBlock) { await stop(controllerBlock); return }
|
||||||
if (row.grant_expires_at.getTime() <= Date.now() || row.generation !== await accessGeneration() || !row.credential_id) { await stop("Допуск контроллера истёк или отозван"); return }
|
if (row.grant_expires_at.getTime() <= Date.now() || row.generation !== await accessGeneration() || !row.credential_id) { await stop("Допуск контроллера истёк или отозван"); return }
|
||||||
const principal: McpPrincipal = await routingControllerBaselinePrincipal(state.baseline)
|
const principal: McpPrincipal = await routingControllerBaselinePrincipal(state.baseline)
|
||||||
const connection = await validatePrincipal(principal)
|
const connection = await validatePrincipal(principal)
|
||||||
@@ -93,6 +95,7 @@ export async function tickRoutingProfiles() {
|
|||||||
next.lastSwitchAt = at; next.switches = [...(state.switches ?? [])]; next.consecutive = 0
|
next.lastSwitchAt = at; next.switches = [...(state.switches ?? [])]; next.consecutive = 0
|
||||||
next.expectedCandidateId = state.candidateId
|
next.expectedCandidateId = state.candidateId
|
||||||
delete next.planId; delete next.candidateId; delete next.pendingCandidateId; delete next.operationId
|
delete next.planId; delete next.candidateId; delete next.pendingCandidateId; delete next.operationId
|
||||||
|
delete next.failoverReserveCursor
|
||||||
next.reason = "Изменение конфигурации проверено; ожидаются новые измерения сети"
|
next.reason = "Изменение конфигурации проверено; ожидаются новые измерения сети"
|
||||||
} else if (plan.operation_id) next.reason = "Операция выполняется"
|
} else if (plan.operation_id) next.reason = "Операция выполняется"
|
||||||
else if (plan.expires_at.getTime() <= Date.now()) { await stop("План истёк без подтверждения"); return }
|
else if (plan.expires_at.getTime() <= Date.now()) { await stop("План истёк без подтверждения"); return }
|
||||||
@@ -109,21 +112,44 @@ export async function tickRoutingProfiles() {
|
|||||||
const analysis = await invokeMcpTool(principal, "mm_route_optimizer_analyze", { profile }) as { ranking: ReturnType<typeof rankRoutingCandidates>; currentCandidateId: string | null }
|
const analysis = await invokeMcpTool(principal, "mm_route_optimizer_analyze", { profile }) as { ranking: ReturnType<typeof rankRoutingCandidates>; currentCandidateId: string | null }
|
||||||
if (state.expectedCandidateId && analysis.currentCandidateId !== state.expectedCandidateId) { await stop("Наблюдаемый путь изменился вне ожидаемого переключения; требуется проверка вмешательства или дрейфа"); return }
|
if (state.expectedCandidateId && analysis.currentCandidateId !== state.expectedCandidateId) { await stop("Наблюдаемый путь изменился вне ожидаемого переключения; требуется проверка вмешательства или дрейфа"); return }
|
||||||
if (analysis.currentCandidateId) next.expectedCandidateId = analysis.currentCandidateId
|
if (analysis.currentCandidateId) next.expectedCandidateId = analysis.currentCandidateId
|
||||||
const health: PathHealth[] = []
|
|
||||||
const selected = [analysis.currentCandidateId, ...analysis.ranking.filter(value => value.eligible).slice(0, 2).map(value => value.candidate.id)]
|
|
||||||
if (profile.candidates.some(candidate => candidate.controlProbe) && !connection.policy.scope.operationIds.includes("routing.path.verify")) { await stop("Не выдано право контрольной проверки пути"); return }
|
if (profile.candidates.some(candidate => candidate.controlProbe) && !connection.policy.scope.operationIds.includes("routing.path.verify")) { await stop("Не выдано право контрольной проверки пути"); return }
|
||||||
for (const candidateId of [...new Set(selected)].filter((id): id is string => !!id)) {
|
const probeCandidate = async (candidateId: string): Promise<PathHealth | undefined> => {
|
||||||
|
if (!profile.candidates.find(candidate => candidate.id === candidateId)?.controlProbe) return
|
||||||
await current()
|
await current()
|
||||||
if (profile.candidates.find(candidate => candidate.id === candidateId)?.controlProbe) health.push(await invokeMcpTool(principal, "mm_route_path_verify", { profile, candidateId }) as PathHealth)
|
return await invokeMcpTool(principal, "mm_route_path_verify", { profile, candidateId }) as PathHealth
|
||||||
|
}
|
||||||
|
const currentHealth = analysis.currentCandidateId ? await probeCandidate(analysis.currentCandidateId) : undefined
|
||||||
|
const health = currentHealth ? [currentHealth] : []
|
||||||
|
const failoverProbes = analysis.currentCandidateId && currentHealth?.status === "failed"
|
||||||
|
? selectFailoverReserveProbes(analysis.ranking, analysis.currentCandidateId, state.failoverReserveCursor) : undefined
|
||||||
|
const candidates = failoverProbes?.candidateIds ?? analysis.ranking.filter(value => value.eligible).slice(0, 2)
|
||||||
|
.map(value => value.candidate.id).filter(id => id !== analysis.currentCandidateId)
|
||||||
|
for (const candidateId of candidates) {
|
||||||
|
const observed = await probeCandidate(candidateId)
|
||||||
|
if (observed) health.push(observed)
|
||||||
}
|
}
|
||||||
await assertRoutingControllerBaseline(state.baseline,row.id,row.version,profile,row.grant_expires_at.toISOString(),{principal,connection})
|
await assertRoutingControllerBaseline(state.baseline,row.id,row.version,profile,row.grant_expires_at.toISOString(),{principal,connection})
|
||||||
const charges = await createRoutingReservationStore().readHourlyCharges(row.id, principal)
|
const charges = await createRoutingReservationStore().readHourlyCharges(row.id, principal)
|
||||||
await current()
|
await current()
|
||||||
const decision = decideRoutingSwitch(profile, state, analysis.ranking, analysis.currentCandidateId, Date.now(), health, charges.totalCharges)
|
const decisionAt = Date.now()
|
||||||
|
const hasFreshPassedProbe = (candidateId: string | null) => health.some(probe => {
|
||||||
|
if (probe.candidateId !== candidateId || probe.status !== "passed") return false
|
||||||
|
const age = decisionAt - Date.parse(probe.observedAt)
|
||||||
|
return age >= 0 && age < COMMUNITY_PATH_MAX_AGE_MS
|
||||||
|
})
|
||||||
|
const decision = decideRoutingSwitch(profile, state, analysis.ranking, analysis.currentCandidateId, decisionAt, health, charges.totalCharges)
|
||||||
Object.assign(next, decision.state, { reason: decision.reason })
|
Object.assign(next, decision.state, { reason: decision.reason })
|
||||||
|
if (!failoverProbes?.cursor) delete next.failoverReserveCursor
|
||||||
|
else if (!hasFreshPassedProbe(failoverProbes.cursor)) {
|
||||||
|
// Advance past failed/unknown reserves. Keep rechecking a passing
|
||||||
|
// reserve while the current path accumulates independent failures.
|
||||||
|
next.failoverReserveCursor = failoverProbes.cursor
|
||||||
|
}
|
||||||
next.probedAt = new Date().toISOString()
|
next.probedAt = new Date().toISOString()
|
||||||
if (decision.state.pausedReason) { await stop(decision.state.pausedReason); return }
|
if (decision.state.pausedReason) { await stop(decision.state.pausedReason); return }
|
||||||
if (decision.switchTo) {
|
if (decision.switchTo && usesScopedRouteAiContext(profile) && !hasFreshPassedProbe(decision.switchTo)) {
|
||||||
|
next.reason = "Ожидается успешная контрольная проба выбранного пути"
|
||||||
|
} else if (decision.switchTo) {
|
||||||
await current()
|
await current()
|
||||||
await assertRoutingControllerBaseline(state.baseline,row.id,row.version,profile,row.grant_expires_at.toISOString(),{principal,connection})
|
await assertRoutingControllerBaseline(state.baseline,row.id,row.version,profile,row.grant_expires_at.toISOString(),{principal,connection})
|
||||||
const prepared = await invokeMcpTool(principal, "mm_route_optimizer_plan", { profile, candidateId: decision.switchTo }, undefined, { persistentRoutingProfile: { id: row.id, version: row.version } }) as { planId: string }
|
const prepared = await invokeMcpTool(principal, "mm_route_optimizer_plan", { profile, candidateId: decision.switchTo }, undefined, { persistentRoutingProfile: { id: row.id, version: row.version } }) as { planId: string }
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ try {
|
|||||||
issuedAt: Date.now() - 1000, expiresAt: Date.now() + 900000 }
|
issuedAt: Date.now() - 1000, expiresAt: Date.now() + 900000 }
|
||||||
await (await import("./store.js")).updateMcpSettings({ enabled: true }, actor)
|
await (await import("./store.js")).updateMcpSettings({ enabled: true }, actor)
|
||||||
await import("./actions.js")
|
await import("./actions.js")
|
||||||
const mixed = await (await import("./routing-community-scoped-acceptance.js")).acceptScopedRouteCommunitySwitch(actor,
|
const { acceptScopedRouteCommunitySwitch } = await import("./routing-community-scoped-acceptance.js")
|
||||||
{ manualInverseChargeOnly: true, manualInverseChargeCase: "mixed-forward" })
|
const inverse = await acceptScopedRouteCommunitySwitch(actor, { manualInverseChargeOnly: true })
|
||||||
|
console.log("private routing v8 manual inverse charge and intent SQL acceptance:", JSON.stringify(inverse))
|
||||||
|
const mixed = await acceptScopedRouteCommunitySwitch(actor, { manualInverseChargeOnly: true, manualInverseChargeCase: "mixed-forward" })
|
||||||
console.log("private routing mixed forward/manual inverse admission SQL acceptance:", JSON.stringify(mixed))
|
console.log("private routing mixed forward/manual inverse admission SQL acceptance:", JSON.stringify(mixed))
|
||||||
} finally {
|
} finally {
|
||||||
await (await import("../../services/scheduler.js")).stopSchedulerForDatabaseOperation()
|
await (await import("../../services/scheduler.js")).stopSchedulerForDatabaseOperation()
|
||||||
|
|||||||
@@ -4,10 +4,29 @@ import { routeAiProfileSchema } from "@mmapp/contracts/mcp-routing"
|
|||||||
import type { ChangeEvidence } from "./action-types.js"
|
import type { ChangeEvidence } from "./action-types.js"
|
||||||
import type { McpPrincipal, StoredPlan } from "./store.js"
|
import type { McpPrincipal, StoredPlan } from "./store.js"
|
||||||
import { decryptSecret, fingerprint, McpError } from "./security.js"
|
import { decryptSecret, fingerprint, McpError } from "./security.js"
|
||||||
import { assertScopedCommunityRequest } from "./routing-community-scoped-request.js"
|
import { assertScopedCommunityRequest, readScopedPathCheckpoint } from "./routing-community-scoped-request.js"
|
||||||
import { assertPersistentScopedProfileAuthority, routingProfileAuthorityBindingSchema } from "./routing-profile-authority.js"
|
import { assertPersistentScopedProfileAuthority, routingProfileAuthorityBindingSchema } from "./routing-profile-authority.js"
|
||||||
import { loadRoutingMainTransitionPreparation } from "./routing-main-transition.js"
|
import { loadRoutingMainTransitionPreparation } from "./routing-main-transition.js"
|
||||||
|
|
||||||
|
export function requiresScopedRoutingOwnerApproval(evidence: ChangeEvidence) {
|
||||||
|
const step = evidence.steps[0]
|
||||||
|
return evidence.steps.length === 1 && step.kind === "service" &&
|
||||||
|
step.service === "routing.community.scoped" && step.input.proofRevision === 3
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readScopedRoutingPathAdmissionDeadline(db: PoolClient, evidence: ChangeEvidence, connectionId: string) {
|
||||||
|
const step = evidence.steps[0]
|
||||||
|
if (step.kind !== "service" || step.service !== "routing.community.scoped" || step.input.proofRevision !== 3 ||
|
||||||
|
routeAiProfileSchema.parse(step.input.profile).autoRecovery) return
|
||||||
|
const { pathCheckId, pathCheckpointProof } = step.input
|
||||||
|
if (typeof pathCheckId !== "string" || typeof pathCheckpointProof !== "string") {
|
||||||
|
throw new McpError("MEASUREMENT_UNKNOWN", "Подтверждение пути изменилось", 409)
|
||||||
|
}
|
||||||
|
const checkpoint = await readScopedPathCheckpoint(db, pathCheckId, connectionId)
|
||||||
|
if (checkpoint.proof !== pathCheckpointProof) throw new McpError("MEASUREMENT_UNKNOWN", "Подтверждение пути изменилось", 409)
|
||||||
|
return checkpoint.validUntil
|
||||||
|
}
|
||||||
|
|
||||||
/** Only authenticated encrypted evidence can select this admission lane. */
|
/** Only authenticated encrypted evidence can select this admission lane. */
|
||||||
export async function routingReservationEvidence(plan: Pick<StoredPlan, "operation_id" | "operation_version">, evidence: ChangeEvidence) {
|
export async function routingReservationEvidence(plan: Pick<StoredPlan, "operation_id" | "operation_version">, evidence: ChangeEvidence) {
|
||||||
if (plan.operation_id !== "routing.community.switch" || evidence.steps.length !== 1) return undefined
|
if (plan.operation_id !== "routing.community.switch" || evidence.steps.length !== 1) return undefined
|
||||||
|
|||||||
@@ -506,6 +506,7 @@ export async function assertPersistentRoutingForwardTransportCharge(db: Pick<Poo
|
|||||||
if (source.length !== 1) throw fail("Exact transport source unavailable")
|
if (source.length !== 1) throw fail("Exact transport source unavailable")
|
||||||
const row = source[0], observedAt = iso(row.observed_at)
|
const row = source[0], observedAt = iso(row.observed_at)
|
||||||
if (row.operation_state !== "running" || row.plan_state !== "executing" || row.step_state !== "intent" || !row.approved_by ||
|
if (row.operation_state !== "running" || row.plan_state !== "executing" || row.step_state !== "intent" || !row.approved_by ||
|
||||||
|
metadata.serviceName === "routing.community.scoped" && row.approved_by !== ownerId ||
|
||||||
row.policy_version !== metadata.policyVersion || row.generation !== metadata.generation ||
|
row.policy_version !== metadata.policyVersion || row.generation !== metadata.generation ||
|
||||||
typeof row.evidence_sealed !== "string" || typeof row.payload_sealed !== "string" ||
|
typeof row.evidence_sealed !== "string" || typeof row.payload_sealed !== "string" ||
|
||||||
fingerprint(row.intended_state) !== fingerprint(redact({ kind: "service", service: metadata.serviceName, input: metadata.serviceInput })) ||
|
fingerprint(row.intended_state) !== fingerprint(redact({ kind: "service", service: metadata.serviceName, input: metadata.serviceInput })) ||
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export async function acceptRoutingWorkerFixtureIsolation(actor:AuthUser) {
|
|||||||
const changed=(await pool.query("SELECT enabled,state,version,grant_expires_at FROM mcp_routing_profiles WHERE id=$1",[id])).rows[0]
|
const changed=(await pool.query("SELECT enabled,state,version,grant_expires_at FROM mcp_routing_profiles WHERE id=$1",[id])).rows[0]
|
||||||
assert.equal(changed.enabled,false);assert.equal(changed.version,1)
|
assert.equal(changed.enabled,false);assert.equal(changed.version,1)
|
||||||
assert.equal(changed.grant_expires_at.toISOString(),expiry.toISOString())
|
assert.equal(changed.grant_expires_at.toISOString(),expiry.toISOString())
|
||||||
assert.match(changed.state.pausedReason,/Контроллер scoped table\/VRF\/input-chain пока недоступен/)
|
assert.match(changed.state.pausedReason,/Укажите явную BGP-привязку/)
|
||||||
const pauses=(await pool.query("SELECT id FROM mcp_audit WHERE action='controller.pause' AND payload->>'profileId'=$1",[id])).rows
|
const pauses=(await pool.query("SELECT id FROM mcp_audit WHERE action='controller.pause' AND payload->>'profileId'=$1",[id])).rows
|
||||||
assert.equal(pauses.length,1)
|
assert.equal(pauses.length,1)
|
||||||
await assert.rejects(assertRoutingProfileAuthority(authority,context),(error:{code?:string})=>error.code==="STATE_CONFLICT")
|
await assert.rejects(assertRoutingProfileAuthority(authority,context),(error:{code?:string})=>error.code==="STATE_CONFLICT")
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { pollErrorLog } from "../../services/server-poll-error-provenance.js"
|
import { pollErrorLog } from "../../services/server-poll-error-provenance.js"
|
||||||
|
import { vrfPreparationDiagnostic } from "./vrf-preparation-diagnostic.js"
|
||||||
|
import { createVrfRuntimeDiagnosticBoundary } from "../../services/vrf-execution-diagnostic.js"
|
||||||
import { capturePublishedServerProbeTerminalProof } from "../../services/server-probe-operation-retirement.js"
|
import { capturePublishedServerProbeTerminalProof } from "../../services/server-probe-operation-retirement.js"
|
||||||
import { SERVER_PROBE_OPERATION } from "../../services/server-probe-authority.js"
|
import { SERVER_PROBE_OPERATION } from "../../services/server-probe-authority.js"
|
||||||
import { preparedPlanExpiry } from "./plan-expiry.js"
|
import { preparedPlanExpiry } from "./plan-expiry.js"
|
||||||
@@ -13,6 +15,7 @@ import { logSettingsPatchSchema } from "@mmapp/contracts"
|
|||||||
import { pool } from "../../db/index.js"
|
import { pool } from "../../db/index.js"
|
||||||
import { hasPermission, type AuthUser } from "../../lib/permissions.js"
|
import { hasPermission, type AuthUser } from "../../lib/permissions.js"
|
||||||
import { createUser, updateUser, deleteUser, addBinding, removeBinding, getUserById } from "../users/service/users-service.js"
|
import { createUser, updateUser, deleteUser, addBinding, removeBinding, getUserById } from "../users/service/users-service.js"
|
||||||
|
import { normalizeBindingPeer } from "../users/peer-bind.js"
|
||||||
import { getLogSettings, updateLogSettings } from "../../services/router-logs-collector.js"
|
import { getLogSettings, updateLogSettings } from "../../services/router-logs-collector.js"
|
||||||
import { refreshScheduler } from "../../services/scheduler.js"
|
import { refreshScheduler } from "../../services/scheduler.js"
|
||||||
import { withApplicationMutationLock, withConfigMutationLock } from "../../services/config-mutation-lock.js"
|
import { withApplicationMutationLock, withConfigMutationLock } from "../../services/config-mutation-lock.js"
|
||||||
@@ -20,7 +23,7 @@ import { withOperationConfigurationLockClient, assertOperationConfigurationLockS
|
|||||||
import { sharedDatabaseGate } from "../../services/shared-database-gate.js"
|
import { sharedDatabaseGate } from "../../services/shared-database-gate.js"
|
||||||
import { withRuntimePollPrincipalSource, executeRuntimePollPolicyTransaction, type RuntimePollPolicyLoan } from "./runtime-poll-principal-boundary.js"
|
import { withRuntimePollPrincipalSource, executeRuntimePollPolicyTransaction, type RuntimePollPolicyLoan } from "./runtime-poll-principal-boundary.js"
|
||||||
import { readPollRuntimePolicySource, type PollRuntimePolicySource } from "./poll-runtime-policy-source.js"
|
import { readPollRuntimePolicySource, type PollRuntimePolicySource } from "./poll-runtime-policy-source.js"
|
||||||
import { withVerifiedConfigurationChange } from "../../services/config-mutation-policy.js"
|
import { withVerifiedConfigurationChange, assertVerifiedConfigurationAccess } from "../../services/config-mutation-policy.js"
|
||||||
import { beginMcpOperationAdmission, insertMcpOperationAdmission, retireMcpOperationAdmission, withMcpOperationExecutionOrigin, stripMcpOperationExecutionPrivateResult, type McpOperationAdmission } from "./operation-execution-origin.js"
|
import { beginMcpOperationAdmission, insertMcpOperationAdmission, retireMcpOperationAdmission, withMcpOperationExecutionOrigin, stripMcpOperationExecutionPrivateResult, type McpOperationAdmission } from "./operation-execution-origin.js"
|
||||||
import { createOspfOwnerSourceGuard, verifyOspfOwnerGraph, verifyOspfOwnerGraphAtPhase } from "./ospf-owner-graph.js"
|
import { createOspfOwnerSourceGuard, verifyOspfOwnerGraph, verifyOspfOwnerGraphAtPhase } from "./ospf-owner-graph.js"
|
||||||
import { evaluateMcpPolicy } from "./policy.js"
|
import { evaluateMcpPolicy } from "./policy.js"
|
||||||
@@ -32,6 +35,7 @@ import { executeRosStep, readRosSnapshot, resourceById, routerClient, type RosSn
|
|||||||
import { verifyRosStepSnapshots } from "./ros-step-verification.js"
|
import { verifyRosStepSnapshots } from "./ros-step-verification.js"
|
||||||
import { createRosPrefix } from "./ros-prefix.js"
|
import { createRosPrefix } from "./ros-prefix.js"
|
||||||
import type { ActionContext, ChangeEvidence, ChangeStep } from "./action-types.js"
|
import type { ActionContext, ChangeEvidence, ChangeStep } from "./action-types.js"
|
||||||
|
import { logsFollowCancellation } from "./logs-follow-cancellation.js"
|
||||||
import { findMcpService, type McpServiceExecutionContext } from "./service-registry.js"
|
import { findMcpService, type McpServiceExecutionContext } from "./service-registry.js"
|
||||||
import { withDiagnosticCancellation } from "./diagnostic-cancellation.js"
|
import { withDiagnosticCancellation } from "./diagnostic-cancellation.js"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
@@ -47,7 +51,7 @@ import { serviceNativeProgress } from "./service-native-progress.js"
|
|||||||
import { serviceDiagnosticProgress, serviceDiagnosticReceipt } from "./service-diagnostic-progress.js"
|
import { serviceDiagnosticProgress, serviceDiagnosticReceipt } from "./service-diagnostic-progress.js"
|
||||||
import { admitManualInverseWithinOwner } from "./routing-manual-inverse-store.js"
|
import { admitManualInverseWithinOwner } from "./routing-manual-inverse-store.js"
|
||||||
import { withRoutingReservationAdmission, createRoutingReservationStore, readHistoricalChargesWithinOwner, admitPersistentRoutingForwardWithinOwner, type RoutingReservedForwardAllocation } from "./routing-reservation-store.js"
|
import { withRoutingReservationAdmission, createRoutingReservationStore, readHistoricalChargesWithinOwner, admitPersistentRoutingForwardWithinOwner, type RoutingReservedForwardAllocation } from "./routing-reservation-store.js"
|
||||||
import { lockRoutingReservationAdmissionSource, routingReservationEvidence } from "./routing-reservation-runtime.js"
|
import { lockRoutingReservationAdmissionSource, readScopedRoutingPathAdmissionDeadline, requiresScopedRoutingOwnerApproval, routingReservationEvidence } from "./routing-reservation-runtime.js"
|
||||||
import { executeReservedRoutingStep, hasConfirmedReservedRoutingForward, type ReservedRoutingStepOutcome } from "./routing-reserved-step.js"
|
import { executeReservedRoutingStep, hasConfirmedReservedRoutingForward, type ReservedRoutingStepOutcome } from "./routing-reserved-step.js"
|
||||||
|
|
||||||
let activeRunners = 0
|
let activeRunners = 0
|
||||||
@@ -77,6 +81,12 @@ async function policyInput(client: Pick<PoolClient, "query">, connection: Connec
|
|||||||
usage: { callsInMinute: Number(usage.calls), mutationsInHour: Number(usage.mutations), concurrentReads: Number(usage.reads) }, auditAvailable: true,
|
usage: { callsInMinute: Number(usage.calls), mutationsInHour: Number(usage.mutations), concurrentReads: Number(usage.reads) }, auditAvailable: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function readMcpPolicyPreviewInput(connection: ConnectionRow, input: Pick<McpPolicyPreviewRequest, "operationId" | "target" | "objects" | "objectCount">): Promise<McpPolicyPreviewRequest> {
|
||||||
|
const current = await policyInput(pool, connection, input.operationId, input.target)
|
||||||
|
return { ...current, objects: input.objects, objectCount: input.objectCount }
|
||||||
|
}
|
||||||
|
|
||||||
async function temporaryScope(client: Pick<PoolClient, "query">, connection: ConnectionRow, credentialId: string) {
|
async function temporaryScope(client: Pick<PoolClient, "query">, connection: ConnectionRow, credentialId: string) {
|
||||||
const { rows } = await client.query("SELECT payload FROM mcp_grants WHERE connection_id=$1 AND revoked_at IS NULL AND payload->>'credentialId'=$2", [connection.id, credentialId])
|
const { rows } = await client.query("SELECT payload FROM mcp_grants WHERE connection_id=$1 AND revoked_at IS NULL AND payload->>'credentialId'=$2", [connection.id, credentialId])
|
||||||
if (!rows.length) return undefined
|
if (!rows.length) return undefined
|
||||||
@@ -112,12 +122,24 @@ async function completeRequest(id: string, started: number, result: unknown, err
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
export async function invokeMcpTool(principal: McpPrincipal, toolName: string, raw: unknown, signal?: AbortSignal, internal?: { persistentRoutingProfile: { id: string; version: number } }): Promise<unknown> {
|
export async function invokeMcpTool(principal: McpPrincipal, toolName: string, raw: unknown, signal?: AbortSignal, internal?: { persistentRoutingProfile: { id: string; version: number } }): Promise<unknown> {
|
||||||
|
if (toolName === "mm_logs_follow") {
|
||||||
|
const maintenance = new AbortController()
|
||||||
|
return sharedDatabaseGate.request(
|
||||||
|
() => invokeMcpToolInner(principal, toolName, raw, signal, internal, maintenance.signal),
|
||||||
|
() => maintenance.abort(new McpError("RESET_REQUIRED", "База данных перешла в режим обслуживания; начните новое чтение", 409)),
|
||||||
|
)
|
||||||
|
}
|
||||||
return sharedDatabaseGate.request(() => invokeMcpToolInner(principal, toolName, raw, signal, internal))
|
return sharedDatabaseGate.request(() => invokeMcpToolInner(principal, toolName, raw, signal, internal))
|
||||||
}
|
}
|
||||||
async function invokeMcpToolInner(principal: McpPrincipal, toolName: string, raw: unknown, signal?: AbortSignal, internal?: { persistentRoutingProfile: { id: string; version: number } }): Promise<unknown> {
|
async function invokeMcpToolInner(principal: McpPrincipal, toolName: string, raw: unknown, signal?: AbortSignal, internal?: { persistentRoutingProfile: { id: string; version: number } }, logsFollowMaintenance?: AbortSignal): Promise<unknown> {
|
||||||
const action = findAction(toolName), input = action.schema.parse(raw) as Record<string, unknown>, started = Date.now()
|
const action = findAction(toolName), input = action.schema.parse(raw) as Record<string, unknown>, started = Date.now()
|
||||||
const call = await reserveCall(principal, toolName, input, signal)
|
const call = await reserveCall(principal, toolName, input, signal)
|
||||||
|
const cancelled = () => logsFollowMaintenance && logsFollowCancellation(logsFollowMaintenance, signal)
|
||||||
|
const assertActive = () => { const failure = cancelled(); if (failure) throw failure }
|
||||||
|
let vrfPreparing = false
|
||||||
try {
|
try {
|
||||||
|
assertActive()
|
||||||
|
if (logsFollowMaintenance) call.context.logsFollowMaintenance = logsFollowMaintenance
|
||||||
if (internal) {
|
if (internal) {
|
||||||
if (action.operation.id !== "routing.community.switch") throw new McpError("INVALID_INPUT", "Контекст профиля не относится к этой операции", 400)
|
if (action.operation.id !== "routing.community.switch") throw new McpError("INVALID_INPUT", "Контекст профиля не относится к этой операции", 400)
|
||||||
call.context.persistentRoutingProfile = z.object({ id: z.string().uuid(), version: z.number().int().positive().safe() }).strict().parse(internal.persistentRoutingProfile)
|
call.context.persistentRoutingProfile = z.object({ id: z.string().uuid(), version: z.number().int().positive().safe() }).strict().parse(internal.persistentRoutingProfile)
|
||||||
@@ -125,6 +147,7 @@ async function invokeMcpToolInner(principal: McpPrincipal, toolName: string, raw
|
|||||||
let result: unknown
|
let result: unknown
|
||||||
if (action.read) result = await action.read(input, call.context)
|
if (action.read) result = await action.read(input, call.context)
|
||||||
else if (action.prepare) {
|
else if (action.prepare) {
|
||||||
|
vrfPreparing = ["ros.vrf.create", "ros.vrf.update", "ros.vrf.delete"].includes(action.operation.id)
|
||||||
const evidence = await action.prepare(input, call.context)
|
const evidence = await action.prepare(input, call.context)
|
||||||
result = await transaction(async client => {
|
result = await transaction(async client => {
|
||||||
await client.query("SELECT id FROM mcp_connections WHERE id=$1 FOR UPDATE", [principal.connectionId])
|
await client.query("SELECT id FROM mcp_connections WHERE id=$1 FOR UPDATE", [principal.connectionId])
|
||||||
@@ -136,7 +159,7 @@ async function invokeMcpToolInner(principal: McpPrincipal, toolName: string, raw
|
|||||||
let decision = evaluateMcpPolicy(check)
|
let decision = evaluateMcpPolicy(check)
|
||||||
if (decision.decision === "denied") throw new McpError(decision.reasons[0].code, decision.reasons.map(reason => reason.message).join("; "))
|
if (decision.decision === "denied") throw new McpError(decision.reasons[0].code, decision.reasons.map(reason => reason.message).join("; "))
|
||||||
const id = randomUUID(), expiresAt = preparedPlanExpiry(action.operation.id, action.operation.section, evidence)
|
const id = randomUUID(), expiresAt = preparedPlanExpiry(action.operation.id, action.operation.section, evidence)
|
||||||
if (!expiresAt) throw new McpError("PLAN_EXPIRED", "Срок исходного плана опроса истёк или недоступен; подготовьте новый план", 409)
|
if (!expiresAt) throw new McpError("PLAN_EXPIRED", "Срок исходных данных плана истёк или недоступен; подготовьте новый план", 409)
|
||||||
const generation = await accessGeneration()
|
const generation = await accessGeneration()
|
||||||
await client.query("INSERT INTO mcp_plans(id,connection_id,operation_id,operation_version,policy_version,generation,payload,evidence,state,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9,$10)", [id, connection.id, action.operation.id, action.operation.version, connection.policy.version, generation, JSON.stringify({ sealed: await encryptSecret(JSON.stringify(input)) }), JSON.stringify({ sealed: await encryptSecret(JSON.stringify(evidence)) }), decision.decision === "approval-required" ? "awaiting-approval" : "draft", expiresAt])
|
await client.query("INSERT INTO mcp_plans(id,connection_id,operation_id,operation_version,policy_version,generation,payload,evidence,state,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9,$10)", [id, connection.id, action.operation.id, action.operation.version, connection.policy.version, generation, JSON.stringify({ sealed: await encryptSecret(JSON.stringify(input)) }), JSON.stringify({ sealed: await encryptSecret(JSON.stringify(evidence)) }), decision.decision === "approval-required" ? "awaiting-approval" : "draft", expiresAt])
|
||||||
const vrfTicket = await prepareRuntimeVrfTicket({ operationId: action.operation.id, operationVersion: action.operation.version, planId: id, connectionId: connection.id, ownerId: connection.owner_id, evidence, sql: client,
|
const vrfTicket = await prepareRuntimeVrfTicket({ operationId: action.operation.id, operationVersion: action.operation.version, planId: id, connectionId: connection.id, ownerId: connection.owner_id, evidence, sql: client,
|
||||||
@@ -172,13 +195,27 @@ async function invokeMcpToolInner(principal: McpPrincipal, toolName: string, raw
|
|||||||
await audit(client, connection.owner_id, "plan.create", connection.id, { planId: id, operationId: action.operation.id, decision })
|
await audit(client, connection.owner_id, "plan.create", connection.id, { planId: id, operationId: action.operation.id, decision })
|
||||||
return { planId: id, expiresAt: expiresAt.toISOString(), state: decision.decision === "approval-required" ? "awaiting-approval" : "draft", decision, changes: redact(publicMcpChangeEvidence(action.operation.id, evidence)), ...(vrfTicket ? { vrfTicket } : {}), executionAvailable: true }
|
return { planId: id, expiresAt: expiresAt.toISOString(), state: decision.decision === "approval-required" ? "awaiting-approval" : "draft", decision, changes: redact(publicMcpChangeEvidence(action.operation.id, evidence)), ...(vrfTicket ? { vrfTicket } : {}), executionAvailable: true }
|
||||||
})
|
})
|
||||||
|
vrfPreparing = false
|
||||||
} else throw new McpError("UNSUPPORTED_CAPABILITY", "Нет обработчика операции", 409)
|
} else throw new McpError("UNSUPPORTED_CAPABILITY", "Нет обработчика операции", 409)
|
||||||
|
assertActive()
|
||||||
const current = await effectiveMcpConnection(principal)
|
const current = await effectiveMcpConnection(principal)
|
||||||
|
assertActive()
|
||||||
if (fingerprint(current.policy) !== fingerprint(call.context.connection.policy) || fingerprint(current.owner_permissions) !== fingerprint(call.context.connection.owner_permissions)) throw new McpError("STATE_CONFLICT", "Доступ изменился во время вызова; результат не выдан", 409)
|
if (fingerprint(current.policy) !== fingerprint(call.context.connection.policy) || fingerprint(current.owner_permissions) !== fingerprint(call.context.connection.owner_permissions)) throw new McpError("STATE_CONFLICT", "Доступ изменился во время вызова; результат не выдан", 409)
|
||||||
const output = redact(result)
|
const output = redact(result)
|
||||||
await completeRequest(call.requestId, started, output)
|
await completeRequest(call.requestId, started, output)
|
||||||
|
assertActive()
|
||||||
return output
|
return output
|
||||||
} catch (error) { await completeRequest(call.requestId, started, null, error); throw error }
|
} catch (error) {
|
||||||
|
if (vrfPreparing) {
|
||||||
|
try {
|
||||||
|
const diagnostic = vrfPreparationDiagnostic(call.requestId, action.operation.id, error)
|
||||||
|
if (diagnostic) console.error(JSON.stringify(diagnostic))
|
||||||
|
} catch { /* Diagnostics must never replace the original failure. */ }
|
||||||
|
}
|
||||||
|
const failure = cancelled() ?? error
|
||||||
|
await completeRequest(call.requestId, started, null, failure)
|
||||||
|
throw failure
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadPlan(id: string, client: Pick<PoolClient, "query"> = pool): Promise<StoredPlan> {
|
export async function loadPlan(id: string, client: Pick<PoolClient, "query"> = pool): Promise<StoredPlan> {
|
||||||
@@ -200,6 +237,10 @@ export async function approvePlan(id: string, actor: AuthUser, approve: boolean)
|
|||||||
if (!["awaiting-approval", "draft"].includes(plan.state) || plan.expires_at.getTime() <= Date.now() || plan.generation !== await accessGeneration()) throw new McpError("PLAN_EXPIRED", "План устарел или уже запущен", 409)
|
if (!["awaiting-approval", "draft"].includes(plan.state) || plan.expires_at.getTime() <= Date.now() || plan.generation !== await accessGeneration()) throw new McpError("PLAN_EXPIRED", "План устарел или уже запущен", 409)
|
||||||
const action = findActionByOperation(plan.operation_id)
|
const action = findActionByOperation(plan.operation_id)
|
||||||
if (!hasPermission(actor.permissions, "mm:mcp:approve") || action.operation.requiredPermissions.some(permission => !hasPermission(actor.permissions, permission))) throw new McpError("PERMISSION_DENIED", "Нет прав на подтверждение действия")
|
if (!hasPermission(actor.permissions, "mm:mcp:approve") || action.operation.requiredPermissions.some(permission => !hasPermission(actor.permissions, permission))) throw new McpError("PERMISSION_DENIED", "Нет прав на подтверждение действия")
|
||||||
|
if (approve && actor.id !== connection.owner_id && plan.operation_id === "routing.community.switch" &&
|
||||||
|
requiresScopedRoutingOwnerApproval(JSON.parse(await decryptSecret(plan.evidence.sealed)) as ChangeEvidence)) {
|
||||||
|
throw new McpError("PERMISSION_DENIED", "Переключение постоянного профиля подтверждает владелец подключения", 403)
|
||||||
|
}
|
||||||
await client.query("UPDATE mcp_plans SET state=$2,approved_by=$3,approved_at=now() WHERE id=$1", [id, approve ? "approved" : "cancelled", actor.id])
|
await client.query("UPDATE mcp_plans SET state=$2,approved_by=$3,approved_at=now() WHERE id=$1", [id, approve ? "approved" : "cancelled", actor.id])
|
||||||
await audit(client, actor.id, approve ? "plan.approve" : "plan.reject", connection.id, { planId: id })
|
await audit(client, actor.id, approve ? "plan.approve" : "plan.reject", connection.id, { planId: id })
|
||||||
})
|
})
|
||||||
@@ -239,7 +280,7 @@ async function serviceExecute(service: string, input: Record<string, unknown>, c
|
|||||||
if (service === "users.create") return createUser(appUserCreateSchema.parse(input.user))
|
if (service === "users.create") return createUser(appUserCreateSchema.parse(input.user))
|
||||||
if (service === "users.update") return updateUser(String(input.id), appUserUpdateSchema.parse(input.user))
|
if (service === "users.update") return updateUser(String(input.id), appUserUpdateSchema.parse(input.user))
|
||||||
if (service === "users.delete") return deleteUser(String(input.id))
|
if (service === "users.delete") return deleteUser(String(input.id))
|
||||||
if (service === "users.binding_add") return addBinding(String(input.id), userBindingCreateSchema.parse(input.binding))
|
if (service === "users.binding_add") return addBinding(String(input.id), userBindingCreateSchema.parse(input.binding), { assertCurrent: assertVerifiedConfigurationAccess })
|
||||||
if (service === "users.binding_remove") return removeBinding(String(input.id), String(input.bindingId))
|
if (service === "users.binding_remove") return removeBinding(String(input.id), String(input.bindingId))
|
||||||
if (service === "logs.settings.update") { const result = await updateLogSettings(logSettingsPatchSchema.parse(input.settings)); await refreshScheduler(); return result }
|
if (service === "logs.settings.update") { const result = await updateLogSettings(logSettingsPatchSchema.parse(input.settings)); await refreshScheduler(); return result }
|
||||||
throw new McpError("UNSUPPORTED_CAPABILITY", "Неизвестный сервис", 409)
|
throw new McpError("UNSUPPORTED_CAPABILITY", "Неизвестный сервис", 409)
|
||||||
@@ -263,7 +304,11 @@ export async function verifyStep(step: ChangeStep, result: unknown, before?: Ros
|
|||||||
if (step.service === "users.binding_remove") { const current = await getUserById(String(step.input.id)); return !!current && !current.bindings.some(binding => binding.id === step.input.bindingId) }
|
if (step.service === "users.binding_remove") { const current = await getUserById(String(step.input.id)); return !!current && !current.bindings.some(binding => binding.id === step.input.bindingId) }
|
||||||
if (step.service === "users.binding_add") {
|
if (step.service === "users.binding_add") {
|
||||||
const binding = userBindingCreateSchema.parse(step.input.binding), current = await getUserById(String(step.input.id))
|
const binding = userBindingCreateSchema.parse(step.input.binding), current = await getUserById(String(step.input.id))
|
||||||
return !!current?.bindings.some(item => item.serverId === binding.serverId && item.interfaceName === binding.interfaceName && item.peerPublicKey === (binding.peerPublicKey ?? ""))
|
const createdId = result && typeof result === "object" ? (result as { id?: unknown }).id : undefined
|
||||||
|
return typeof createdId === "string" && !!current?.bindings.some(item => item.id === createdId
|
||||||
|
&& item.serverId === binding.serverId && item.interfaceName === binding.interfaceName.trim()
|
||||||
|
&& (binding.interfaceType === undefined || item.interfaceType === binding.interfaceType)
|
||||||
|
&& item.peerPublicKey === normalizeBindingPeer(item.interfaceType, binding.peerPublicKey))
|
||||||
}
|
}
|
||||||
if (step.service === "logs.settings.update") {
|
if (step.service === "logs.settings.update") {
|
||||||
const current = await getLogSettings() as unknown as Record<string, unknown>
|
const current = await getLogSettings() as unknown as Record<string, unknown>
|
||||||
@@ -305,6 +350,11 @@ export async function applyPlan(principal: McpPrincipal, planId: string, idempot
|
|||||||
const action = findActionByOperation(plan.operation_id)
|
const action = findActionByOperation(plan.operation_id)
|
||||||
if (plan.policy_version !== connection.policy.version || plan.operation_version !== action.operation.version || plan.generation !== await accessGeneration() || plan.expires_at.getTime() <= Date.now()) throw new McpError("PLAN_EXPIRED", "План или его разрешения устарели", 409)
|
if (plan.policy_version !== connection.policy.version || plan.operation_version !== action.operation.version || plan.generation !== await accessGeneration() || plan.expires_at.getTime() <= Date.now()) throw new McpError("PLAN_EXPIRED", "План или его разрешения устарели", 409)
|
||||||
if (!["draft", "approved"].includes(plan.state)) throw new McpError("APPROVAL_REQUIRED", "План требует подтверждения или недоступен", 409)
|
if (!["draft", "approved"].includes(plan.state)) throw new McpError("APPROVAL_REQUIRED", "План требует подтверждения или недоступен", 409)
|
||||||
|
let scopedPathExpiresAt: number | undefined
|
||||||
|
if (plan.operation_id === "routing.community.switch" && requiresScopedRoutingOwnerApproval(evidence)) {
|
||||||
|
if (plan.approved_by !== connection.owner_id) throw new McpError("APPROVAL_REQUIRED", "Переключение постоянного профиля подтверждает владелец подключения", 409)
|
||||||
|
scopedPathExpiresAt = await readScopedRoutingPathAdmissionDeadline(client, evidence, connection.id)
|
||||||
|
}
|
||||||
if (evidence.validUntil && (!Number.isFinite(Date.parse(evidence.validUntil)) || Date.now() >= Date.parse(evidence.validUntil))) {
|
if (evidence.validUntil && (!Number.isFinite(Date.parse(evidence.validUntil)) || Date.now() >= Date.parse(evidence.validUntil))) {
|
||||||
throw new McpError("MEASUREMENT_UNKNOWN", "Измерения плана устарели; подготовьте новый план", 409)
|
throw new McpError("MEASUREMENT_UNKNOWN", "Измерения плана устарели; подготовьте новый план", 409)
|
||||||
}
|
}
|
||||||
@@ -327,7 +377,7 @@ export async function applyPlan(principal: McpPrincipal, planId: string, idempot
|
|||||||
{
|
{
|
||||||
const grantWall=Date.now(),grantMonotonic=performance.now()
|
const grantWall=Date.now(),grantMonotonic=performance.now()
|
||||||
const grant=await readMcpTemporaryGrantOrigin(client,connection.id,principal.credentialId)
|
const grant=await readMcpTemporaryGrantOrigin(client,connection.id,principal.credentialId)
|
||||||
const admissionExpiresAt=Math.min(plan.expires_at.getTime(),evidence.validUntil?Date.parse(evidence.validUntil):Infinity,Date.parse(principal.credentialExpiresAt),Date.parse(connection.policy.expiresAt),connection.owner_valid_until.getTime(),grant.expiresAt??Infinity)
|
const admissionExpiresAt=Math.min(plan.expires_at.getTime(),evidence.validUntil?Date.parse(evidence.validUntil):Infinity,Date.parse(principal.credentialExpiresAt),Date.parse(connection.policy.expiresAt),connection.owner_valid_until.getTime(),grant.expiresAt??Infinity,scopedPathExpiresAt??Infinity)
|
||||||
originalServiceGrant=Object.freeze({...grant,monotonicEnd:grant.expiresAt===null?Infinity:grantMonotonic+Math.max(0,grant.expiresAt-grantWall),admissionExpiresAt,admissionMonotonicEnd:grantMonotonic+Math.max(0,admissionExpiresAt-grantWall)})
|
originalServiceGrant=Object.freeze({...grant,monotonicEnd:grant.expiresAt===null?Infinity:grantMonotonic+Math.max(0,grant.expiresAt-grantWall),admissionExpiresAt,admissionMonotonicEnd:grantMonotonic+Math.max(0,admissionExpiresAt-grantWall)})
|
||||||
await assertOriginalMcpTemporaryGrant(client,connection.id,principal.credentialId,originalServiceGrant)
|
await assertOriginalMcpTemporaryGrant(client,connection.id,principal.credentialId,originalServiceGrant)
|
||||||
}
|
}
|
||||||
@@ -413,6 +463,7 @@ async function executeOperationWithLease(operationId: string, principal: McpPrin
|
|||||||
const plan = await loadPlan(operation.plan_id), evidence = JSON.parse(await decryptSecret(plan.evidence.sealed)) as ChangeEvidence
|
const plan = await loadPlan(operation.plan_id), evidence = JSON.parse(await decryptSecret(plan.evidence.sealed)) as ChangeEvidence
|
||||||
const serverIds = evidence.target.scope === "servers" ? evidence.target.serverIds : evidence.objects.flatMap(object => object.serverId ? [object.serverId] : [])
|
const serverIds = evidence.target.scope === "servers" ? evidence.target.serverIds : evidence.objects.flatMap(object => object.serverId ? [object.serverId] : [])
|
||||||
let attempted = false, confirmed = 0, activeServiceStep = false
|
let attempted = false, confirmed = 0, activeServiceStep = false
|
||||||
|
let vrfDiagnosticFallback: ReturnType<typeof createVrfRuntimeDiagnosticBoundary> | undefined
|
||||||
let reservedForwardPassed = false
|
let reservedForwardPassed = false
|
||||||
let reservedRecoverySummary: Record<string, unknown> | undefined
|
let reservedRecoverySummary: Record<string, unknown> | undefined
|
||||||
const diagnosticOnly = evidence.steps.length === 1 && evidence.steps.every(step => step.kind === "service" && findMcpService(step.service)?.cancellableDiagnostic === true)
|
const diagnosticOnly = evidence.steps.length === 1 && evidence.steps.every(step => step.kind === "service" && findMcpService(step.service)?.cancellableDiagnostic === true)
|
||||||
@@ -520,17 +571,23 @@ async function executeOperationWithLease(operationId: string, principal: McpPrin
|
|||||||
originalExpiresAt:Math.min(plan.expires_at.getTime(),evidence.validUntil?Date.parse(evidence.validUntil):Infinity,Date.parse(principal.credentialExpiresAt),Date.parse(connection.policy.expiresAt),connection.owner_valid_until.getTime(),originalServiceGrant!.admissionExpiresAt),
|
originalExpiresAt:Math.min(plan.expires_at.getTime(),evidence.validUntil?Date.parse(evidence.validUntil):Infinity,Date.parse(principal.credentialExpiresAt),Date.parse(connection.policy.expiresAt),connection.owner_valid_until.getTime(),originalServiceGrant!.admissionExpiresAt),
|
||||||
serviceInput:step.input}:undefined
|
serviceInput:step.input}:undefined
|
||||||
const reservedStep: { outcome?: ReservedRoutingStepOutcome<unknown> } = {}
|
const reservedStep: { outcome?: ReservedRoutingStepOutcome<unknown> } = {}
|
||||||
|
vrfDiagnosticFallback = productionVrf && step.kind === "ros" && step.step.resource === "vrf" && ["ros.vrf.create", "ros.vrf.update", "ros.vrf.delete", "routing.vrf.update"].includes(plan.operation_id)
|
||||||
|
? createVrfRuntimeDiagnosticBoundary(operationId, step.step.action === "update" ? "patch" : step.step.action, diagnostic => { console.error(JSON.stringify(diagnostic)) }) : undefined
|
||||||
const result = await withVerifiedConfigurationChange({ actorId: connection.owner_id, connectionId: connection.id, serverIds, objects: evidence.objects, approved: !!plan.approved_by, serverWide: step.kind === "service", ...(serviceExecution?{serviceExecution}:{}), authorize: async () => { await (step.kind === "ros" ? authorizeCatalog() : authorize()) }, ...(step.kind === "ros" ? { beforeDispatch: async () => { await prefix!.check(authorizeCatalog); await authorize(); await authorizeCatalog(); if (productionVrf) { if (!vrfBeforeDispatch) throw new McpError("STATE_CONFLICT", "VRF command guard отсутствует", 409); await vrfBeforeDispatch() } } } : {}) },
|
const result = await withVerifiedConfigurationChange({ actorId: connection.owner_id, connectionId: connection.id, serverIds, objects: evidence.objects, approved: !!plan.approved_by, serverWide: step.kind === "service", ...(serviceExecution?{serviceExecution}:{}), authorize: async () => { await (step.kind === "ros" ? authorizeCatalog() : authorize()) }, ...(step.kind === "ros" ? { beforeDispatch: async () => { await prefix!.check(authorizeCatalog); await authorize(); await authorizeCatalog(); if (productionVrf) { if (!vrfBeforeDispatch) throw new McpError("STATE_CONFLICT", "VRF command guard отсутствует", 409); await vrfBeforeDispatch() } } } : {}) },
|
||||||
async () => {
|
async () => {
|
||||||
|
vrfDiagnosticFallback?.executorEntered()
|
||||||
if (step.kind === "ros") {
|
if (step.kind === "ros") {
|
||||||
if (!productionVrf) return executeRosStep(step.step)
|
if (!productionVrf) return executeRosStep(step.step)
|
||||||
const before = evidence.snapshots.find(snapshot => snapshot.resource === "vrf" && snapshot.serverId === step.step.serverId)
|
const before = evidence.snapshots.find(snapshot => snapshot.resource === "vrf" && snapshot.serverId === step.step.serverId)
|
||||||
if (!before?.sourceProof) throw new McpError("STATE_CONFLICT", "Нет VRF source-v3 proof", 409)
|
if (!before?.sourceProof) throw new McpError("STATE_CONFLICT", "Нет VRF source-v3 proof", 409)
|
||||||
if (!["ros.vrf.create", "ros.vrf.update", "ros.vrf.delete", "routing.vrf.update"].includes(plan.operation_id)) throw new McpError("STATE_CONFLICT", "VRF action binding изменён", 409)
|
if (!["ros.vrf.create", "ros.vrf.update", "ros.vrf.delete", "routing.vrf.update"].includes(plan.operation_id)) throw new McpError("STATE_CONFLICT", "VRF action binding изменён", 409)
|
||||||
return executeVrfProductionStep({ operationId, planId: plan.id, connectionId: connection.id, ownerId: connection.owner_id,
|
const receipt = await executeVrfProductionStep({ operationId, planId: plan.id, connectionId: connection.id, ownerId: connection.owner_id,
|
||||||
operationName: plan.operation_id as "ros.vrf.create" | "ros.vrf.update" | "ros.vrf.delete" | "routing.vrf.update", operationVersion: plan.operation_version,
|
operationName: plan.operation_id as "ros.vrf.create" | "ros.vrf.update" | "ros.vrf.delete" | "routing.vrf.update", operationVersion: plan.operation_version,
|
||||||
stepIndex: index, step: step.step, sourceProof: before.sourceProof, originalSnapshots: evidence.snapshots, authorize: authorizeCatalog,
|
stepIndex: index, step: step.step, sourceProof: before.sourceProof, originalSnapshots: evidence.snapshots, authorize: authorizeCatalog,
|
||||||
assertDependencies: () => prefix!.check(authorizeCatalog), installBeforeDispatch: guard => { vrfBeforeDispatch = guard } })
|
assertDependencies: () => prefix!.check(authorizeCatalog), installBeforeDispatch: guard => { vrfBeforeDispatch = guard },
|
||||||
|
onDiagnostic: diagnostic => { vrfDiagnosticFallback?.observed(diagnostic) } })
|
||||||
|
vrfDiagnosticFallback?.executorReturned()
|
||||||
|
return receipt
|
||||||
}
|
}
|
||||||
const forward = () => serviceExecute(step.service, step.input, { operationId, phase: "forward", stepIndex: index, ...(routingReservationForward ? { routingReservationForward } : {}) })
|
const forward = () => serviceExecute(step.service, step.input, { operationId, phase: "forward", stepIndex: index, ...(routingReservationForward ? { routingReservationForward } : {}) })
|
||||||
if (!routingReservationId) return step.service === "servers.poll" ? withRuntimePollPrincipalSource(principal, async (current, policyLoan) => {
|
if (!routingReservationId) return step.service === "servers.poll" ? withRuntimePollPrincipalSource(principal, async (current, policyLoan) => {
|
||||||
@@ -543,6 +600,7 @@ async function executeOperationWithLease(operationId: string, principal: McpPrin
|
|||||||
reservedStep.outcome = await executeReservedRoutingStep({ reservationId: routingReservationId, sourceOperationId: operationId, principal, forward })
|
reservedStep.outcome = await executeReservedRoutingStep({ reservationId: routingReservationId, sourceOperationId: operationId, principal, forward })
|
||||||
return reservedStep.outcome.forwardResult
|
return reservedStep.outcome.forwardResult
|
||||||
})
|
})
|
||||||
|
vrfDiagnosticFallback = undefined
|
||||||
if (reservedStep.outcome?.kind === "recovered") {
|
if (reservedStep.outcome?.kind === "recovered") {
|
||||||
// The generic source remains intent/result=null until the private
|
// The generic source remains intent/result=null until the private
|
||||||
// inverse has finished. Record the forward ACK, never B VERIFIED
|
// inverse has finished. Record the forward ACK, never B VERIFIED
|
||||||
@@ -608,6 +666,7 @@ async function executeOperationWithLease(operationId: string, principal: McpPrin
|
|||||||
assertOperationConfigurationLockCheckpoint()
|
assertOperationConfigurationLockCheckpoint()
|
||||||
if (receiptId && finishedAt) return { receiptId, operationId, connectionId:operation.connection_id, finishedAt }
|
if (receiptId && finishedAt) return { receiptId, operationId, connectionId:operation.connection_id, finishedAt }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
vrfDiagnosticFallback?.failure(error)
|
||||||
if (routingReservationId && hasConfirmedReservedRoutingForward(error)) confirmed = Math.max(confirmed, 1)
|
if (routingReservationId && hasConfirmedReservedRoutingForward(error)) confirmed = Math.max(confirmed, 1)
|
||||||
if (error instanceof VrfProductionAcknowledgedFailure) confirmed = Math.max(confirmed, error.acknowledgedWrites)
|
if (error instanceof VrfProductionAcknowledgedFailure) confirmed = Math.max(confirmed, error.acknowledgedWrites)
|
||||||
const code = error instanceof VrfProductionClaimOutcomeUnknown ? "VRF_CLAIM_OUTCOME_UNKNOWN" : error instanceof McpError ? error.code : "DEVICE_UNAVAILABLE"
|
const code = error instanceof VrfProductionClaimOutcomeUnknown ? "VRF_CLAIM_OUTCOME_UNKNOWN" : error instanceof McpError ? error.code : "DEVICE_UNAVAILABLE"
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
process.env.AUTH_REQUIRED = "true"
|
||||||
|
process.env.AUTH_JWT_SECRET = "users-bindings-owned-acceptance-secret"
|
||||||
|
const { pool, closePool } = await import("../../db/index.js")
|
||||||
|
const { applySqlMigrations } = await import("../../db/migrate.js")
|
||||||
|
const { updateMcpSettings } = await import("./store.js")
|
||||||
|
const actor = { id: "users-bindings-owner", email: "users@example.invalid", name: "User binding owner", apps: ["mm"], permissions: ["mm:settings:admin"], expiresAt: Date.now() + 3600000 }
|
||||||
|
try {
|
||||||
|
await applySqlMigrations(pool)
|
||||||
|
assert.equal((await pool.query("SELECT count(*)::int AS n FROM servers")).rows[0].n, 0, "Disposable empty database required")
|
||||||
|
await updateMcpSettings({ enabled: true }, actor)
|
||||||
|
await (await import("../../test/mcp-users-bindings.js")).verifyMcpUserBindings(actor)
|
||||||
|
} finally {
|
||||||
|
await closePool()
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { test } from "node:test"
|
||||||
|
import { vrfPreparationDiagnostic } from "./vrf-preparation-diagnostic.js"
|
||||||
|
|
||||||
|
const requestId = "12345678-1234-4123-8123-123456789abc"
|
||||||
|
const operationId = "ros.vrf.update"
|
||||||
|
function fixture(message = "VRF production transition refused: static address provenance/context", stack?: string) {
|
||||||
|
const error = new Error(message)
|
||||||
|
if (stack !== undefined) Object.defineProperty(error, "stack", { value: stack, configurable: true, writable: true })
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
const stack = "Error: SECRET-PASSWORD 192.0.2.9 native-body\n at must (C:\\private\\backend\\src\\services\\vrf-production-transition.ts:80:82)\n at wrapped (C:\\private\\backend\\src\\modules\\mcp\\vrf-ticket-runtime.ts:76:9)"
|
||||||
|
|
||||||
|
test("plain Error at known refusal produces bounded first owned source, stage and constant reason", () => {
|
||||||
|
const diagnostic = vrfPreparationDiagnostic(requestId, operationId, fixture(undefined, stack))
|
||||||
|
assert.deepEqual(diagnostic, { event: "mcp.vrf.preparation-failed", requestId, operationId, stage: "transition", errorClass: "Error", firstOwnedFrame: { source: "vrf-production-transition", line: 80 }, reason: "static-address-context" })
|
||||||
|
for (const secret of ["SECRET-PASSWORD", "192.0.2.9", "private", "native-body", "must", "stack", "message"]) assert.ok(!JSON.stringify(diagnostic).includes(secret), secret)
|
||||||
|
})
|
||||||
|
test("real constructed Error with native stack never returns function names or raw text", () => {
|
||||||
|
const diagnostic = vrfPreparationDiagnostic(requestId, "ros.vrf.create", new Error("SECRET native payload"))!
|
||||||
|
assert.equal(diagnostic.errorClass, "Error"); assert.equal(diagnostic.reason, "other")
|
||||||
|
assert.ok(!JSON.stringify(diagnostic).includes("SECRET"))
|
||||||
|
const known = new Error("VRF production transition refused: dependency connected route identity")
|
||||||
|
const result = vrfPreparationDiagnostic(requestId, operationId, known)!
|
||||||
|
assert.equal(result.reason, "connected-route-identity"); assert.equal(result.stage, "transition")
|
||||||
|
// Native lazy stack accessors stay unmaterialized. No prepareStackTrace hook
|
||||||
|
// is needed to classify the actual own-data refusal message.
|
||||||
|
assert.equal(typeof Object.getOwnPropertyDescriptor(known, "stack")?.get, "function")
|
||||||
|
assert.equal(result.firstOwnedFrame, undefined)
|
||||||
|
})
|
||||||
|
test("exact known messages map to enums; appended secrets remain other", () => {
|
||||||
|
for (const [message, reason] of [
|
||||||
|
["VRF production ticket refused: original dependency projection changed during preparation", "original-dependency-projection"],
|
||||||
|
["VRF production transition refused: dependency connected route identity", "connected-route-identity"],
|
||||||
|
["VRF production transition refused: missing ordered main VRF", "main-vrf-context"],
|
||||||
|
["VRF production transition refused: main VRF shape", "main-vrf-context"],
|
||||||
|
]) {
|
||||||
|
assert.equal(vrfPreparationDiagnostic(requestId, operationId, fixture(message))!.reason, reason)
|
||||||
|
assert.equal(vrfPreparationDiagnostic(requestId, operationId, fixture(message + " SECRET"))!.reason, "other")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
test("actual native Error unknown refusal suffix maps only reviewed stage without exposing secrets", () => {
|
||||||
|
for (const [prefix, stage] of [["VRF production read refused: ", "read"], ["VRF production admission refused: ", "admission"], ["VRF production transition refused: ", "transition"], ["VRF production ticket refused: ", "ticket"]]) {
|
||||||
|
const error = new Error(prefix + "SECRET-PASSWORD 192.0.2.9 native-body")
|
||||||
|
Object.defineProperty(error, "name", { get() { throw Error("SECRET name getter invoked") } })
|
||||||
|
const result = vrfPreparationDiagnostic(requestId, operationId, error)!
|
||||||
|
assert.equal(result.stage, stage); assert.equal(result.reason, "other"); assert.equal(result.firstOwnedFrame, undefined)
|
||||||
|
for (const secret of ["SECRET-PASSWORD", "192.0.2.9", "native-body", prefix]) assert.ok(!JSON.stringify(result).includes(secret))
|
||||||
|
}
|
||||||
|
const tooLong = fixture("VRF production read refused: " + "S".repeat(512))
|
||||||
|
assert.equal(vrfPreparationDiagnostic(requestId, operationId, tooLong)!.stage, "other")
|
||||||
|
})
|
||||||
|
test("proxy, prototype and toStringTag spoofing reject without triggering traps", () => {
|
||||||
|
let traps = 0
|
||||||
|
const fail = () => { traps++; throw Error("SECRET getter called") }
|
||||||
|
const proxy = new Proxy(fixture(), { get: fail, getPrototypeOf: fail, getOwnPropertyDescriptor: fail, ownKeys: fail })
|
||||||
|
const spoof = Object.create(Error.prototype) as object
|
||||||
|
Object.defineProperties(spoof, { message: { get: fail }, stack: { get: fail }, [Symbol.toStringTag]: { get: fail } })
|
||||||
|
for (const error of [proxy, spoof, { name: "Error", message: "SECRET", stack }]) assert.deepEqual(vrfPreparationDiagnostic(requestId, operationId, error), { event: "mcp.vrf.preparation-failed", requestId, operationId, stage: "other", errorClass: "NonError", reason: "other" })
|
||||||
|
assert.equal(traps, 0)
|
||||||
|
})
|
||||||
|
test("actual Error own accessors and constructor/name getters are never invoked", () => {
|
||||||
|
for (const field of ["message", "stack"]) {
|
||||||
|
const error = fixture(undefined, stack), calls = { count: 0 }
|
||||||
|
Object.defineProperty(error, field, { get() { calls.count++; throw Error("SECRET") } })
|
||||||
|
Object.defineProperty(error, "name", { get() { calls.count++; throw Error("SECRET") } })
|
||||||
|
Object.defineProperty(error, "constructor", { get() { calls.count++; throw Error("SECRET") } })
|
||||||
|
const diagnostic = vrfPreparationDiagnostic(requestId, operationId, error)!
|
||||||
|
assert.equal(diagnostic.reason, field === "stack" ? "static-address-context" : "other"); assert.equal(diagnostic.stage, field === "stack" ? "transition" : "other"); assert.equal(diagnostic.firstOwnedFrame, undefined)
|
||||||
|
assert.equal(calls.count, 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
test("built-in error classes use prototype identity and subclasses remain a fixed enum", () => {
|
||||||
|
for (const [error, expected] of [[new TypeError("SECRET"), "TypeError"], [new RangeError("SECRET"), "RangeError"], [new SyntaxError("SECRET"), "SyntaxError"], [new (class SecretError extends Error {})("SECRET"), "OtherNativeError"]] as const) assert.equal(vrfPreparationDiagnostic(requestId, operationId, error)!.errorClass, expected)
|
||||||
|
})
|
||||||
|
test("nested Linux/Windows/compiled paths return source enums with bounded line only", () => {
|
||||||
|
for (const path of ["/app/dist/services/vrf-production-read.js", "file:///app/dist/services/vrf-production-read.js", "file:///C:/workspace/backend/src/services/vrf-production-read.ts", "C:\\workspace\\backend\\src\\services\\vrf-production-read.ts"]) {
|
||||||
|
const diagnostic = vrfPreparationDiagnostic(requestId, operationId, fixture("unexpected", `Error: SECRET\n at external (/tmp/node_modules/native.js:99:2)\n at closure (${path}:203:12)`))!
|
||||||
|
assert.equal(diagnostic.stage, "read"); assert.deepEqual(diagnostic.firstOwnedFrame, { source: "vrf-production-read", line: 203 })
|
||||||
|
assert.ok(!JSON.stringify(diagnostic).includes(path))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
test("non-owned paths, wrong directories and invalid line numbers never pass through", () => {
|
||||||
|
for (const path of ["/app/dist/services/private-secret.ts:1:1", "/app/dist/other/vrf-production-read.ts:1:1", "/app/dist/services/vrf-production-read.ts:0:1", "/app/dist/services/vrf-production-read.ts:1000001:1", "https://secret.example/src/services/vrf-production-read.ts:1:1"]) assert.equal(vrfPreparationDiagnostic(requestId, operationId, fixture("unexpected", `Error: SECRET\n at run (${path})`))!.firstOwnedFrame, undefined)
|
||||||
|
})
|
||||||
|
test("stack scan has fixed byte/line bounds and never inspects error causes", () => {
|
||||||
|
const error = fixture("SECRET", "Error: SECRET\n" + " at external (/tmp/external.js:1:1)\n".repeat(70) + " at internal (/app/dist/services/vrf-production-transition.js:80:1)")
|
||||||
|
Object.defineProperty(error, "cause", { get() { throw Error("SECRET cause accessed") } })
|
||||||
|
assert.equal(vrfPreparationDiagnostic(requestId, operationId, error)!.firstOwnedFrame, undefined)
|
||||||
|
error.stack = "Error: " + "S".repeat(17000) + "\n at internal (/app/dist/services/vrf-production-transition.js:80:1)"
|
||||||
|
assert.equal(vrfPreparationDiagnostic(requestId, operationId, error)!.firstOwnedFrame, undefined)
|
||||||
|
})
|
||||||
|
test("nonerrors have no copied fields; non-VRF/non-UUID calls have no diagnostic", () => {
|
||||||
|
for (const error of [null, undefined, "SECRET", 42, Symbol("SECRET"), () => { throw Error("SECRET") }]) assert.equal(vrfPreparationDiagnostic(requestId, operationId, error)!.errorClass, "NonError")
|
||||||
|
for (const operation of ["ros.vrf.list", "routing.vrf.update", "mcp.operation.apply", "ros.bridge.update", "ros.vrf.update SECRET"]) assert.equal(vrfPreparationDiagnostic(requestId, operation, fixture()), undefined)
|
||||||
|
assert.equal(vrfPreparationDiagnostic("SECRET-not-a-uuid", operationId, fixture()), undefined)
|
||||||
|
})
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { types } from "node:util"
|
||||||
|
|
||||||
|
const ownedSources = {
|
||||||
|
"vrf-production-read": { directory: "services", stage: "read" },
|
||||||
|
"vrf-production-admission": { directory: "services", stage: "admission" },
|
||||||
|
"vrf-production-transition": { directory: "services", stage: "transition" },
|
||||||
|
"vrf-production-ticket-store": { directory: "services", stage: "ticket" },
|
||||||
|
"vrf-production-action-binding": { directory: "services", stage: "admission" },
|
||||||
|
"vrf-members": { directory: "services", stage: "read" },
|
||||||
|
"vrf-ticket-runtime": { directory: "modules/mcp", stage: "ticket" },
|
||||||
|
"resources": { directory: "modules/mcp", stage: "read" },
|
||||||
|
"actions": { directory: "modules/mcp", stage: "other" },
|
||||||
|
"runtime": { directory: "modules/mcp", stage: "runtime" },
|
||||||
|
} as const
|
||||||
|
type Source = keyof typeof ownedSources
|
||||||
|
type Stage = typeof ownedSources[Source]["stage"]
|
||||||
|
type Reason = "original-dependency-projection" | "static-address-context" | "connected-route-identity" | "main-vrf-context" | "other"
|
||||||
|
type ErrorClass = "Error" | "TypeError" | "RangeError" | "SyntaxError" | "ReferenceError" | "URIError" | "EvalError" | "OtherNativeError" | "NonError"
|
||||||
|
type Operation = "ros.vrf.create" | "ros.vrf.update" | "ros.vrf.delete"
|
||||||
|
export interface VrfPreparationDiagnostic {
|
||||||
|
event: "mcp.vrf.preparation-failed"; requestId: string; operationId: Operation;
|
||||||
|
stage: Stage; errorClass: ErrorClass;
|
||||||
|
firstOwnedFrame?: { source: Source; line: number };
|
||||||
|
reason: Reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only complete, reviewed constant refusals. No user/native text is copied and
|
||||||
|
// appending any text (including secrets) intentionally maps back to "other".
|
||||||
|
const reasons = new Map<string, Reason>([
|
||||||
|
["VRF production ticket refused: original dependency projection changed during preparation", "original-dependency-projection"],
|
||||||
|
["VRF production transition refused: static address provenance/context", "static-address-context"],
|
||||||
|
["VRF production transition refused: dependency connected route identity", "connected-route-identity"],
|
||||||
|
["VRF production transition refused: missing ordered main VRF", "main-vrf-context"],
|
||||||
|
["VRF production transition refused: main VRF shape", "main-vrf-context"],
|
||||||
|
])
|
||||||
|
const refusalStages: ReadonlyArray<readonly [string, Stage]> = [
|
||||||
|
["VRF production read refused: ", "read"],
|
||||||
|
["VRF production admission refused: ", "admission"],
|
||||||
|
["VRF production transition refused: ", "transition"],
|
||||||
|
["VRF production ticket refused: ", "ticket"],
|
||||||
|
]
|
||||||
|
const classes: Array<[object, ErrorClass]> = [
|
||||||
|
[Error.prototype, "Error"], [TypeError.prototype, "TypeError"], [RangeError.prototype, "RangeError"], [SyntaxError.prototype, "SyntaxError"],
|
||||||
|
[ReferenceError.prototype, "ReferenceError"], [URIError.prototype, "URIError"], [EvalError.prototype, "EvalError"],
|
||||||
|
]
|
||||||
|
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||||
|
|
||||||
|
function dataString(error: object, key: "message" | "stack"): string | undefined {
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(error, key)
|
||||||
|
return descriptor && Object.hasOwn(descriptor, "value") && typeof descriptor.value === "string" ? descriptor.value : undefined
|
||||||
|
}
|
||||||
|
function ownedFrame(stack: string | undefined): VrfPreparationDiagnostic["firstOwnedFrame"] {
|
||||||
|
if (!stack) return undefined
|
||||||
|
// Bound work before splitting, and ignore the first error-message line.
|
||||||
|
for (const line of stack.slice(0, 16384).split(/\r?\n/).slice(1, 65)) {
|
||||||
|
if (line.length > 2048 || !/^\s+at\s/.test(line)) continue
|
||||||
|
const match = /(?:^|[ (])(?:file:\/\/)?(?:[A-Za-z]:[\\/]|\/)[^\r\n]*?[\\/](?:src|dist)[\\/](services|modules[\\/]mcp)[\\/]([a-z-]+)\.(?:ts|js):([1-9]\d{0,6}):[1-9]\d{0,6}\)?$/.exec(line)
|
||||||
|
if (!match || !Object.hasOwn(ownedSources, match[2])) continue
|
||||||
|
const source = match[2] as Source, lineNumber = Number(match[3])
|
||||||
|
if (match[1].replaceAll("\\", "/") !== ownedSources[source].directory || lineNumber > 1000000) continue
|
||||||
|
return { source, line: lineNumber }
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure bounded diagnostic. Never inspect arbitrary error getters/proxies,
|
||||||
|
* retain original objects, or expose messages, paths, addresses or stack text. */
|
||||||
|
export function vrfPreparationDiagnostic(requestId: string, operationId: string, error: unknown): VrfPreparationDiagnostic | undefined {
|
||||||
|
if (typeof requestId !== "string" || !uuid.test(requestId) || !["ros.vrf.create", "ros.vrf.update", "ros.vrf.delete"].includes(operationId)) return undefined
|
||||||
|
const result: VrfPreparationDiagnostic = { event: "mcp.vrf.preparation-failed", requestId, operationId: operationId as Operation, stage: "other", errorClass: "NonError", reason: "other" }
|
||||||
|
// isNativeError is an internal-slot check, unlike instanceof/toString. It
|
||||||
|
// rejects Proxy(Error) and prototype/toStringTag imitations without traps.
|
||||||
|
if (!types.isNativeError(error)) return result
|
||||||
|
result.errorClass = classes.find(([prototype]) => Object.getPrototypeOf(error) === prototype)?.[1] ?? "OtherNativeError"
|
||||||
|
const message = dataString(error, "message"), stack = dataString(error, "stack")
|
||||||
|
if (message !== undefined && message.length <= 512) {
|
||||||
|
result.reason = reasons.get(message) ?? "other"
|
||||||
|
result.stage = refusalStages.find(([prefix]) => message.startsWith(prefix))?.[1] ?? "other"
|
||||||
|
}
|
||||||
|
// Node 22 uses a native lazy stack accessor. We deliberately do not invoke
|
||||||
|
// it (nor Error.prepareStackTrace); exact own-message reasons still work.
|
||||||
|
if (message === undefined || stack === undefined) return result
|
||||||
|
const frame = ownedFrame(stack)
|
||||||
|
if (frame) { result.firstOwnedFrame = frame; result.stage = ownedSources[frame.source].stage }
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -49,7 +49,7 @@ export async function prepareRuntimeVrfTicket(input: VrfRuntimeTicketInput, depe
|
|||||||
const actionId = vrfNativeActionFor(input.operationId, request)
|
const actionId = vrfNativeActionFor(input.operationId, request)
|
||||||
const context: PrepareVrfProductionTicketInput["context"] = { binding: { planId: input.planId, actorId: input.ownerId, serverId: native.serverId, sourceProof }, connectionId: input.connectionId, actionId,
|
const context: PrepareVrfProductionTicketInput["context"] = { binding: { planId: input.planId, actorId: input.ownerId, serverId: native.serverId, sourceProof }, connectionId: input.connectionId, actionId,
|
||||||
actionVersion: input.operationId === "routing.vrf.update" ? Number(findMcpOperation(actionId)?.version) : input.operationVersion,
|
actionVersion: input.operationId === "routing.vrf.update" ? Number(findMcpOperation(actionId)?.version) : input.operationVersion,
|
||||||
...(input.operationId === "routing.vrf.update" ? { authorityAction: { id: "routing.vrf.update" as const, version: input.operationVersion as 3 } } : {}) }
|
...(input.operationId === "routing.vrf.update" ? { authorityAction: { id: "routing.vrf.update" as const, version: input.operationVersion as 4 } } : {}) }
|
||||||
assertVrfActionContext(context, request); assertVrfAliasEvidence(context, request, evidence)
|
assertVrfActionContext(context, request); assertVrfAliasEvidence(context, request, evidence)
|
||||||
const authority = async () => {
|
const authority = async () => {
|
||||||
await input.assertAuthority()
|
await input.assertAuthority()
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { UserBindingCreate } from "@mmapp/contracts/users"
|
||||||
|
import { getServerRowById } from "../servers/repository/servers-repository.js"
|
||||||
|
import { MikrotikClient } from "../../services/mikrotik.js"
|
||||||
|
import { sameWireguardEndpoint } from "../../services/wireguard-source.js"
|
||||||
|
import { ipsecClientBindingKeys, type IpsecServerState } from "../../services/ipsec-live.js"
|
||||||
|
import { bindingNativeRows, readBindingTarget, UserBindingTargetError } from "./binding-target.js"
|
||||||
|
|
||||||
|
export function readNativeUserBindingTarget(input: UserBindingCreate, options: { signal?: AbortSignal; assertCurrent?: () => Promise<unknown> } = {}) {
|
||||||
|
return readBindingTarget(input, async () => {
|
||||||
|
const server = await getServerRowById(input.serverId)
|
||||||
|
if (!server?.enabled) throw new UserBindingTargetError("Сервер отключён или не найден", 404)
|
||||||
|
const client = MikrotikClient.fromServer(server)
|
||||||
|
return {
|
||||||
|
async assertCurrent() {
|
||||||
|
const fresh = await getServerRowById(input.serverId)
|
||||||
|
if (!fresh || !sameWireguardEndpoint(fresh, server)) throw new UserBindingTargetError("Источник привязки изменился", 409)
|
||||||
|
},
|
||||||
|
get: (path, timeout, signal, maximum) => client.get(path, timeout, signal, maximum),
|
||||||
|
ipsecKeys: (certs, peers, identities) => ipsecClientBindingKeys({ server,
|
||||||
|
certs: bindingNativeRows(certs, [".id", "name"]) as IpsecServerState["certs"],
|
||||||
|
peers: bindingNativeRows(peers, [".id"]) as IpsecServerState["peers"],
|
||||||
|
identities: bindingNativeRows(identities, [".id"]) as IpsecServerState["identities"],
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}, options)
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { resolveBindingInterface, resolveBindingPeer, readBindingTarget, UserBindingTargetError } from "./binding-target.js"
|
||||||
|
|
||||||
|
const row = (name: string, type: string, id = "*1") => ({ ".id": id, name, type })
|
||||||
|
const refused = (status: number) => (error: unknown) => error instanceof UserBindingTargetError && error.status === status
|
||||||
|
let cases = 0
|
||||||
|
for (const [native, expected] of [["ether", "ether"], ["bridge", "bridge"], ["vlan", "vlan"], ["vxlan", "vxlan"], ["gre-tunnel", "gre"], ["wg", "wg"], ["wireguard", "wg"], ["dummy", "other"]]) {
|
||||||
|
const iface = resolveBindingInterface({ serverId: 1, interfaceName: "actual" }, [row("actual", native)])
|
||||||
|
assert.equal(iface.type, expected); cases++
|
||||||
|
}
|
||||||
|
for (const raw of [undefined, {}, [{ ".id": "*1", name: "ether-guess" }], [row("actual", "")], [row("actual", "ether", "*G1")], [row("actual", "ether"), row("duplicate-id", "ether")]]) {
|
||||||
|
assert.throws(() => resolveBindingInterface({ serverId: 1, interfaceName: "actual" }, raw), refused(502)); cases++
|
||||||
|
}
|
||||||
|
assert.throws(() => resolveBindingInterface({ serverId: 1, interfaceName: "missing" }, []), refused(404)); cases++
|
||||||
|
assert.throws(() => resolveBindingInterface({ serverId: 1, interfaceName: "actual" }, [row("actual", "ether"), row("actual", "ether", "*2")]), refused(409)); cases++
|
||||||
|
assert.throws(() => resolveBindingInterface({ serverId: 1, interfaceName: "actual", interfaceType: "ether" }, [row("actual", "wg")]), refused(409)); cases++
|
||||||
|
const wgInput = { serverId: 1, interfaceName: "wg-real", peerPublicKey: "actual-key" }
|
||||||
|
const wg = resolveBindingInterface(wgInput, [row("wg-real", "wg")])
|
||||||
|
const peer = { ".id": "*2", interface: "wg-real", "public-key": "actual-key", "preshared-key": "PRIVATE_VALUE" }
|
||||||
|
assert.equal(resolveBindingPeer(wgInput, wg, [peer]).peerPublicKey, "actual-key"); cases++
|
||||||
|
assert.equal(JSON.stringify(resolveBindingPeer(wgInput, wg, [peer])).includes("PRIVATE_VALUE"), false); cases++
|
||||||
|
for (const peers of [[], [{ ...peer, interface: "foreign" }], [{ ...peer, "public-key": "foreign" }]]) {
|
||||||
|
assert.throws(() => resolveBindingPeer(wgInput, wg, peers), refused(404)); cases++
|
||||||
|
}
|
||||||
|
assert.throws(() => resolveBindingPeer(wgInput, wg, [peer, { ...peer, ".id": "*3" }]), refused(409)); cases++
|
||||||
|
assert.throws(() => resolveBindingPeer({ ...wgInput, peerPublicKey: "" }, wg, [peer]), refused(400)); cases++
|
||||||
|
for (const key of ["client-cn", "managed-psk-name"]) {
|
||||||
|
const input = { serverId: 1, interfaceName: "ipsec-vpn", peerPublicKey: key }
|
||||||
|
const iface = resolveBindingInterface(input)
|
||||||
|
assert.equal(resolveBindingPeer(input, iface, undefined, ["client-cn", "managed-psk-name"]).peerPublicKey, key); cases++
|
||||||
|
assert.throws(() => resolveBindingPeer(input, iface, undefined, [key, key]), refused(409)); cases++
|
||||||
|
assert.throws(() => resolveBindingPeer(input, iface, undefined, []), refused(404)); cases++
|
||||||
|
}
|
||||||
|
let current = true, authority = true, nativeCalls = 0
|
||||||
|
const open = async () => ({
|
||||||
|
async assertCurrent() { if (!current) throw new UserBindingTargetError("Источник привязки изменился", 409) },
|
||||||
|
async get(path: string, timeout: number, signal: AbortSignal, maximum: number) {
|
||||||
|
assert(timeout > 0 && timeout <= 10000); assert.equal(maximum, 1048576); assert(signal instanceof AbortSignal)
|
||||||
|
nativeCalls++
|
||||||
|
return path === "/interface" ? [row("wg-real", "wg")] : [peer]
|
||||||
|
},
|
||||||
|
ipsecKeys() { return ["client-cn", "managed-psk-name"] },
|
||||||
|
})
|
||||||
|
const selected = await readBindingTarget(wgInput, open, { assertCurrent: async () => { assert(authority, "current authority") } })
|
||||||
|
assert.equal(nativeCalls, 2); cases++
|
||||||
|
current = false; await assert.rejects(selected.assertCurrent(), refused(409)); cases++
|
||||||
|
current = true; authority = false; await assert.rejects(selected.assertCurrent(), /current authority/); cases++
|
||||||
|
authority = true
|
||||||
|
await assert.rejects(readBindingTarget(wgInput, async () => ({ ...await open(), async get() { current = false; return [row("wg-real", "wg")] } })), refused(409)); cases++
|
||||||
|
current = true
|
||||||
|
const cancelled = new AbortController(); cancelled.abort()
|
||||||
|
await assert.rejects(readBindingTarget(wgInput, open, { signal: cancelled.signal })); cases++
|
||||||
|
const ipsecPaths: string[] = []
|
||||||
|
const psk = await readBindingTarget({ serverId: 1, interfaceName: "ipsec-vpn", peerPublicKey: "managed-psk-name" }, async () => ({
|
||||||
|
...await open(), async get(path) { ipsecPaths.push(path); return [] },
|
||||||
|
}))
|
||||||
|
assert.equal(psk.peerPublicKey, "managed-psk-name")
|
||||||
|
assert.deepEqual(ipsecPaths, ["/certificate", "/ip/ipsec/peer", "/ip/ipsec/identity"]); cases++
|
||||||
|
await assert.rejects(readBindingTarget(wgInput, async () => ({ ...await open(), async get() { throw Error("PRIVATE_UPSTREAM_BODY") } })), error =>
|
||||||
|
refused(502)(error) && !(error as Error).message.includes("PRIVATE_UPSTREAM_BODY")); cases++
|
||||||
|
console.log("User binding native selectors, eight types, peer keys, scope/source/authority and privacy cases:", cases)
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import type { InterfaceType, UserBindingCreate } from "@mmapp/contracts/users"
|
||||||
|
import { mapRosInterfaceType } from "./iface-type.js"
|
||||||
|
import { normalizeBindingPeer, PeerBindError, peerDisplayName } from "./peer-bind.js"
|
||||||
|
import { nativeNetworkRows } from "../../services/network-observed-read.js"
|
||||||
|
|
||||||
|
export class UserBindingTargetError extends Error {
|
||||||
|
constructor(message: string, readonly status: 400 | 404 | 409 | 502) { super(message) }
|
||||||
|
}
|
||||||
|
export interface BindingInterface { ifaceName: string; type: InterfaceType; nativeId?: string }
|
||||||
|
export interface BindingTarget extends BindingInterface { peerPublicKey: string; peerName: string }
|
||||||
|
export function bindingNativeRows(raw: unknown, required: string[]) {
|
||||||
|
try {
|
||||||
|
const rows = nativeNetworkRows(raw, required)
|
||||||
|
if (rows.some(row => !/^\*[a-f0-9]+$/i.test(row[".id"]!))) throw new Error("native ID")
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
catch { throw new UserBindingTargetError("Не подтверждён native-каталог привязки.", 502) }
|
||||||
|
}
|
||||||
|
export function resolveBindingInterface(input: UserBindingCreate, raw?: unknown): BindingInterface {
|
||||||
|
const ifaceName = input.interfaceName.trim()
|
||||||
|
if (!ifaceName) throw new UserBindingTargetError("Имя интерфейса обязательно", 400)
|
||||||
|
let type: InterfaceType, nativeId: string | undefined
|
||||||
|
if (ifaceName === "ipsec-vpn") type = "ipsec"
|
||||||
|
else {
|
||||||
|
const rows = bindingNativeRows(raw, [".id", "name", "type"])
|
||||||
|
const matches = rows.filter(row => row.name === ifaceName)
|
||||||
|
if (!matches.length) throw new UserBindingTargetError("Интерфейс не найден", 404)
|
||||||
|
if (matches.length !== 1) throw new UserBindingTargetError("Неоднозначное имя интерфейса", 409)
|
||||||
|
type = mapRosInterfaceType(matches[0].type)
|
||||||
|
nativeId = matches[0][".id"]
|
||||||
|
if (type === "ipsec") throw new UserBindingTargetError("Для IPsec выберите каталог ipsec-vpn", 409)
|
||||||
|
}
|
||||||
|
if (input.interfaceType !== undefined && input.interfaceType !== type) throw new UserBindingTargetError("Тип интерфейса изменился или не соответствует устройству", 409)
|
||||||
|
return { ifaceName, type, ...(nativeId ? { nativeId } : {}) }
|
||||||
|
}
|
||||||
|
export function resolveBindingPeer(input: UserBindingCreate, iface: BindingInterface, rawPeers?: unknown, ipsecKeys?: string[]): BindingTarget {
|
||||||
|
let peerPublicKey: string
|
||||||
|
try { peerPublicKey = normalizeBindingPeer(iface.type, input.peerPublicKey) }
|
||||||
|
catch (error) { if (error instanceof PeerBindError) throw new UserBindingTargetError(error.message, 400); throw error }
|
||||||
|
if (iface.type === "wg") {
|
||||||
|
const matches = bindingNativeRows(rawPeers, [".id", "interface", "public-key"]).filter(row =>
|
||||||
|
[iface.ifaceName, iface.nativeId].includes(row.interface) && row["public-key"] === peerPublicKey)
|
||||||
|
if (!matches.length) throw new UserBindingTargetError("Пир WireGuard не найден на выбранном интерфейсе", 404)
|
||||||
|
if (matches.length !== 1) throw new UserBindingTargetError("Неоднозначный пир WireGuard", 409)
|
||||||
|
}
|
||||||
|
if (iface.type === "ipsec") {
|
||||||
|
if (!Array.isArray(ipsecKeys) || ipsecKeys.length > 10000 || ipsecKeys.some(key => typeof key !== "string" || !key.trim())) throw new UserBindingTargetError("Не подтверждён native-каталог клиентов IPsec", 502)
|
||||||
|
const matches = ipsecKeys.filter(key => key === peerPublicKey)
|
||||||
|
if (!matches.length) throw new UserBindingTargetError("Клиент IPsec не найден", 404)
|
||||||
|
if (matches.length !== 1) throw new UserBindingTargetError("Неоднозначный ключ клиента IPsec", 409)
|
||||||
|
}
|
||||||
|
return { ...iface, peerPublicKey, peerName: iface.type === "wg" ? peerDisplayName({ publicKey: peerPublicKey, name: input.peerName }) : "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BindingReadSession {
|
||||||
|
assertCurrent(): Promise<unknown>
|
||||||
|
get(path: string, timeoutMs: number, signal: AbortSignal, maxResponseBytes: number): Promise<unknown>
|
||||||
|
ipsecKeys(certs: unknown, peers: unknown, identities: unknown): string[]
|
||||||
|
}
|
||||||
|
/** Fixed catalogues only; no UI snapshot/history, writes, retries or authority token. */
|
||||||
|
export async function readBindingTarget(input: UserBindingCreate, open: () => Promise<BindingReadSession>, options: { signal?: AbortSignal; assertCurrent?: () => Promise<unknown> } = {}) {
|
||||||
|
const end = performance.now() + 10000, signal = options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(10000)]) : AbortSignal.timeout(10000)
|
||||||
|
const session = await open()
|
||||||
|
const assertCurrent = async () => {
|
||||||
|
signal.throwIfAborted()
|
||||||
|
if (performance.now() >= end) throw new UserBindingTargetError("Истёк срок проверки привязки", 502)
|
||||||
|
await options.assertCurrent?.()
|
||||||
|
await session.assertCurrent()
|
||||||
|
signal.throwIfAborted()
|
||||||
|
if (performance.now() >= end) throw new UserBindingTargetError("Истёк срок проверки привязки", 502)
|
||||||
|
}
|
||||||
|
const read = async (path: string) => {
|
||||||
|
await assertCurrent()
|
||||||
|
let value: unknown
|
||||||
|
try { value = await session.get(path, Math.max(1, Math.ceil(end - performance.now())), signal, 1048576) }
|
||||||
|
catch { throw new UserBindingTargetError("Native-каталог привязки недоступен", 502) }
|
||||||
|
await assertCurrent()
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
const iface = resolveBindingInterface(input, input.interfaceName.trim() === "ipsec-vpn" ? undefined : await read("/interface"))
|
||||||
|
let rawPeers: unknown, ipsecKeys: string[] | undefined
|
||||||
|
if (iface.type === "wg") rawPeers = await read("/interface/wireguard/peers")
|
||||||
|
if (iface.type === "ipsec") {
|
||||||
|
const certs = await read("/certificate"), peers = await read("/ip/ipsec/peer"), identities = await read("/ip/ipsec/identity")
|
||||||
|
ipsecKeys = session.ipsecKeys(certs, peers, identities)
|
||||||
|
}
|
||||||
|
const target = resolveBindingPeer(input, iface, rawPeers, ipsecKeys)
|
||||||
|
await assertCurrent()
|
||||||
|
return { ...target, assertCurrent }
|
||||||
|
}
|
||||||
@@ -41,10 +41,10 @@ import {
|
|||||||
parseRawInterfaces,
|
parseRawInterfaces,
|
||||||
} from "../iface-type.js"
|
} from "../iface-type.js"
|
||||||
import {
|
import {
|
||||||
normalizeBindingPeer,
|
|
||||||
PeerBindError,
|
|
||||||
peerDisplayName,
|
peerDisplayName,
|
||||||
} from "../peer-bind.js"
|
} from "../peer-bind.js"
|
||||||
|
import { readNativeUserBindingTarget } from "../binding-native-read.js"
|
||||||
|
import { UserBindingTargetError } from "../binding-target.js"
|
||||||
import { listWireGuardPeersForCatalog } from "../../../services/wireguard-live.js"
|
import { listWireGuardPeersForCatalog } from "../../../services/wireguard-live.js"
|
||||||
import { listIpsecClientsForCatalog } from "../../../services/ipsec-live.js"
|
import { listIpsecClientsForCatalog } from "../../../services/ipsec-live.js"
|
||||||
|
|
||||||
@@ -190,28 +190,22 @@ async function deleteUserImpl(id: string): Promise<void> {
|
|||||||
await deleteUserRowById(id)
|
await deleteUserRowById(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addBinding(userId: string, input: UserBindingCreate): Promise<UserBinding> { return executeGuardedApplicationWrite({ kind: "user", id: userId }, () => userMutationDependencies(userId, [input.serverId]), () => addBindingImpl(userId, input)) }
|
type BindingReadOptions = { signal?: AbortSignal; assertCurrent?: () => Promise<unknown> }
|
||||||
async function addBindingImpl(userId: string, input: UserBindingCreate): Promise<UserBinding> {
|
export function addBinding(userId: string, input: UserBindingCreate, options: BindingReadOptions = {}): Promise<UserBinding> { return executeGuardedApplicationWrite({ kind: "user", id: userId }, () => userMutationDependencies(userId, [input.serverId]), () => addBindingImpl(userId, input, options)) }
|
||||||
|
/** Read-only validation shared by plan preparation and the guarded write. */
|
||||||
|
export async function validateUserBindingCreate(userId: string, input: UserBindingCreate, options: BindingReadOptions = {}): Promise<{ ifaceName: string; type: InterfaceType; peerPublicKey: string; peerName: string; assertCurrent(): Promise<void> }> {
|
||||||
const user = await getUserRowById(userId)
|
const user = await getUserRowById(userId)
|
||||||
if (!user) throw new UsersServiceError("Пользователь не найден", 404)
|
if (!user) throw new UsersServiceError("Пользователь не найден", 404)
|
||||||
const serverRows = await db.select().from(servers).where(eq(servers.id, input.serverId)).limit(1)
|
const serverRows = await db.select().from(servers).where(eq(servers.id, input.serverId)).limit(1)
|
||||||
if (!serverRows[0]) throw new UsersServiceError("Сервер не найден", 404)
|
if (!serverRows[0]) throw new UsersServiceError("Сервер не найден", 404)
|
||||||
const ifaceName = input.interfaceName.trim()
|
let target: Awaited<ReturnType<typeof readNativeUserBindingTarget>>
|
||||||
if (!ifaceName) throw new UsersServiceError("Имя интерфейса обязательно", 400)
|
|
||||||
const type: InterfaceType = input.interfaceType ?? await inferIfaceType(input.serverId, ifaceName)
|
|
||||||
let peerPublicKey = ""
|
|
||||||
try {
|
try {
|
||||||
peerPublicKey = normalizeBindingPeer(type, input.peerPublicKey)
|
target = await readNativeUserBindingTarget(input, options)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof PeerBindError) throw new UsersServiceError(err.message, err.status)
|
if (err instanceof UserBindingTargetError) throw new UsersServiceError(err.message, err.status)
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
const peerName = type === "wg"
|
const { ifaceName, type, peerPublicKey, peerName, assertCurrent } = target
|
||||||
? peerDisplayName({
|
|
||||||
publicKey: peerPublicKey,
|
|
||||||
name: input.peerName,
|
|
||||||
})
|
|
||||||
: ""
|
|
||||||
const taken = await getBindingByServerIfacePeer(input.serverId, ifaceName, peerPublicKey)
|
const taken = await getBindingByServerIfacePeer(input.serverId, ifaceName, peerPublicKey)
|
||||||
if (taken) {
|
if (taken) {
|
||||||
throw new UsersServiceError(
|
throw new UsersServiceError(
|
||||||
@@ -221,8 +215,14 @@ async function addBindingImpl(userId: string, input: UserBindingCreate): Promise
|
|||||||
409,
|
409,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
return { ifaceName, type, peerPublicKey, peerName, assertCurrent }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addBindingImpl(userId: string, input: UserBindingCreate, options: BindingReadOptions): Promise<UserBinding> {
|
||||||
|
const { ifaceName, type, peerPublicKey, peerName, assertCurrent } = await validateUserBindingCreate(userId, input, options)
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
try {
|
try {
|
||||||
|
await assertCurrent()
|
||||||
const row = await createBindingRow({
|
const row = await createBindingRow({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
userId,
|
userId,
|
||||||
@@ -237,6 +237,7 @@ async function addBindingImpl(userId: string, input: UserBindingCreate): Promise
|
|||||||
})
|
})
|
||||||
return await toBindingDto(row)
|
return await toBindingDto(row)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof UserBindingTargetError) throw new UsersServiceError(err.message, err.status)
|
||||||
if (isUniqueConstraintError(err)) {
|
if (isUniqueConstraintError(err)) {
|
||||||
throw new UsersServiceError(
|
throw new UsersServiceError(
|
||||||
type === "wg"
|
type === "wg"
|
||||||
@@ -258,13 +259,6 @@ async function removeBindingImpl(userId: string, bindingId: string): Promise<voi
|
|||||||
await deleteBindingRowById(bindingId)
|
await deleteBindingRowById(bindingId)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function inferIfaceType(serverId: number, ifaceName: string): Promise<InterfaceType> {
|
|
||||||
const snap = await getLatestSnapshot(serverId)
|
|
||||||
const parsed = parseRawInterfaces(snap?.rawInterfaces)
|
|
||||||
const found = parsed.find((i) => i.name === ifaceName)
|
|
||||||
return found?.type ?? "other"
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getInterfaceCatalogState(serverId: number): Promise<InterfaceCatalogState> {
|
export async function getInterfaceCatalogState(serverId: number): Promise<InterfaceCatalogState> {
|
||||||
const serverRows = await db.select().from(servers).where(eq(servers.id, serverId)).limit(1)
|
const serverRows = await db.select().from(servers).where(eq(servers.id, serverId)).limit(1)
|
||||||
if (!serverRows[0]) throw new UsersServiceError("Сервер не найден", 404)
|
if (!serverRows[0]) throw new UsersServiceError("Сервер не найден", 404)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import * as operation from "../services/system-database-operation.js"
|
|||||||
|
|
||||||
// Real Fastify + plugin and shared gate/readiness wiring; SQL is inert and
|
// Real Fastify + plugin and shared gate/readiness wiring; SQL is inert and
|
||||||
// counted. No configured pool, credentials, native process or network is opened.
|
// counted. No configured pool, credentials, native process or network is opened.
|
||||||
async function fixture() {
|
async function fixture(authRequired = true) {
|
||||||
async function load<T extends object>(file: URL, modules: Record<string, unknown>): Promise<T> {
|
async function load<T extends object>(file: URL, modules: Record<string, unknown>): Promise<T> {
|
||||||
const exports: Record<string, unknown> = {}
|
const exports: Record<string, unknown> = {}
|
||||||
const compiled = ts.transpileModule(await readFile(file, "utf8"), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true } }).outputText
|
const compiled = ts.transpileModule(await readFile(file, "utf8"), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true } }).outputText
|
||||||
@@ -42,7 +42,7 @@ async function fixture() {
|
|||||||
const oauth = await load<{ mcpOauth: FastifyPluginAsync }>(new URL("./mcp-oauth.ts", import.meta.url), {
|
const oauth = await load<{ mcpOauth: FastifyPluginAsync }>(new URL("./mcp-oauth.ts", import.meta.url), {
|
||||||
"node:crypto": crypto, zod: { z }, "../db/index.js": { pool: { query } },
|
"node:crypto": crypto, zod: { z }, "../db/index.js": { pool: { query } },
|
||||||
"../services/shared-database-gate.js": shared,
|
"../services/shared-database-gate.js": shared,
|
||||||
"../config.js": { env: { authRequired: true, CORS_ORIGIN: "http://localhost:3000" } },
|
"../config.js": { env: { authRequired, CORS_ORIGIN: "http://localhost:3000" } },
|
||||||
"../plugins/auth.js": { requireAuth: async (req: { authUser?: { id: string } }) => { req.authUser = { id: "owned-admin" } } },
|
"../plugins/auth.js": { requireAuth: async (req: { authUser?: { id: string } }) => { req.authUser = { id: "owned-admin" } } },
|
||||||
"../modules/mcp/store.js": {
|
"../modules/mcp/store.js": {
|
||||||
audit: async () => { audits++ }, getConnection: async () => ({ generation, policy: { status: "active", expiresAt: new Date(Date.now() + 600000).toISOString() }, owner_valid_until: new Date(Date.now() + 600000) }),
|
audit: async () => { audits++ }, getConnection: async () => ({ generation, policy: { status: "active", expiresAt: new Date(Date.now() + 600000).toISOString() }, owner_valid_until: new Date(Date.now() + 600000) }),
|
||||||
@@ -142,3 +142,31 @@ test("ordinary invalid OAuth input preserves its existing protocol error and no-
|
|||||||
assert.equal(response.statusCode, 400); assert.deepEqual(response.json(), { error: "invalid_request" }); assert.equal(response.headers["cache-control"], "no-store"); assert.equal(f.counts().sqlCalls, 0)
|
assert.equal(response.statusCode, 400); assert.deepEqual(response.json(), { error: "invalid_request" }); assert.equal(response.headers["cache-control"], "no-store"); assert.equal(f.counts().sqlCalls, 0)
|
||||||
} finally { await f.app.close() }
|
} finally { await f.app.close() }
|
||||||
})
|
})
|
||||||
|
test("disabled Portal auth prevents token exchange and refresh before SQL but permits revocation", async () => {
|
||||||
|
const f = await fixture(false)
|
||||||
|
try {
|
||||||
|
const [, refresh, revoke] = f.publicRequests()
|
||||||
|
const code = {
|
||||||
|
...refresh,
|
||||||
|
payload: {
|
||||||
|
grant_type: "authorization_code", client_id: refresh.payload.client_id,
|
||||||
|
resource: "http://localhost:8000/mcp", redirect_uri: "http://localhost/callback",
|
||||||
|
code: "owned-code", code_verifier: "a".repeat(43),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const before = f.counts()
|
||||||
|
for (const request of [code, refresh]) {
|
||||||
|
const response = await f.app.inject(request)
|
||||||
|
assert.equal(response.statusCode, 503)
|
||||||
|
assert.deepEqual(response.json(), { error: "temporarily_unavailable" })
|
||||||
|
assert.equal(response.headers["cache-control"], "no-store")
|
||||||
|
assert.equal(response.headers.pragma, "no-cache")
|
||||||
|
}
|
||||||
|
const after = f.counts()
|
||||||
|
assert.equal(after.sqlCalls, before.sqlCalls)
|
||||||
|
assert.equal(after.transactions, before.transactions)
|
||||||
|
assert.equal(after.audits, before.audits)
|
||||||
|
assert.equal((await f.app.inject(revoke)).statusCode, 200)
|
||||||
|
assert.equal(f.counts().sqlCalls, before.sqlCalls + 1)
|
||||||
|
} finally { await f.app.close() }
|
||||||
|
})
|
||||||
@@ -11,6 +11,10 @@ import { mcpPublicUrl } from "./mcp-transport.js"
|
|||||||
import { assertPortalClaimsCurrent } from "../modules/mcp/portal-revocation.js"
|
import { assertPortalClaimsCurrent } from "../modules/mcp/portal-revocation.js"
|
||||||
|
|
||||||
const clientInput = z.object({ name: z.string().trim().min(1).max(100), redirectUris: z.array(z.string().url()).min(1).max(10) }).strict()
|
const clientInput = z.object({ name: z.string().trim().min(1).max(100), redirectUris: z.array(z.string().url()).min(1).max(10) }).strict()
|
||||||
|
const consentInput = z.discriminatedUnion("approve", [
|
||||||
|
z.object({ requestId: z.string().uuid(), approve: z.literal(true), connectionId: z.string().uuid() }).strict(),
|
||||||
|
z.object({ requestId: z.string().uuid(), approve: z.literal(false), connectionId: z.string().uuid().optional() }).strict(),
|
||||||
|
])
|
||||||
function safeRedirect(value: string): boolean {
|
function safeRedirect(value: string): boolean {
|
||||||
const url = new URL(value)
|
const url = new URL(value)
|
||||||
return !url.username && !url.password && !url.hash && (url.protocol === "https:" || url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))
|
return !url.username && !url.password && !url.hash && (url.protocol === "https:" || url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))
|
||||||
@@ -62,6 +66,7 @@ export const mcpOauth: FastifyPluginAsync = async app => {
|
|||||||
})
|
})
|
||||||
app.post("/oauth/token", async (request, reply) => {
|
app.post("/oauth/token", async (request, reply) => {
|
||||||
reply.header("Cache-Control", "no-store").header("Pragma", "no-cache")
|
reply.header("Cache-Control", "no-store").header("Pragma", "no-cache")
|
||||||
|
if (!env.authRequired) return reply.code(503).send({ error: "temporarily_unavailable" })
|
||||||
const input = z.object({ grant_type: z.enum(["authorization_code", "refresh_token"]), client_id: z.string().min(1).max(200), resource: z.literal(resource.href), code: z.string().max(200).optional(), redirect_uri: z.string().url().optional(), code_verifier: z.string().max(128).optional(), refresh_token: z.string().max(200).optional() }).strict().parse(request.body)
|
const input = z.object({ grant_type: z.enum(["authorization_code", "refresh_token"]), client_id: z.string().min(1).max(200), resource: z.literal(resource.href), code: z.string().max(200).optional(), redirect_uri: z.string().url().optional(), code_verifier: z.string().max(128).optional(), refresh_token: z.string().max(200).optional() }).strict().parse(request.body)
|
||||||
const result = await transaction(async db => {
|
const result = await transaction(async db => {
|
||||||
const { rows: [client] } = await db.query("SELECT id FROM mcp_oauth_clients WHERE id=$1 AND enabled=true", [input.client_id])
|
const { rows: [client] } = await db.query("SELECT id FROM mcp_oauth_clients WHERE id=$1 AND enabled=true", [input.client_id])
|
||||||
@@ -133,21 +138,29 @@ export const mcpOauth: FastifyPluginAsync = async app => {
|
|||||||
return authorization
|
return authorization
|
||||||
})
|
})
|
||||||
admin.post("/api/mcp/oauth/consent", async request => {
|
admin.post("/api/mcp/oauth/consent", async request => {
|
||||||
const input = z.object({ requestId: z.string().uuid(), connectionId: z.string().uuid(), approve: z.boolean() }).strict().parse(request.body)
|
const input = consentInput.parse(request.body)
|
||||||
return transaction(async db => {
|
return transaction(async db => {
|
||||||
const { rows: [authorization] } = await db.query("SELECT r.* FROM mcp_oauth_requests r JOIN mcp_oauth_clients c ON c.id=r.client_id WHERE r.id=$1 AND r.expires_at>now() AND r.code_digest IS NULL AND r.consumed_at IS NULL AND c.enabled=true FOR UPDATE OF r", [input.requestId])
|
const { rows: [authorization] } = await db.query("SELECT r.* FROM mcp_oauth_requests r JOIN mcp_oauth_clients c ON c.id=r.client_id WHERE r.id=$1 AND r.expires_at>now() AND r.code_digest IS NULL AND r.consumed_at IS NULL AND c.enabled=true FOR UPDATE OF r", [input.requestId])
|
||||||
if (!authorization) throw new McpError("NOT_FOUND", "Запрос согласия устарел", 404)
|
if (!authorization) throw new McpError("NOT_FOUND", "Запрос согласия устарел", 404)
|
||||||
const connection = await getConnection(input.connectionId, db)
|
|
||||||
if (connection.owner_id !== request.authUser!.id || connection.policy.status !== "active" || connection.generation !== await accessGeneration() || connection.owner_valid_until.getTime() <= Date.now()) throw new McpError("PERMISSION_DENIED", "Выберите своё действующее подключение с подтверждёнными правами владельца")
|
|
||||||
const redirect = new URL(authorization.redirect_uri)
|
const redirect = new URL(authorization.redirect_uri)
|
||||||
if (authorization.state) redirect.searchParams.set("state", authorization.state)
|
if (authorization.state) redirect.searchParams.set("state", authorization.state)
|
||||||
redirect.searchParams.set("iss", issuer)
|
redirect.searchParams.set("iss", issuer)
|
||||||
if (!input.approve) { redirect.searchParams.set("error", "access_denied"); await db.query("UPDATE mcp_oauth_requests SET consumed_at=now() WHERE id=$1", [authorization.id]) }
|
let connectionId: string | null = null
|
||||||
else {
|
if (input.approve) {
|
||||||
const code = randomToken("code"); redirect.searchParams.set("code", code)
|
const connection = await getConnection(input.connectionId, db)
|
||||||
await db.query("UPDATE mcp_oauth_requests SET code_digest=$2,connection_id=$3 WHERE id=$1", [authorization.id, tokenDigest(code), connection.id])
|
const generation = await accessGeneration(), now = Date.now()
|
||||||
|
if (connection.owner_id !== request.authUser!.id || connection.policy.status !== "active" || connection.generation !== generation ||
|
||||||
|
Date.parse(connection.policy.expiresAt) <= now || connection.owner_valid_until.getTime() <= now) {
|
||||||
|
throw new McpError("PERMISSION_DENIED", "Выберите своё действующее подключение с подтверждёнными правами владельца")
|
||||||
}
|
}
|
||||||
await audit(db, request.authUser!.id, "oauth.consent", connection.id, { clientId: authorization.client_id, approved: input.approve })
|
connectionId = connection.id
|
||||||
|
const code = randomToken("code"); redirect.searchParams.set("code", code)
|
||||||
|
await db.query("UPDATE mcp_oauth_requests SET code_digest=$2,connection_id=$3 WHERE id=$1", [authorization.id, tokenDigest(code), connectionId])
|
||||||
|
} else {
|
||||||
|
redirect.searchParams.set("error", "access_denied")
|
||||||
|
await db.query("UPDATE mcp_oauth_requests SET consumed_at=now() WHERE id=$1", [authorization.id])
|
||||||
|
}
|
||||||
|
await audit(db, request.authUser!.id, "oauth.consent", connectionId, { clientId: authorization.client_id, approved: input.approve })
|
||||||
return { redirectUrl: redirect.href }
|
return { redirectUrl: redirect.href }
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -71,7 +71,10 @@ function makeMcpServerWithBindings(principal: McpPrincipal, bindings: ReturnType
|
|||||||
}
|
}
|
||||||
// Only invoke errors are mapped to tool failure. Budget/serialization delivery
|
// Only invoke errors are mapped to tool failure. Budget/serialization delivery
|
||||||
// is handled separately, preserving real effects and prohibiting auto replay.
|
// is handled separately, preserving real effects and prohibiting auto replay.
|
||||||
return deliverRegisteredToolResult(contract, () => invokeMcpTool(principal, descriptor.toolName, input, signal), invocationError)
|
const toolSignal = descriptor.id === "logs.follow"
|
||||||
|
? signal ? AbortSignal.any([signal, sdkContext.mcpReq.signal]) : sdkContext.mcpReq.signal
|
||||||
|
: signal
|
||||||
|
return deliverRegisteredToolResult(contract, () => invokeMcpTool(principal, descriptor.toolName, input, toolSignal), invocationError)
|
||||||
}
|
}
|
||||||
// Ownership never inspects/rejects an actual result after invocation.
|
// Ownership never inspects/rejects an actual result after invocation.
|
||||||
return genericOwner ? genericOwner.callback(execute) : execute()
|
return genericOwner ? genericOwner.callback(execute) : execute()
|
||||||
|
|||||||
@@ -163,6 +163,23 @@ try {
|
|||||||
assert.ok(!response.body.includes("NEVER_"))
|
assert.ok(!response.body.includes("NEVER_"))
|
||||||
assert.equal(artifactQueries, 2, "malformed artifact stops after bounded metadata before ciphertext, owner, source or native access")
|
assert.equal(artifactQueries, 2, "malformed artifact stops after bounded metadata before ciphertext, owner, source or native access")
|
||||||
} finally { pool.query = originalQuery; pool.connect = originalConnect }
|
} finally { pool.query = originalQuery; pool.connect = originalConnect }
|
||||||
|
const profile = {
|
||||||
|
id: createBody.connectionId, connection_id: createBody.connectionId, version: 2,
|
||||||
|
config: { name: "Route AI test" }, enabled: true, grant_expires_at: "2099-01-01T00:00:00Z", updated_at: "2026-11-01T00:00:00Z",
|
||||||
|
state: { reason: "План ожидает подтверждения", candidateId: "wan-b", failureCount: 3,
|
||||||
|
baseline: { nativeRule: "NEVER_PRIVATE_BASELINE" }, failoverReserveCursor: "NEVER_PRIVATE_CURSOR" },
|
||||||
|
}
|
||||||
|
pool.query = (async (sql: string, values: unknown[]) => {
|
||||||
|
assert.match(sql, /FROM mcp_routing_profiles p JOIN mcp_connections c/)
|
||||||
|
assert.deepEqual(values, [true, "human-admin"])
|
||||||
|
return { rows: [profile] }
|
||||||
|
}) as typeof pool.query
|
||||||
|
try {
|
||||||
|
const listed = await app.inject({ url: "/api/mcp/routing-profiles", headers: admin })
|
||||||
|
assert.equal(listed.statusCode, 200, listed.body)
|
||||||
|
assert.deepEqual(listed.json().profiles, [{ ...profile, state: { reason: profile.state.reason, candidateId: "wan-b", failureCount: 3 } }])
|
||||||
|
assert.ok(!listed.body.includes("NEVER_PRIVATE_"))
|
||||||
|
} finally { pool.query = originalQuery }
|
||||||
env.authRequired = false
|
env.authRequired = false
|
||||||
assert.equal((await app.inject({ url: "/api/mcp/catalog" })).statusCode, 503)
|
assert.equal((await app.inject({ url: "/api/mcp/catalog" })).statusCode, 503)
|
||||||
assert.equal((await app.inject({ method: "POST", url: "/api/mcp/policy/preview", payload, headers: admin })).statusCode, 503)
|
assert.equal((await app.inject({ method: "POST", url: "/api/mcp/policy/preview", payload, headers: admin })).statusCode, 503)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { humanIpsecExistingCertificateExportMetadata, readHumanIpsecExistingCert
|
|||||||
import type { createIpsecExistingCertificateExportArtifactStore } from "../services/ipsec-existing-certificate-export-artifact.js"
|
import type { createIpsecExistingCertificateExportArtifactStore } from "../services/ipsec-existing-certificate-export-artifact.js"
|
||||||
import type { FastifyPluginAsync } from "fastify"
|
import type { FastifyPluginAsync } from "fastify"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
import { bridgeVlanConfigurationRequestSchema } from "@mmapp/contracts/bridge-vlan"
|
||||||
import { randomUUID, createHash } from "node:crypto"
|
import { randomUUID, createHash } from "node:crypto"
|
||||||
import { mcpCatalogResponseSchema, mcpConnectionCreateSchema, mcpPageQuerySchema, mcpPolicyPreviewRequestSchema, mcpPolicyPreviewResponseSchema, mcpSettingsPatchSchema, mcpTemporaryGrantSchema } from "@mmapp/contracts/mcp"
|
import { mcpCatalogResponseSchema, mcpConnectionCreateSchema, mcpPageQuerySchema, mcpPolicyPreviewRequestSchema, mcpPolicyPreviewResponseSchema, mcpSettingsPatchSchema, mcpTemporaryGrantSchema } from "@mmapp/contracts/mcp"
|
||||||
import { mcpProtectionCreateRequestSchema } from "../modules/mcp/protection-requests.js"
|
import { mcpProtectionCreateRequestSchema } from "../modules/mcp/protection-requests.js"
|
||||||
@@ -16,7 +17,7 @@ import { pool } from "../db/index.js"
|
|||||||
import { hasPermission, type AuthUser } from "../lib/permissions.js"
|
import { hasPermission, type AuthUser } from "../lib/permissions.js"
|
||||||
import { accessGeneration, decryptSecret, encryptSecret, McpError, randomToken, redact, tokenDigest } from "../modules/mcp/security.js"
|
import { accessGeneration, decryptSecret, encryptSecret, McpError, randomToken, redact, tokenDigest } from "../modules/mcp/security.js"
|
||||||
import { createMcpConnection, listMcpConnections, getMcpSettings, updateMcpSettings, getConnection, assertConnectionVisible, rotateConnectionToken, setConnectionStatus, updateMcpConnection, transaction, audit } from "../modules/mcp/store.js"
|
import { createMcpConnection, listMcpConnections, getMcpSettings, updateMcpSettings, getConnection, assertConnectionVisible, rotateConnectionToken, setConnectionStatus, updateMcpConnection, transaction, audit } from "../modules/mcp/store.js"
|
||||||
import { approvePlan, loadPlan, publicPlan } from "../modules/mcp/runtime.js"
|
import { approvePlan, loadPlan, publicPlan, readMcpPolicyPreviewInput } from "../modules/mcp/runtime.js"
|
||||||
import { applyPlan, invokeMcpTool } from "../modules/mcp/runtime.js"
|
import { applyPlan, invokeMcpTool } from "../modules/mcp/runtime.js"
|
||||||
import { findOwnerPlanReplayTombstone } from "../modules/mcp/operation-replay-tombstones.js"
|
import { findOwnerPlanReplayTombstone } from "../modules/mcp/operation-replay-tombstones.js"
|
||||||
import { routeAiProfileSchema } from "@mmapp/contracts/mcp-routing"
|
import { routeAiProfileSchema } from "@mmapp/contracts/mcp-routing"
|
||||||
@@ -28,6 +29,7 @@ import { assertPortalClaimsCurrent } from "../modules/mcp/portal-revocation.js"
|
|||||||
import { sharedDatabaseGate } from "../services/shared-database-gate.js"
|
import { sharedDatabaseGate } from "../services/shared-database-gate.js"
|
||||||
import { readFile } from "node:fs/promises"
|
import { readFile } from "node:fs/promises"
|
||||||
import { checkMcpProtocol, type ProtocolProbe } from "../modules/mcp/readiness.js"
|
import { checkMcpProtocol, type ProtocolProbe } from "../modules/mcp/readiness.js"
|
||||||
|
import { publicRoutingControllerState } from "../modules/mcp/routing-community-public.js"
|
||||||
import { humanBackupDownloadMetadata, readHumanBackupDownloadArtifact } from "../modules/mcp/backup-download-artifact.js"
|
import { humanBackupDownloadMetadata, readHumanBackupDownloadArtifact } from "../modules/mcp/backup-download-artifact.js"
|
||||||
import { humanDatabaseBackupMetadata, readHumanDatabaseBackupArtifact } from "../modules/mcp/database-backup-artifact.js"
|
import { humanDatabaseBackupMetadata, readHumanDatabaseBackupArtifact } from "../modules/mcp/database-backup-artifact.js"
|
||||||
import { humanWireguardExportMetadata, readHumanWireguardExportArtifact } from "../modules/mcp/wireguard-export-artifact.js"
|
import { humanWireguardExportMetadata, readHumanWireguardExportArtifact } from "../modules/mcp/wireguard-export-artifact.js"
|
||||||
@@ -38,6 +40,7 @@ import { readServerPollCapacity } from "../services/server-poll-capacity.js"
|
|||||||
import { connectProbeStorage } from "../services/server-probe-storage-connection.js"
|
import { connectProbeStorage } from "../services/server-probe-storage-connection.js"
|
||||||
|
|
||||||
const uuid = z.string().uuid()
|
const uuid = z.string().uuid()
|
||||||
|
const selectedPolicyPreviewSchema = mcpPolicyPreviewRequestSchema.pick({ operationId: true, target: true, objects: true, objectCount: true }).extend({ connectionId: uuid })
|
||||||
const deviceResource = z.enum(["ethernet", "bridge", "bridge_port", "bridge_vlan", "vlan", "ipv4_address", "ipv6_address", "vrf"])
|
const deviceResource = z.enum(["ethernet", "bridge", "bridge_port", "bridge_vlan", "vlan", "ipv4_address", "ipv6_address", "vrf"])
|
||||||
const devicePlanCommon = { connectionId: uuid, fields: z.record(z.string(), z.unknown()).default({}) }
|
const devicePlanCommon = { connectionId: uuid, fields: z.record(z.string(), z.unknown()).default({}) }
|
||||||
export const mcpDevicePlanBodySchema = z.discriminatedUnion("action", [
|
export const mcpDevicePlanBodySchema = z.discriminatedUnion("action", [
|
||||||
@@ -126,15 +129,25 @@ export function createMcpRoutes(checkClaims = assertPortalClaimsCurrent, options
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.post("/mcp/policy/preview", async (request, reply) => {
|
app.post("/mcp/policy/preview", async (request, reply) => {
|
||||||
const parsed = mcpPolicyPreviewRequestSchema.safeParse(request.body)
|
const parsed = z.union([selectedPolicyPreviewSchema, mcpPolicyPreviewRequestSchema]).safeParse(request.body)
|
||||||
if (!parsed.success) return reply.code(400).send({ error: "Некорректные параметры симуляции политики MCP", details: parsed.error.flatten() })
|
if (!parsed.success) return reply.code(400).send({ error: "Некорректные параметры симуляции политики MCP", details: parsed.error.flatten() })
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
let input
|
||||||
|
if ("connectionId" in parsed.data) {
|
||||||
|
const connection = await getConnection(parsed.data.connectionId)
|
||||||
|
assertConnectionVisible(connection, actorOf(request.authUser))
|
||||||
|
const [generation, settings, current] = await Promise.all([
|
||||||
|
accessGeneration(), getMcpSettings(), readMcpPolicyPreviewInput(connection, parsed.data),
|
||||||
|
])
|
||||||
|
const inactive = !settings.enabled || connection.generation !== generation || connection.owner_valid_until <= now
|
||||||
|
input = inactive && current.policy.status === "active" ? { ...current, policy: { ...current.policy, status: "paused" as const } } : current
|
||||||
|
} else input = parsed.data
|
||||||
return mcpPolicyPreviewResponseSchema.parse({
|
return mcpPolicyPreviewResponseSchema.parse({
|
||||||
simulation: true,
|
simulation: true,
|
||||||
executionAvailable: false,
|
executionAvailable: false,
|
||||||
evaluatedAt: now.toISOString(),
|
evaluatedAt: now.toISOString(),
|
||||||
policyVersion: parsed.data.policy.version,
|
policyVersion: input.policy.version,
|
||||||
result: evaluateMcpPolicy(parsed.data, now),
|
result: evaluateMcpPolicy(input, now),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -183,6 +196,20 @@ export function createMcpRoutes(checkClaims = assertPortalClaimsCurrent, options
|
|||||||
await audit(pool, actor.id, "device.web.plan", input.connectionId, { serverId: params.serverId, resource: params.resource, action: input.action, objectId: input.action === "create" ? null : input.id ?? null })
|
await audit(pool, actor.id, "device.web.plan", input.connectionId, { serverId: params.serverId, resource: params.resource, action: input.action, objectId: input.action === "create" ? null : input.id ?? null })
|
||||||
return ownerDelegate(async () => invokeMcpTool(principal, `mm_${params.resource}_${input.action}_plan`, mcpDevicePlanArguments(params.serverId, input)))
|
return ownerDelegate(async () => invokeMcpTool(principal, `mm_${params.resource}_${input.action}_plan`, mcpDevicePlanArguments(params.serverId, input)))
|
||||||
})
|
})
|
||||||
|
app.get("/mcp/devices/:serverId/bridge-vlan/configuration", async request => {
|
||||||
|
const { serverId } = z.object({ serverId: z.coerce.number().int().positive() }).parse(request.params)
|
||||||
|
const { connectionId } = z.object({ connectionId: uuid }).strict().parse(request.query)
|
||||||
|
const principal = await ownerPrincipal(connectionId, actorOf(request.authUser), "network.bridge-vlan.read")
|
||||||
|
return ownerDelegate(() => invokeMcpTool(principal, "mm_bridge_vlan_configuration_get", { serverId }))
|
||||||
|
})
|
||||||
|
app.post("/mcp/devices/:serverId/bridge-vlan/configuration/plan", async request => {
|
||||||
|
const { serverId } = z.object({ serverId: z.coerce.number().int().positive() }).parse(request.params)
|
||||||
|
const { connectionId, ...input } = bridgeVlanConfigurationRequestSchema.omit({ serverId: true }).extend({ connectionId: uuid }).strict().parse(request.body)
|
||||||
|
const actor = actorOf(request.authUser)
|
||||||
|
const principal = await ownerPrincipal(connectionId, actor, "network.bridge-vlan.configure")
|
||||||
|
await audit(pool, actor.id, "device.web.vlan-plan", connectionId, { serverId, bridgeId: input.bridgeId })
|
||||||
|
return ownerDelegate(() => invokeMcpTool(principal, "mm_bridge_vlan_configuration_plan", { serverId, ...input }))
|
||||||
|
})
|
||||||
app.get("/mcp/settings", async () => getMcpSettings())
|
app.get("/mcp/settings", async () => getMcpSettings())
|
||||||
app.put("/mcp/settings", async request => updateMcpSettings(mcpSettingsPatchSchema.parse(request.body), actorOf(request.authUser)))
|
app.put("/mcp/settings", async request => updateMcpSettings(mcpSettingsPatchSchema.parse(request.body), actorOf(request.authUser)))
|
||||||
app.post("/mcp/pause", async request => { await updateMcpSettings({ enabled: false }, actorOf(request.authUser)); return { ok: true } })
|
app.post("/mcp/pause", async request => { await updateMcpSettings({ enabled: false }, actorOf(request.authUser)); return { ok: true } })
|
||||||
@@ -310,14 +337,15 @@ export function createMcpRoutes(checkClaims = assertPortalClaimsCurrent, options
|
|||||||
permissions: actions.filter(action => connection.policy.scope.operationIds.includes(action.operation.id)).every(action => action.operation.requiredPermissions.every(permission => hasPermission(connection.owner_permissions, permission) && hasPermission(connection.policy.permissions, permission))),
|
permissions: actions.filter(action => connection.policy.scope.operationIds.includes(action.operation.id)).every(action => action.operation.requiredPermissions.every(permission => hasPermission(connection.owner_permissions, permission) && hasPermission(connection.policy.permissions, permission))),
|
||||||
credentials: (await pool.query("SELECT 1 FROM mcp_credentials WHERE connection_id=$1 AND revoked_at IS NULL AND expires_at>now() AND kind IN ('bearer','access') LIMIT 1", [connection.id])).rows.length > 0,
|
credentials: (await pool.query("SELECT 1 FROM mcp_credentials WHERE connection_id=$1 AND revoked_at IS NULL AND expires_at>now() AND kind IN ('bearer','access') LIMIT 1", [connection.id])).rows.length > 0,
|
||||||
}
|
}
|
||||||
const protocolProbe: ProtocolProbe = Object.values(checks).every(Boolean) ? await checkMcpProtocol(connection.id, actorOf(request.authUser)) : { status: "skipped", reason: "PRECHECK_FAILED", elapsedMs: 0, credentialType: "temporary-no-operations", clientCredentialsVerified: false }
|
const protocolProbe: ProtocolProbe = Object.values(checks).every(Boolean) ? await checkMcpProtocol(connection.id, actorOf(request.authUser)) : { status: "skipped", reason: "PRECHECK_FAILED", elapsedMs: 0, credentialType: "temporary-no-operations", capabilitiesRead: "skipped", clientCredentialsVerified: false }
|
||||||
const result = { ...checks, protocolHandshake: protocolProbe.status === "passed" }
|
const result = { ...checks, protocolHandshake: protocolProbe.status === "passed" }
|
||||||
await audit(pool, actorOf(request.authUser).id, "connection.check", connection.id, { checks: result, protocolProbe })
|
await audit(pool, actorOf(request.authUser).id, "connection.check", connection.id, { checks: result, protocolProbe })
|
||||||
return { ok: Object.values(result).every(Boolean), checks: result, protocolProbe, checkedAt: new Date().toISOString(), transport: "streamable-http", endpointPath: "/mcp" }
|
return { ok: Object.values(result).every(Boolean), checks: result, protocolProbe, checkedAt: new Date().toISOString(), transport: "streamable-http", endpointPath: "/mcp" }
|
||||||
})
|
})
|
||||||
app.get("/mcp/routing-profiles", async request => {
|
app.get("/mcp/routing-profiles", async request => {
|
||||||
const actor = actorOf(request.authUser)
|
const actor = actorOf(request.authUser)
|
||||||
return { profiles: (await pool.query("SELECT p.id,p.connection_id,p.version,p.config,p.state,p.enabled,p.grant_expires_at,p.updated_at FROM mcp_routing_profiles p JOIN mcp_connections c ON c.id=p.connection_id WHERE ($1::boolean OR c.owner_id=$2) ORDER BY p.updated_at DESC LIMIT 200", [hasPermission(actor.permissions, "mm:mcp:admin"), actor.id])).rows }
|
const { rows } = await pool.query("SELECT p.id,p.connection_id,p.version,p.config,p.state,p.enabled,p.grant_expires_at,p.updated_at FROM mcp_routing_profiles p JOIN mcp_connections c ON c.id=p.connection_id WHERE ($1::boolean OR c.owner_id=$2) ORDER BY p.updated_at DESC LIMIT 200", [hasPermission(actor.permissions, "mm:mcp:admin"), actor.id])
|
||||||
|
return { profiles: rows.map(row => ({ ...row, state: publicRoutingControllerState(row.state) })) }
|
||||||
})
|
})
|
||||||
app.post("/mcp/routing-profiles/:id/pause", async request => {
|
app.post("/mcp/routing-profiles/:id/pause", async request => {
|
||||||
const id = idOf(request.params), actor = actorOf(request.authUser)
|
const id = idOf(request.params), actor = actorOf(request.authUser)
|
||||||
|
|||||||
@@ -97,6 +97,9 @@ assert.equal(failedProperty.statusCode,503);assert.equal(failedProperty.json().r
|
|||||||
propertyFailure=false
|
propertyFailure=false
|
||||||
rawPayload=[{".id":"one",gateway:"192.0.2.1"}]
|
rawPayload=[{".id":"one",gateway:"192.0.2.1"}]
|
||||||
assert.equal((await app.inject({method:"GET",url:"/api/recursive-routes/gateways/catalog?serverId=1"})).json().gateways[0].status,"unknown")
|
assert.equal((await app.inject({method:"GET",url:"/api/recursive-routes/gateways/catalog?serverId=1"})).json().gateways[0].status,"unknown")
|
||||||
|
rawPayload=[{".id":"one",gateway:"192.0.2.1",inactive:"true"}]
|
||||||
|
assert.equal((await app.inject({method:"GET",url:"/api/recursive-routes/gateways/catalog?serverId=1"})).json().gateways[0].status,"inactive")
|
||||||
|
assert.equal((await app.inject({method:"GET",url:"/api/recursive-routes/gateways?serverId=1"})).json().gateways[0].status,"down")
|
||||||
for (const type of ["home-router", "exit-node"] as const) {
|
for (const type of ["home-router", "exit-node"] as const) {
|
||||||
server.enabled=false;server.type=type
|
server.enabled=false;server.type=type
|
||||||
const counts={writes,rawReads,savedSnapshots}
|
const counts={writes,rawReads,savedSnapshots}
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ const recursiveDefaults: RecursiveRouteDependencies = {
|
|||||||
// Every ownership marker is a comment. Keep all commented rows, including
|
// Every ownership marker is a comment. Keep all commented rows, including
|
||||||
// unsupported ones that must block an unsafe replacement of the managed set.
|
// unsupported ones that must block an unsafe replacement of the managed set.
|
||||||
return purpose === "gateways"
|
return purpose === "gateways"
|
||||||
? client.get<unknown>("/ip/route?.proplist=.id,gateway,active")
|
? client.get<unknown>("/ip/route?.proplist=.id,gateway,active,inactive")
|
||||||
: client.post<unknown>("/ip/route/print", { ".query": ["comment"] })
|
: client.post<unknown>("/ip/route/print", { ".query": ["comment"] })
|
||||||
},
|
},
|
||||||
readProperty: (server, id, property) => MikrotikClient.fromServer(server).post("/ip/route/get", { ".id": id, "value-name": property }),
|
readProperty: (server, id, property) => MikrotikClient.fromServer(server).post("/ip/route/get", { ".id": id, "value-name": property }),
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { test } from "node:test"
|
||||||
|
import { bridgeVlanConfigurationRequestSchema } from "@mmapp/contracts/bridge-vlan"
|
||||||
|
import { buildBridgeVlanConfiguration, bridgeVlanCurrentDesired, bridgeVlanInverseDesired, parseBridgeVlanIds, type BridgeVlanDesired, type BridgeVlanState } from "./bridge-vlan-configuration.js"
|
||||||
|
|
||||||
|
const cpu = { resource: "bridge", id: "*1" }, access = { resource: "ethernet", id: "*2" }, trunk = { resource: "ethernet", id: "*3" }
|
||||||
|
function fixture() {
|
||||||
|
const state: BridgeVlanState = {
|
||||||
|
bridges: [{ ".id": "*1", name: "br-users", disabled: "false", pvid: "1", "vlan-filtering": "false", "frame-types": "admit-all", "ingress-filtering": "true", mvrp: "false" }],
|
||||||
|
interfaces: [
|
||||||
|
{ ...cpu, name: "br-users", disabled: "false" }, { ...access, name: "ether2", disabled: "false" }, { ...trunk, name: "ether3", disabled: "false" },
|
||||||
|
{ resource: "ethernet", id: "*4", name: "ether-mgmt", disabled: "false" },
|
||||||
|
{ resource: "vlan", id: "*5", name: "mgmt-vlan", disabled: "false" },
|
||||||
|
],
|
||||||
|
ports: [{ ".id": "*10", bridge: "br-users", interface: "ether2", pvid: "10", "ingress-filtering": "true", "frame-types": "admit-all", disabled: "false" }],
|
||||||
|
vlans: [], vlanInterfaces: [],
|
||||||
|
ipv4Addresses: [{ ".id": "*20", address: "192.0.2.1/24", interface: "ether-mgmt", disabled: "false" }], ipv6Addresses: [],
|
||||||
|
}
|
||||||
|
const desired: BridgeVlanDesired = {
|
||||||
|
filtering: true, pvid: 1,
|
||||||
|
ports: [{ id: "*10", interface: access, pvid: 10, ingressFiltering: true, frameTypes: "admit-all", disabled: false }, { interface: trunk, pvid: 1, ingressFiltering: true, frameTypes: "admit-only-vlan-tagged", disabled: false }],
|
||||||
|
vlans: [{ vlanIds: "10", tagged: [cpu, trunk], untagged: [access], disabled: false }], removePortIds: [], removeVlanIds: [],
|
||||||
|
}
|
||||||
|
return structuredClone({ serverId: 1, bridgeId: "*1", state, desired, management: { serverId: 1, channelId: "verified-channel", host: "192.0.2.1", expiresAt: "2026-11-05T01:00:00Z" }, now: Date.parse("2026-11-05T00:00:00Z") })
|
||||||
|
}
|
||||||
|
const build = (change?: (value: ReturnType<typeof fixture>) => void) => { const value = fixture(); change?.(value); return buildBridgeVlanConfiguration(value) }
|
||||||
|
|
||||||
|
test("enable creates VLAN and port before filtering, stable IDs and foreign rows retained", () => {
|
||||||
|
const value = fixture(); value.state.ports.push({ ".id": "*99", bridge: "foreign", interface: "foreign-port" })
|
||||||
|
const original = structuredClone(value), plan = buildBridgeVlanConfiguration(value)
|
||||||
|
assert.deepEqual(plan.steps.map(step => [step.resource, step.action]), [["bridge_vlan", "create"], ["bridge_port", "create"], ["bridge", "update"]])
|
||||||
|
assert.deepEqual(plan.steps.at(-1)?.fields, { "vlan-filtering": "true" })
|
||||||
|
assert.equal(plan.steps[0].fields.tagged, "br-users,ether3")
|
||||||
|
assert.ok(plan.objects.some(object => object.kind === "bridge_port" && object.id === "*10"))
|
||||||
|
assert.deepEqual(value, original, "preparation must be pure")
|
||||||
|
})
|
||||||
|
test("strict VID parsing includes arbitrary ranges and rejects overlaps and invalid syntax", () => {
|
||||||
|
assert.deepEqual(parseBridgeVlanIds("4094,11-13,1"), [1, 11, 12, 13, 4094])
|
||||||
|
for (const bad of ["", "0", "4095", "10-9", "1,1", "1-3,3-4", "01", "1, 2", "1-", "+1", "1.1", "1,,2"]) assert.throws(() => parseBridgeVlanIds(bad), undefined, bad)
|
||||||
|
})
|
||||||
|
test("public schema rejects management evidence, unknown flags, and fractional PVID", () => {
|
||||||
|
const value = fixture(), request = { serverId: value.serverId, bridgeId: value.bridgeId, desired: value.desired }
|
||||||
|
assert.ok(bridgeVlanConfigurationRequestSchema.safeParse(request).success)
|
||||||
|
assert.ok(!bridgeVlanConfigurationRequestSchema.safeParse({ ...request, management: value.management }).success)
|
||||||
|
assert.ok(!bridgeVlanConfigurationRequestSchema.safeParse({ ...request, desired: { ...request.desired, managementSafe: true } }).success)
|
||||||
|
assert.ok(!bridgeVlanConfigurationRequestSchema.safeParse({ ...request, desired: { ...request.desired, pvid: 1.5 } }).success)
|
||||||
|
})
|
||||||
|
test("full desired set cannot silently omit existing rows or claim a foreign row", () => {
|
||||||
|
assert.throws(() => build(value => { value.desired.ports.shift() }), /полный список/)
|
||||||
|
assert.throws(() => build(value => { value.desired.ports[0].id = "*99" }), /полный список/)
|
||||||
|
assert.throws(() => build(value => { value.desired.removePortIds.push("*10") }), /Повтор/)
|
||||||
|
})
|
||||||
|
test("cross-bridge memberships and foreign slaves are refused", () => {
|
||||||
|
assert.throws(() => build(value => { value.desired.vlans[0].tagged.push({ resource: "ethernet", id: "*4" }) }), /не принадлежит/)
|
||||||
|
assert.throws(() => build(value => { value.state.ports.push({ ".id": "*98", interface: "ether3", bridge: "foreign" }) }), /другому bridge/)
|
||||||
|
assert.throws(() => build(value => { value.desired.ports[1].interface = cpu }), /является bridge/)
|
||||||
|
})
|
||||||
|
test("effective tagged/untagged overlap and duplicate membership are refused", () => {
|
||||||
|
assert.throws(() => build(value => { value.desired.vlans[0].tagged.push(access) }), /Повтор/)
|
||||||
|
assert.throws(() => build(value => { value.desired.vlans.push(structuredClone(value.desired.vlans[0])) }), /Неоднозначная/)
|
||||||
|
})
|
||||||
|
test("explicit untagged VLAN must agree with PVID; omitted untagged uses native PVID membership", () => {
|
||||||
|
assert.throws(() => build(value => { value.desired.ports[0].pvid = 11 }), /не соответствует PVID/)
|
||||||
|
assert.ok(build(value => { value.desired.vlans[0].untagged = [] }).steps.length > 0)
|
||||||
|
assert.throws(() => build(value => { value.desired.vlans[0].vlanIds = "10-11" }), /не соответствует PVID/)
|
||||||
|
})
|
||||||
|
|
||||||
|
function implicitPvidManagement() {
|
||||||
|
const value = fixture()
|
||||||
|
value.state.bridges[0]["vlan-filtering"] = "true"; value.state.bridges[0].pvid = "10"
|
||||||
|
value.state.ipv4Addresses[0].interface = "br-users"
|
||||||
|
value.state.vlans = [{ ".id": "*30", bridge: "br-users", dynamic: "true", "vlan-ids": "10", "current-untagged": "br-users,ether2" }]
|
||||||
|
value.desired = bridgeVlanCurrentDesired(value.state, value.bridgeId)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
test("implicit PVID CPU and access membership supports a safe edit without static VLAN commands", () => {
|
||||||
|
const value = implicitPvidManagement(); value.desired.ports[0].ingressFiltering = false
|
||||||
|
const plan = buildBridgeVlanConfiguration(value)
|
||||||
|
assert.deepEqual(plan.steps, [{ serverId: 1, resource: "bridge_port", action: "update", id: "*10", fields: { "ingress-filtering": "false" } }])
|
||||||
|
assert.equal(plan.managementPath.bridgeName, "br-users")
|
||||||
|
assert.deepEqual(plan.inverse.desired.vlans, [])
|
||||||
|
})
|
||||||
|
test("implicit management rejects CPU/port PVID migration and CPU frame admission loss", () => {
|
||||||
|
for (const change of [
|
||||||
|
(value: ReturnType<typeof implicitPvidManagement>) => { value.desired.pvid = 11 },
|
||||||
|
(value: ReturnType<typeof implicitPvidManagement>) => { value.desired.ports[0].pvid = 11 },
|
||||||
|
(value: ReturnType<typeof implicitPvidManagement>) => { value.desired.frameTypes = "admit-only-vlan-tagged" },
|
||||||
|
]) {
|
||||||
|
const value = implicitPvidManagement(); change(value)
|
||||||
|
assert.throws(() => buildBridgeVlanConfiguration(value), /CPU management|management/)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
test("CPU observation is required and MVRP dynamic registration is unsupported", () => {
|
||||||
|
for (const field of ["frame-types", "ingress-filtering"]) {
|
||||||
|
const value = implicitPvidManagement(); delete value.state.bridges[0][field]
|
||||||
|
assert.throws(() => buildBridgeVlanConfiguration(value))
|
||||||
|
}
|
||||||
|
const value = implicitPvidManagement(); value.state.bridges[0].mvrp = "true"
|
||||||
|
assert.throws(() => buildBridgeVlanConfiguration(value), error => error instanceof Error && "code" in error && error.code === "UNSUPPORTED_CAPABILITY")
|
||||||
|
})
|
||||||
|
test("tagged-only CPU does not acquire implicit PVID membership and untagged-only CPU cannot serve a VLAN interface", () => {
|
||||||
|
const value = fixture(); value.state.bridges[0]["frame-types"] = "admit-only-vlan-tagged"; value.desired.pvid = 10
|
||||||
|
assert.ok(buildBridgeVlanConfiguration(value).steps.length > 0)
|
||||||
|
const tagged = implicitPvidManagement()
|
||||||
|
tagged.state.vlans = [{ ".id": "*11", bridge: "br-users", "vlan-ids": "10", tagged: "br-users,ether2", disabled: "false" }]
|
||||||
|
tagged.state.ports[0]["frame-types"] = "admit-only-vlan-tagged"
|
||||||
|
tagged.state.bridges[0]["frame-types"] = "admit-only-untagged-and-priority-tagged"; tagged.state.bridges[0].pvid = "1"
|
||||||
|
tagged.state.vlanInterfaces = [{ ".id": "*5", name: "mgmt-vlan", interface: "br-users", "vlan-id": "10", disabled: "false" }]
|
||||||
|
tagged.state.ipv4Addresses[0].interface = "mgmt-vlan"
|
||||||
|
tagged.desired = bridgeVlanCurrentDesired(tagged.state, tagged.bridgeId); tagged.desired.ports[0].ingressFiltering = false
|
||||||
|
assert.throws(() => buildBridgeVlanConfiguration(tagged), /CPU management/)
|
||||||
|
})
|
||||||
|
test("multi-VLAN tagged ranges are supported without fabricated access ports", () => {
|
||||||
|
const plan = build(value => { value.desired.vlans.push({ vlanIds: "20-30,40", tagged: [cpu, trunk], untagged: [], disabled: false }) })
|
||||||
|
assert.equal(plan.steps.filter(step => step.resource === "bridge_vlan" && step.action === "create").length, 2)
|
||||||
|
})
|
||||||
|
test("independent management disables filtering first for edits and restores last", () => {
|
||||||
|
const plan = build(value => { value.state.bridges[0]["vlan-filtering"] = "true" })
|
||||||
|
assert.deepEqual(plan.steps[0].fields, { "vlan-filtering": "false" })
|
||||||
|
assert.deepEqual(plan.steps.at(-1)?.fields, { "vlan-filtering": "true" })
|
||||||
|
})
|
||||||
|
test("explicit deletion is supported, inverse recreates old configuration with original fields", () => {
|
||||||
|
const value = fixture()
|
||||||
|
value.state.vlans = [{ ".id": "*11", bridge: "br-users", "vlan-ids": "10", tagged: "br-users", untagged: "ether2", disabled: "false", comment: "original" }]
|
||||||
|
value.desired.filtering = false; value.desired.ports = []; value.desired.vlans = []
|
||||||
|
value.desired.removePortIds = ["*10"]; value.desired.removeVlanIds = ["*11"]
|
||||||
|
const plan = buildBridgeVlanConfiguration(value)
|
||||||
|
assert.deepEqual(plan.steps.map(step => [step.resource, step.action, step.id]), [["bridge_vlan", "delete", "*11"], ["bridge_port", "delete", "*10"]])
|
||||||
|
const inverse = bridgeVlanInverseDesired(plan, [])
|
||||||
|
assert.equal(inverse.ports[0].id, undefined); assert.equal(inverse.vlans[0].id, undefined)
|
||||||
|
assert.equal(inverse.vlans[0].comment, "original"); assert.equal(inverse.ports[0].pvid, 10)
|
||||||
|
assert.ok(plan.objects.some(object => object.kind === "ethernet" && object.id === access.id), "Removed old physical interface remains protected")
|
||||||
|
assert.ok(plan.objects.some(object => object.kind === "bridge" && object.id === cpu.id), "Removed old CPU membership remains protected")
|
||||||
|
})
|
||||||
|
test("inverse removes only exact executor ACK IDs, never predicted native IDs", () => {
|
||||||
|
const plan = build(), inverse = bridgeVlanInverseDesired(plan, [{ stepIndex: 0, id: "*A1" }, { stepIndex: 1, id: "*A2" }])
|
||||||
|
assert.deepEqual(inverse.removeVlanIds, ["*A1"]); assert.deepEqual(inverse.removePortIds, ["*A2"])
|
||||||
|
assert.throws(() => bridgeVlanInverseDesired(plan, []), /ACK/)
|
||||||
|
assert.throws(() => bridgeVlanInverseDesired(plan, [{ stepIndex: 0, id: "*A1" }, { stepIndex: 0, id: "*A2" }]), /Повтор/)
|
||||||
|
})
|
||||||
|
test("dynamic rows cannot be modified or deleted and never become commands", () => {
|
||||||
|
const plan = build(value => { value.state.vlans.push({ ".id": "*30", bridge: "br-users", dynamic: "true", "vlan-ids": "10", "current-untagged": "ether2" }) })
|
||||||
|
assert.ok(plan.steps.every(step => step.id !== "*30"))
|
||||||
|
assert.throws(() => build(value => { value.state.ports[0].dynamic = "true" }), /полный список/)
|
||||||
|
})
|
||||||
|
test("channel host must be an actual enabled configured address with live same-server receipt", () => {
|
||||||
|
assert.throws(() => build(value => { value.management.host = "198.51.100.1" }), /Адрес канала/)
|
||||||
|
assert.throws(() => build(value => { value.management.serverId = 2 }), /канала управления/)
|
||||||
|
assert.throws(() => build(value => { value.management.expiresAt = new Date(value.now).toISOString() }), /канала управления/)
|
||||||
|
assert.throws(() => build(value => { value.state.ipv4Addresses[0].disabled = "true" }), /Адрес канала/)
|
||||||
|
assert.throws(() => build(value => { value.state.ipv4Addresses.push({ ...value.state.ipv4Addresses[0], ".id": "*21" }) }), /однозначно/)
|
||||||
|
})
|
||||||
|
test("equivalent IPv6 channel addresses resolve without textual spelling assumptions", () => {
|
||||||
|
const plan = build(value => { value.management.host = "2001:db8::1"; value.state.ipv4Addresses = []; value.state.ipv6Addresses = [{ ".id": "*22", address: "2001:0db8:0:0:0:0:0:1/64", interface: "ether-mgmt", disabled: "false" }] })
|
||||||
|
assert.equal(plan.managementPath.addressId, "*22")
|
||||||
|
})
|
||||||
|
function bridgeManagement() {
|
||||||
|
const value = fixture()
|
||||||
|
value.state.bridges[0]["vlan-filtering"] = "true"
|
||||||
|
value.state.bridges[0].pvid = "10"
|
||||||
|
value.state.ipv4Addresses[0].interface = "br-users"
|
||||||
|
value.state.vlans = [{ ".id": "*11", bridge: "br-users", "vlan-ids": "10", untagged: "br-users,ether2", disabled: "false" }]
|
||||||
|
value.desired.pvid = 10
|
||||||
|
value.desired.vlans[0] = { id: "*11", vlanIds: "10", tagged: [trunk], untagged: [cpu, access], disabled: false }
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
test("same-bridge CPU management allows multiple safe changes without a filtering outage", () => {
|
||||||
|
const value = bridgeManagement(), plan = buildBridgeVlanConfiguration(value)
|
||||||
|
assert.equal(plan.managementPath.bridgeName, "br-users")
|
||||||
|
assert.ok(plan.steps.length >= 2)
|
||||||
|
assert.ok(plan.steps.every(step => step.fields["vlan-filtering"] === undefined))
|
||||||
|
})
|
||||||
|
test("removing redundant explicit CPU untagged declaration preserves derived PVID membership", () => {
|
||||||
|
const value = bridgeManagement(); value.desired.vlans[0].untagged = [access]
|
||||||
|
assert.ok(buildBridgeVlanConfiguration(value).steps.length > 0)
|
||||||
|
})
|
||||||
|
test("port removal and untagged migration cannot fabricate management safety", () => {
|
||||||
|
for (const change of [
|
||||||
|
(value: ReturnType<typeof bridgeManagement>) => { value.desired.ports.shift(); value.desired.removePortIds = ["*10"]; value.desired.vlans[0].untagged = [cpu] },
|
||||||
|
(value: ReturnType<typeof bridgeManagement>) => { value.desired.pvid = 20 },
|
||||||
|
]) { const value = bridgeManagement(); change(value); assert.throws(() => buildBridgeVlanConfiguration(value)) }
|
||||||
|
})
|
||||||
|
test("a direct bridge CPU address also preserves existing tagged external management paths", () => {
|
||||||
|
const value = implicitPvidManagement()
|
||||||
|
value.state.ports.push({ ".id": "*12", bridge: "br-users", interface: "ether3", pvid: "1", "ingress-filtering": "true", "frame-types": "admit-only-vlan-tagged", disabled: "false" })
|
||||||
|
value.state.vlans = [{ ".id": "*11", bridge: "br-users", "vlan-ids": "10", tagged: "ether3", disabled: "false" }]
|
||||||
|
value.desired = bridgeVlanCurrentDesired(value.state, value.bridgeId)
|
||||||
|
value.desired.ports[0].ingressFiltering = false
|
||||||
|
assert.equal(buildBridgeVlanConfiguration(value).steps.length, 1)
|
||||||
|
value.desired.vlans[0].tagged = []
|
||||||
|
assert.throws(() => buildBridgeVlanConfiguration(value), /CPU management/)
|
||||||
|
})
|
||||||
|
test("VLAN management derives VID and CPU membership from the configured parent", () => {
|
||||||
|
const value = fixture()
|
||||||
|
value.state.bridges[0]["vlan-filtering"] = "true"
|
||||||
|
value.state.ports[0]["frame-types"] = "admit-only-vlan-tagged"
|
||||||
|
value.state.ports[0].pvid = "1"
|
||||||
|
value.state.vlans = [{ ".id": "*11", bridge: "br-users", "vlan-ids": "10", tagged: "br-users,ether2", disabled: "false" }]
|
||||||
|
value.state.vlanInterfaces = [{ ".id": "*5", name: "mgmt-vlan", interface: "br-users", "vlan-id": "10", disabled: "false" }]
|
||||||
|
value.state.ipv4Addresses[0].interface = "mgmt-vlan"
|
||||||
|
value.desired.ports[0].pvid = 1; value.desired.ports[0].frameTypes = "admit-only-vlan-tagged"
|
||||||
|
value.desired.vlans[0] = { id: "*11", vlanIds: "10", tagged: [cpu, access, trunk], untagged: [], disabled: false }
|
||||||
|
const plan = buildBridgeVlanConfiguration(value)
|
||||||
|
assert.equal(plan.managementPath.vlanId, 10)
|
||||||
|
for (const [kind, id] of [["ipv4_address", "*20"], ["vlan", "*5"], ["bridge", "*1"], ["bridge_port", "*10"], ["ethernet", "*2"]]) assert.ok(plan.objects.some(object => object.kind === kind && object.id === id), `Missing protected management dependency ${kind}/${id}`)
|
||||||
|
assert.ok(plan.steps.every(step => step.fields["vlan-filtering"] === undefined))
|
||||||
|
value.desired.vlans[0].tagged = [access, trunk]
|
||||||
|
assert.throws(() => buildBridgeVlanConfiguration(value), /CPU management/)
|
||||||
|
})
|
||||||
|
test("unknown PVID/frame types/flags and duplicate source identities refuse preparation", () => {
|
||||||
|
assert.throws(() => build(value => { delete value.state.ports[0].pvid }))
|
||||||
|
assert.throws(() => build(value => { value.state.ports[0]["frame-types"] = "unknown" }))
|
||||||
|
assert.throws(() => build(value => { value.state.bridges[0]["vlan-filtering"] = "unknown" }))
|
||||||
|
assert.throws(() => build(value => { value.state.interfaces.push({ ...value.state.interfaces[0] }) }), /Повтор/)
|
||||||
|
})
|
||||||
|
test("unchanged full desired configuration has zero writes and omitted comments stay omitted", () => {
|
||||||
|
const value = bridgeManagement()
|
||||||
|
value.desired.ports.pop(); value.desired.vlans[0].tagged = []
|
||||||
|
const plan = buildBridgeVlanConfiguration(value)
|
||||||
|
assert.deepEqual(plan.steps, [])
|
||||||
|
assert.equal(plan.inverse.desired.ports[0].comment, undefined)
|
||||||
|
assert.equal(plan.inverse.desired.vlans[0].comment, undefined)
|
||||||
|
})
|
||||||
|
test("native projection to desired retains IDs and optional empty-field semantics", () => {
|
||||||
|
const value = bridgeManagement(), desired = bridgeVlanCurrentDesired(value.state, value.bridgeId)
|
||||||
|
assert.deepEqual(desired.vlans[0].tagged, [])
|
||||||
|
assert.equal(desired.ports[0].id, "*10")
|
||||||
|
assert.equal(desired.ports[0].comment, undefined)
|
||||||
|
assert.deepEqual(buildBridgeVlanConfiguration({ ...value, desired }).steps, [])
|
||||||
|
})
|
||||||
|
test("unchanged native VID range syntax does not generate formatting-only mutations", () => {
|
||||||
|
const value = fixture()
|
||||||
|
value.state.ports[0]["frame-types"] = "admit-only-vlan-tagged"
|
||||||
|
value.state.vlans = [{ ".id": "*11", bridge: "br-users", "vlan-ids": "20-30,40", tagged: "br-users,ether2", disabled: "false" }]
|
||||||
|
const desired = bridgeVlanCurrentDesired(value.state, value.bridgeId)
|
||||||
|
assert.deepEqual(buildBridgeVlanConfiguration({ ...value, desired }).steps, [])
|
||||||
|
})
|
||||||
|
test("dynamic PVID admission cannot conflict with an explicit tagged membership", () => {
|
||||||
|
assert.throws(() => build(value => {
|
||||||
|
value.desired.ports[1].frameTypes = "admit-all"; value.desired.ports[1].pvid = 10
|
||||||
|
}), /PVID создаёт конфликт/)
|
||||||
|
assert.throws(() => build(value => { value.desired.pvid = 10 }), /CPU PVID/)
|
||||||
|
})
|
||||||
|
test("unfiltered management ignores ingress flags: every existing L2 port is preserved", () => {
|
||||||
|
const value = fixture()
|
||||||
|
value.state.ipv4Addresses[0].interface = "br-users"
|
||||||
|
value.state.bridges[0].pvid = "10"
|
||||||
|
value.state.ports[0].pvid = "99"
|
||||||
|
value.desired.pvid = 10
|
||||||
|
value.desired.vlans[0].untagged = [cpu, access]; value.desired.vlans[0].tagged = [trunk]
|
||||||
|
assert.ok(buildBridgeVlanConfiguration(value).steps.length > 0)
|
||||||
|
value.desired.ports[0].frameTypes = "admit-only-vlan-tagged"
|
||||||
|
value.desired.vlans[0].untagged = [cpu]; value.desired.vlans[0].tagged = [access, trunk]
|
||||||
|
assert.throws(() => buildBridgeVlanConfiguration(value), /CPU management/)
|
||||||
|
})
|
||||||
|
test("adding a management parent as a bridge port cannot be presented as independence", () => {
|
||||||
|
assert.throws(() => build(value => {
|
||||||
|
value.desired.ports.push({ interface: { resource: "ethernet", id: "*4" }, pvid: 10, ingressFiltering: true, frameTypes: "admit-all", disabled: false })
|
||||||
|
value.desired.vlans[0].untagged.push({ resource: "ethernet", id: "*4" })
|
||||||
|
}), /management интерфейс в slave/)
|
||||||
|
})
|
||||||
|
test("inverse explicitly clears a newly assigned previously absent comment", () => {
|
||||||
|
const plan = build(value => { value.desired.ports[0].comment = "new comment" })
|
||||||
|
assert.equal(plan.inverse.desired.ports[0].comment, "")
|
||||||
|
})
|
||||||
|
test("independent management address, bridge and actual port dependencies enter protection policy", () => {
|
||||||
|
const value = fixture()
|
||||||
|
value.state.bridges.push({ ".id": "*40", name: "br-mgmt", disabled: "false", pvid: "1", "vlan-filtering": "false" })
|
||||||
|
value.state.interfaces.push({ resource: "bridge", id: "*40", name: "br-mgmt", disabled: "false" })
|
||||||
|
value.state.ports.push({ ".id": "*41", bridge: "br-mgmt", interface: "ether-mgmt", pvid: "1", "frame-types": "admit-all", disabled: "false" })
|
||||||
|
value.state.ipv4Addresses[0].interface = "br-mgmt"
|
||||||
|
const plan = buildBridgeVlanConfiguration(value)
|
||||||
|
for (const [kind, id] of [["ipv4_address", "*20"], ["bridge", "*40"], ["bridge_port", "*41"], ["ethernet", "*4"]]) assert.ok(plan.objects.some(object => object.kind === kind && object.id === id), `Missing protected management dependency ${kind}/${id}`)
|
||||||
|
})
|
||||||
|
test("reassigned old port and removed old VLAN members remain in protected references", () => {
|
||||||
|
const value = fixture()
|
||||||
|
value.desired.filtering = false
|
||||||
|
value.state.vlans = [{ ".id": "*11", bridge: "br-users", "vlan-ids": "10", tagged: "ether2", disabled: "false" }]
|
||||||
|
value.desired.ports = [{ ...value.desired.ports[0], interface: trunk }]
|
||||||
|
value.desired.vlans = [{ id: "*11", vlanIds: "10", tagged: [trunk], untagged: [], disabled: false }]
|
||||||
|
const plan = buildBridgeVlanConfiguration(value)
|
||||||
|
assert.ok(plan.objects.some(object => object.kind === "ethernet" && object.id === access.id))
|
||||||
|
assert.ok(plan.objects.some(object => object.kind === "ethernet" && object.id === trunk.id))
|
||||||
|
})
|
||||||
|
test("L3 GRE/WireGuard native bridge ports are rejected before any plan is emitted", () => {
|
||||||
|
for (const resource of ["gre", "wireguard"]) {
|
||||||
|
const value = fixture()
|
||||||
|
value.state.interfaces.find(iface => iface.id === trunk.id)!.resource = resource
|
||||||
|
value.desired.ports[1].interface.resource = resource
|
||||||
|
value.desired.vlans[0].tagged[1].resource = resource
|
||||||
|
assert.throws(() => buildBridgeVlanConfiguration(value), error => error instanceof Error && "code" in error && error.code === "UNSUPPORTED_CAPABILITY")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
test("catalogued L2 VLAN and VXLAN ports remain available", () => {
|
||||||
|
for (const resource of ["vlan", "vxlan"]) {
|
||||||
|
const value = fixture()
|
||||||
|
value.state.interfaces.find(iface => iface.id === trunk.id)!.resource = resource
|
||||||
|
value.desired.ports[1].interface.resource = resource
|
||||||
|
value.desired.vlans[0].tagged[1].resource = resource
|
||||||
|
if (resource === "vlan") value.state.vlanInterfaces.push({ ".id": trunk.id, name: "ether3", interface: "ether-mgmt", "vlan-id": "101", disabled: "false" })
|
||||||
|
assert.ok(buildBridgeVlanConfiguration(value).steps.some(step => step.resource === "bridge_port" && step.fields.interface === "ether3"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
test("a VLAN over the selected bridge cannot be fed back into that bridge", () => {
|
||||||
|
const value = fixture()
|
||||||
|
value.state.interfaces.find(iface => iface.id === trunk.id)!.resource = "vlan"
|
||||||
|
value.desired.ports[1].interface.resource = "vlan"; value.desired.vlans[0].tagged[1].resource = "vlan"
|
||||||
|
value.state.vlanInterfaces.push({ ".id": trunk.id, name: "ether3", interface: "br-users", "vlan-id": "101", disabled: "false" })
|
||||||
|
assert.throws(() => buildBridgeVlanConfiguration(value), /собственные порты/)
|
||||||
|
})
|
||||||
|
test("explicit port replacement deletes old interface claim before native CREATE", () => {
|
||||||
|
const value = fixture()
|
||||||
|
delete value.desired.ports[0].id; value.desired.removePortIds = ["*10"]
|
||||||
|
const plan = buildBridgeVlanConfiguration(value)
|
||||||
|
const removal = plan.steps.findIndex(step => step.resource === "bridge_port" && step.action === "delete" && step.id === "*10")
|
||||||
|
const addition = plan.steps.findIndex(step => step.resource === "bridge_port" && step.action === "create" && step.fields.interface === "ether2")
|
||||||
|
assert.ok(removal >= 0 && addition > removal)
|
||||||
|
const acks = plan.steps.flatMap((step, stepIndex) => step.action === "create" ? [{ stepIndex, id: `*${(stepIndex + 100).toString(16)}` }] : [])
|
||||||
|
const inverse = bridgeVlanInverseDesired(plan, acks)
|
||||||
|
assert.equal(inverse.ports[0].id, undefined)
|
||||||
|
assert.ok(inverse.removePortIds.includes(acks.find(ack => ack.stepIndex === addition)!.id))
|
||||||
|
})
|
||||||
|
test("swapping existing claimed interfaces has no executable order and refuses before publication", () => {
|
||||||
|
const value = fixture()
|
||||||
|
value.state.ports.push({ ".id": "*12", bridge: "br-users", interface: "ether3", pvid: "1", "ingress-filtering": "true", "frame-types": "admit-only-vlan-tagged", disabled: "true" })
|
||||||
|
value.desired.ports[1].id = "*12"
|
||||||
|
value.desired.ports[0].interface = trunk; value.desired.ports[1].interface = access
|
||||||
|
value.desired.filtering = false; value.desired.vlans = []
|
||||||
|
const before = structuredClone(value)
|
||||||
|
assert.throws(() => buildBridgeVlanConfiguration(value), /конфликта интерфейсов/)
|
||||||
|
assert.deepEqual(value, before)
|
||||||
|
})
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import { isIP } from "node:net"
|
||||||
|
import type { z } from "zod"
|
||||||
|
import { bridgeVlanDesiredSchema, bridgeVlanRowIdSchema as id, bridgeVlanInterfaceReferenceSchema as reference, bridgeVlanIdSchema as vid, bridgeVlanFrameTypesSchema as frames, type BridgeVlanDesired, type BridgeVlanState } from "@mmapp/contracts/bridge-vlan"
|
||||||
|
import type { ConfigurationRow, RosStep } from "../modules/mcp/resources.js"
|
||||||
|
export { bridgeVlanDesiredSchema, type BridgeVlanDesired, type BridgeVlanState }
|
||||||
|
export type BridgeVlanInterface = { resource: string; id: string; name: string; disabled?: unknown }
|
||||||
|
/** This argument belongs to the trusted server adapter, never the public request.
|
||||||
|
* The adapter verifies channel identity/revocation and decrypts its literal host.
|
||||||
|
* These values and all source snapshots must be sealed and rechecked on apply. */
|
||||||
|
export type BridgeVlanManagement = { channelId: string; serverId: number; expiresAt: string; host: string }
|
||||||
|
export class BridgeVlanConfigurationError extends Error {
|
||||||
|
constructor(readonly code: "INVALID_INPUT" | "STATE_CONFLICT" | "MANAGEMENT_UNSAFE" | "UNSUPPORTED_CAPABILITY", message: string) { super(message) }
|
||||||
|
}
|
||||||
|
/** L2 port kinds available in the actual typed configuration catalogue.
|
||||||
|
* RouterOS also supports EoIP/bond/Wi-Fi, but they need their own indexed
|
||||||
|
* configuration resource before this planner can prove their stable identity.
|
||||||
|
* GRE and WireGuard carry L3 traffic and cannot be native bridge ports.
|
||||||
|
* https://manual.mikrotik.com/docs/bridging-and-switching/
|
||||||
|
* https://help.mikrotik.com/docs/spaces/ROS/pages/24805531/GRE
|
||||||
|
* https://help.mikrotik.com/docs/spaces/ROS/pages/69664792/WireGuard */
|
||||||
|
export const BRIDGE_VLAN_L2_PORT_RESOURCES = ["ethernet", "vlan", "vxlan"] as const
|
||||||
|
function reject(message: string, code: BridgeVlanConfigurationError["code"] = "STATE_CONFLICT"): never { throw new BridgeVlanConfigurationError(code, message) }
|
||||||
|
const text = (row: ConfigurationRow, key: string): string => typeof row[key] === "string" ? row[key] as string : reject(`Не подтверждено поле ${key}`)
|
||||||
|
function flag(value: unknown): boolean {
|
||||||
|
if ([true, "true", "yes"].includes(value as boolean | string)) return true
|
||||||
|
if ([false, "false", "no"].includes(value as boolean | string)) return false
|
||||||
|
return reject("Не подтверждено логическое свойство")
|
||||||
|
}
|
||||||
|
const mutable = (row: ConfigurationRow) => !["dynamic", "builtin", "default"].some(key => row[key] !== undefined && flag(row[key]))
|
||||||
|
const enabled = (row: ConfigurationRow) => !flag(row.disabled)
|
||||||
|
const rowId = (row: ConfigurationRow) => id.parse(row[".id"])
|
||||||
|
const keyOf = (value: { resource: string; id: string }) => `${value.resource}:${value.id}`
|
||||||
|
const unique = (values: string[], label: string) => { if (new Set(values).size !== values.length) reject(`Повтор ${label}`, "INVALID_INPUT") }
|
||||||
|
|
||||||
|
/** Ranges are inclusive. Reject overlaps rather than silently deduplicating. */
|
||||||
|
export function parseBridgeVlanIds(value: string): number[] {
|
||||||
|
const found = new Set<number>()
|
||||||
|
for (const part of value.split(",")) {
|
||||||
|
if (!/^[1-9]\d{0,3}(?:-[1-9]\d{0,3})?$/.test(part)) reject("Некорректный VLAN ID/диапазон", "INVALID_INPUT")
|
||||||
|
const [start, rawEnd] = part.split("-").map(Number), end = rawEnd ?? start
|
||||||
|
if (start < 1 || end > 4094 || end < start) reject("VLAN ID вне 1..4094", "INVALID_INPUT")
|
||||||
|
for (let n = start; n <= end; n++) { if (found.has(n)) reject("Повтор VLAN ID", "INVALID_INPUT"); found.add(n) }
|
||||||
|
}
|
||||||
|
return [...found].sort((a, b) => a - b)
|
||||||
|
}
|
||||||
|
const names = (row: ConfigurationRow, field: string): string[] => {
|
||||||
|
if (row[field] === undefined && ["tagged", "untagged"].includes(field)) return []
|
||||||
|
const value = text(row, field)
|
||||||
|
if (!value) return []
|
||||||
|
const result = value.split(",")
|
||||||
|
if (result.some(name => !name || name.trim() !== name)) reject("Неоднозначный список интерфейсов")
|
||||||
|
unique(result, "интерфейса")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
const numberVid = (value: unknown) => vid.parse(typeof value === "string" && /^\d+$/.test(value) ? Number(value) : value)
|
||||||
|
type Topology = { bridge: ConfigurationRow; ports: ConfigurationRow[]; vlans: ConfigurationRow[] }
|
||||||
|
type Admission = { interface: string; mode: "tagged" | "untagged" }
|
||||||
|
export type BridgeVlanConfigurationPlan = {
|
||||||
|
serverId: number; bridgeId: string; steps: RosStep[];
|
||||||
|
objects: Array<{ kind: string; id: string; serverId: number }>;
|
||||||
|
managementPath: { channelId: string; addressId: string; interfaceName: string; bridgeName?: string; vlanId?: number };
|
||||||
|
inverse: { desired: BridgeVlanDesired; created: Array<{ stepIndex: number; resource: "bridge_port" | "bridge_vlan" }> };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert observed static rows without replacing unknown native properties
|
||||||
|
* with assumed defaults. Used by the editor and generic effective PATCH guard. */
|
||||||
|
export function bridgeVlanCurrentDesired(state: BridgeVlanState, bridgeId: string): BridgeVlanDesired {
|
||||||
|
const bridges = state.bridges.filter(row => row[".id"] === bridgeId)
|
||||||
|
if (bridges.length !== 1) reject("Bridge не найден однозначно")
|
||||||
|
const bridge = bridges[0], bridgeName = text(bridge, "name")
|
||||||
|
const refFor = (name: string) => {
|
||||||
|
const found = state.interfaces.filter(value => value.name === name)
|
||||||
|
if (found.length !== 1) reject("Не подтверждена идентичность интерфейса")
|
||||||
|
return { resource: found[0].resource, id: id.parse(found[0].id) }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
filtering: flag(bridge["vlan-filtering"]), pvid: numberVid(bridge.pvid), frameTypes: frames.parse(bridge["frame-types"]), ingressFiltering: flag(bridge["ingress-filtering"]),
|
||||||
|
ports: state.ports.filter(row => row.bridge === bridgeName && mutable(row)).map(row => ({ id: rowId(row), interface: refFor(text(row, "interface")), pvid: numberVid(row.pvid), ingressFiltering: flag(row["ingress-filtering"]), frameTypes: frames.parse(row["frame-types"]), disabled: flag(row.disabled), ...(row.comment === undefined ? {} : { comment: text(row, "comment") }) })),
|
||||||
|
vlans: state.vlans.filter(row => row.bridge === bridgeName && mutable(row)).map(row => ({ id: rowId(row), vlanIds: text(row, "vlan-ids"), tagged: names(row, "tagged").map(refFor), untagged: names(row, "untagged").map(refFor), disabled: flag(row.disabled), ...(row.comment === undefined ? {} : { comment: text(row, "comment") }) })),
|
||||||
|
removePortIds: [], removeVlanIds: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBridgeVlanConfiguration(input: {
|
||||||
|
serverId: number; bridgeId: string; desired: BridgeVlanDesired; state: BridgeVlanState; management: BridgeVlanManagement; now: number;
|
||||||
|
}): BridgeVlanConfigurationPlan {
|
||||||
|
const { serverId, bridgeId, state, management } = input, desired = bridgeVlanDesiredSchema.parse(input.desired)
|
||||||
|
if (!Number.isSafeInteger(serverId) || serverId <= 0 || management.serverId !== serverId || !management.channelId || !Number.isFinite(input.now) || !(Date.parse(management.expiresAt) > input.now) || !isIP(management.host)) reject("Нет актуального канала управления", "MANAGEMENT_UNSAFE")
|
||||||
|
const bridges = state.bridges.filter(row => row[".id"] === bridgeId)
|
||||||
|
if (bridges.length !== 1 || !mutable(bridges[0]) || !enabled(bridges[0])) reject("Bridge не подтверждён или отключён")
|
||||||
|
const bridge = bridges[0], bridgeName = text(bridge, "name")
|
||||||
|
// A dynamic VLAN table is not evidence of caller-owned rows. PVID-generated
|
||||||
|
// untagged membership is derived below; protocol registrations are unsupported.
|
||||||
|
if (bridge.mvrp !== undefined && flag(bridge.mvrp)) reject("MVRP создаёт программируемые VLAN memberships; этот режим не поддерживается", "UNSUPPORTED_CAPABILITY")
|
||||||
|
const cpuFrames = frames.parse(bridge["frame-types"]), cpuIngress = flag(bridge["ingress-filtering"])
|
||||||
|
const byRef = new Map(state.interfaces.map(iface => [keyOf(iface), iface]))
|
||||||
|
unique(state.interfaces.map(keyOf), "идентичности интерфейса")
|
||||||
|
unique(state.interfaces.map(iface => iface.name), "имени интерфейса")
|
||||||
|
const bridgeRef = state.interfaces.find(iface => iface.resource === "bridge" && iface.id === bridgeId && iface.name === bridgeName)
|
||||||
|
if (!bridgeRef) reject("Не подтверждена идентичность CPU bridge")
|
||||||
|
const resolve = (ref: z.infer<typeof reference>) => byRef.get(keyOf(ref)) ?? reject("Неизвестный стабильный ID интерфейса", "INVALID_INPUT")
|
||||||
|
const oldPorts = state.ports.filter(row => row.bridge === bridgeName), oldVlans = state.vlans.filter(row => row.bridge === bridgeName)
|
||||||
|
unique(state.ports.map(rowId), "ID порта"); unique(state.vlans.map(rowId), "ID membership")
|
||||||
|
// Dynamic rows remain owned by RouterOS; they are never converted to commands.
|
||||||
|
const staticPorts = oldPorts.filter(mutable), staticVlans = oldVlans.filter(mutable)
|
||||||
|
const account = (rows: ConfigurationRow[], keep: Array<{ id?: string }>, remove: string[], label: string) => {
|
||||||
|
const requested = [...keep.flatMap(row => row.id ? [row.id] : []), ...remove]
|
||||||
|
unique(requested, label)
|
||||||
|
const existing = rows.map(rowId)
|
||||||
|
if (requested.length !== existing.length || requested.some(value => !existing.includes(value))) reject(`Нужен явный полный список ${label}`, "INVALID_INPUT")
|
||||||
|
}
|
||||||
|
account(staticPorts, desired.ports, desired.removePortIds, "портов")
|
||||||
|
account(staticVlans, desired.vlans, desired.removeVlanIds, "VLAN memberships")
|
||||||
|
unique(desired.ports.map(port => keyOf(port.interface)), "порта bridge")
|
||||||
|
const portRows = desired.ports.map((port, index) => {
|
||||||
|
const iface = resolve(port.interface)
|
||||||
|
if (iface.name === bridgeName || iface.resource === "bridge" || state.ports.some(row => row.interface === iface.name && row.bridge !== bridgeName)) reject("Интерфейс уже принадлежит другому bridge или является bridge", "INVALID_INPUT")
|
||||||
|
if (!(BRIDGE_VLAN_L2_PORT_RESOURCES as readonly string[]).includes(iface.resource)) reject("Тип интерфейса не поддерживает подтверждённый L2 bridge port", "UNSUPPORTED_CAPABILITY")
|
||||||
|
let parent = iface.name
|
||||||
|
const ancestors = new Set<string>()
|
||||||
|
while (true) {
|
||||||
|
if (ancestors.has(parent)) reject("Цикл VLAN родителей bridge-порта", "INVALID_INPUT")
|
||||||
|
ancestors.add(parent)
|
||||||
|
const vlans = state.vlanInterfaces.filter(row => row.name === parent)
|
||||||
|
if (!vlans.length) {
|
||||||
|
if (parent === iface.name && iface.resource === "vlan") reject("Не подтверждён VLAN parent bridge-порта", "UNSUPPORTED_CAPABILITY")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (vlans.length !== 1) reject("Неоднозначный VLAN parent bridge-порта", "STATE_CONFLICT")
|
||||||
|
parent = text(vlans[0], "interface")
|
||||||
|
if (parent === bridgeName) reject("VLAN над выбранным bridge нельзя вернуть в его собственные порты", "INVALID_INPUT")
|
||||||
|
}
|
||||||
|
if (!port.disabled && iface.disabled !== undefined && flag(iface.disabled)) reject("Порт ссылается на отключённый интерфейс", "INVALID_INPUT")
|
||||||
|
const previous = staticPorts.find(row => row[".id"] === port.id)
|
||||||
|
return { ".id": port.id ?? `new-port-${index}`, bridge: bridgeName, interface: iface.name, pvid: String(port.pvid), "ingress-filtering": String(port.ingressFiltering), "frame-types": port.frameTypes, disabled: String(port.disabled), ...(port.comment !== undefined ? { comment: port.comment } : previous?.comment !== undefined ? { comment: text(previous, "comment") } : {}) }
|
||||||
|
})
|
||||||
|
const vlanRows = desired.vlans.map((vlan, index) => {
|
||||||
|
const tagged = vlan.tagged.map(ref => resolve(ref).name), untagged = vlan.untagged.map(ref => resolve(ref).name)
|
||||||
|
unique([...tagged, ...untagged], "tagged/untagged участника")
|
||||||
|
parseBridgeVlanIds(vlan.vlanIds)
|
||||||
|
const previous = staticVlans.find(row => row[".id"] === vlan.id)
|
||||||
|
return { ".id": vlan.id ?? `new-vlan-${index}`, bridge: bridgeName, "vlan-ids": vlan.vlanIds, ...(tagged.length || previous?.tagged !== undefined ? { tagged: tagged.join(",") } : {}), ...(untagged.length || previous?.untagged !== undefined ? { untagged: untagged.join(",") } : {}), disabled: String(vlan.disabled), ...(vlan.comment !== undefined ? { comment: vlan.comment } : previous?.comment !== undefined ? { comment: text(previous, "comment") } : {}) }
|
||||||
|
})
|
||||||
|
const target: Topology = { bridge: { ...bridge, pvid: String(desired.pvid), "vlan-filtering": String(desired.filtering), "frame-types": desired.frameTypes ?? cpuFrames, "ingress-filtering": String(desired.ingressFiltering ?? cpuIngress) }, ports: [...oldPorts.filter(row => !mutable(row)), ...portRows], vlans: [...oldVlans.filter(row => !mutable(row)), ...vlanRows] }
|
||||||
|
const activePorts = (topology: Topology) => topology.ports.filter(enabled)
|
||||||
|
const membership = (topology: Topology, vlanId: number, mode: "tagged" | "untagged") => {
|
||||||
|
const members = new Set(topology.vlans.filter(row => mutable(row) && enabled(row) && parseBridgeVlanIds(text(row, "vlan-ids")).includes(vlanId)).flatMap(row => names(row, mode)))
|
||||||
|
// RouterOS automatically includes CPU/ports that accept untagged ingress
|
||||||
|
// as untagged PVID members. Never synthesize a VLAN table command for it.
|
||||||
|
if (mode === "untagged") for (const port of [topology.bridge, ...activePorts(topology)]) {
|
||||||
|
if (enabled(port) && frames.parse(port["frame-types"]) !== "admit-only-vlan-tagged" && numberVid(port.pvid) === vlanId) members.add(port === topology.bridge ? bridgeName : text(port, "interface"))
|
||||||
|
}
|
||||||
|
return members
|
||||||
|
}
|
||||||
|
// Validate effective static state; dynamic PVID membership is derived from
|
||||||
|
// the actual port PVID, never accepted as caller-owned VLAN configuration.
|
||||||
|
const validate = (topology: Topology) => {
|
||||||
|
const ports = activePorts(topology), allowed = new Set([bridgeName, ...topology.ports.map(row => text(row, "interface"))])
|
||||||
|
const used = new Set<string>(), configuredVids = new Set<number>()
|
||||||
|
for (const vlan of topology.vlans.filter(row => mutable(row) && enabled(row))) {
|
||||||
|
const vids = parseBridgeVlanIds(text(vlan, "vlan-ids")), tagged = names(vlan, "tagged"), untagged = names(vlan, "untagged")
|
||||||
|
unique([...tagged, ...untagged], "tagged/untagged участника")
|
||||||
|
if ([...tagged, ...untagged].some(name => !allowed.has(name))) reject("Участник VLAN не принадлежит выбранному bridge", "INVALID_INPUT")
|
||||||
|
for (const n of vids) { if (configuredVids.has(n)) reject("Неоднозначная эффективная VLAN membership", "INVALID_INPUT"); configuredVids.add(n) }
|
||||||
|
for (const n of vids) for (const name of [...tagged, ...untagged]) {
|
||||||
|
const key = `${n}:${name}`
|
||||||
|
if (used.has(key)) reject("Неоднозначная эффективная VLAN membership", "INVALID_INPUT")
|
||||||
|
used.add(key)
|
||||||
|
const pvid = name === bridgeName ? numberVid(topology.bridge.pvid) : numberVid(topology.ports.find(row => row.interface === name)!.pvid)
|
||||||
|
if (untagged.includes(name) && (vids.length !== 1 || pvid !== n)) reject("Untagged VLAN не соответствует PVID", "INVALID_INPUT")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (flag(topology.bridge["vlan-filtering"])) for (const port of ports) {
|
||||||
|
const frameTypes = frames.parse(port["frame-types"])
|
||||||
|
if (frameTypes !== "admit-only-vlan-tagged" && membership(topology, numberVid(port.pvid), "tagged").has(text(port, "interface"))) reject("PVID создаёт конфликт tagged/untagged membership", "INVALID_INPUT")
|
||||||
|
}
|
||||||
|
if (flag(topology.bridge["vlan-filtering"]) && frames.parse(topology.bridge["frame-types"]) !== "admit-only-vlan-tagged" && membership(topology, numberVid(topology.bridge.pvid), "tagged").has(bridgeName)) reject("CPU PVID создаёт конфликт tagged/untagged membership", "INVALID_INPUT")
|
||||||
|
}
|
||||||
|
validate(target)
|
||||||
|
const addresses = [...state.ipv4Addresses, ...state.ipv6Addresses].filter(row => typeof row.address === "string" && isIP(row.address.split("/")[0]) === isIP(management.host) && sameAddress(row.address.split("/")[0], management.host) && enabled(row))
|
||||||
|
if (addresses.length !== 1) reject("Адрес канала не найден однозначно в конфигурации", "MANAGEMENT_UNSAFE")
|
||||||
|
const address = addresses[0], addressInterface = text(address, "interface")
|
||||||
|
let managementInterface = addressInterface, managementVid: number | undefined
|
||||||
|
const visited = new Set<string>()
|
||||||
|
while (true) {
|
||||||
|
if (visited.has(managementInterface)) reject("Цикл родителей management", "MANAGEMENT_UNSAFE")
|
||||||
|
visited.add(managementInterface)
|
||||||
|
const candidates = state.vlanInterfaces.filter(row => row.name === managementInterface)
|
||||||
|
if (!candidates.length) break
|
||||||
|
if (candidates.length !== 1 || !enabled(candidates[0]) || managementVid !== undefined) reject("Не подтверждён management VLAN parent", "MANAGEMENT_UNSAFE")
|
||||||
|
managementVid = numberVid(candidates[0]["vlan-id"]); managementInterface = text(candidates[0], "interface")
|
||||||
|
}
|
||||||
|
const managementPort = state.ports.find(row => row.interface === managementInterface)
|
||||||
|
const affected = managementInterface === bridgeName || managementPort?.bridge === bridgeName
|
||||||
|
// An IP directly assigned to a bridge slave does not prove CPU delivery.
|
||||||
|
if (managementPort?.bridge === bridgeName) reject("Management IP на slave-порту не доказывает CPU-доступ", "MANAGEMENT_UNSAFE")
|
||||||
|
if (target.ports.some(row => row.interface === managementInterface)) reject("План превращает management интерфейс в slave-порт", "MANAGEMENT_UNSAFE")
|
||||||
|
const managementBase = state.interfaces.find(iface => iface.name === managementInterface)
|
||||||
|
if (!managementBase || !["ethernet", "bridge"].includes(managementBase.resource)) reject("Не подтверждён физический management parent", "MANAGEMENT_UNSAFE")
|
||||||
|
if (managementPort) reject("Management IP на slave-порту не доказывает CPU-доступ", "MANAGEMENT_UNSAFE")
|
||||||
|
// A different bridge is independent only if none of its actual member
|
||||||
|
// interfaces depends on this bridge (for example a VLAN over this bridge).
|
||||||
|
const dependentOnTarget = (name: string, visiting = new Set<string>()): boolean => {
|
||||||
|
if (name === bridgeName || target.ports.some(row => row.interface === name)) return true
|
||||||
|
if (visiting.has(name)) reject("Цикл management зависимостей", "MANAGEMENT_UNSAFE")
|
||||||
|
const next = new Set(visiting).add(name)
|
||||||
|
const vlan = state.vlanInterfaces.find(row => row.name === name)
|
||||||
|
if (vlan) return dependentOnTarget(text(vlan, "interface"), next)
|
||||||
|
const iface = state.interfaces.find(item => item.name === name)
|
||||||
|
if (!iface || !["ethernet", "bridge"].includes(iface.resource)) reject("Не подтверждены management зависимости", "MANAGEMENT_UNSAFE")
|
||||||
|
return iface.resource === "bridge" && state.ports.filter(row => row.bridge === name && enabled(row)).some(row => dependentOnTarget(text(row, "interface"), next))
|
||||||
|
}
|
||||||
|
if (!affected && dependentOnTarget(managementInterface)) reject("Management зависит от изменяемого bridge", "MANAGEMENT_UNSAFE")
|
||||||
|
if (!state.interfaces.some(iface => iface.name === managementInterface && (iface.disabled === undefined || !flag(iface.disabled)))) reject("Не подтверждён management интерфейс", "MANAGEMENT_UNSAFE")
|
||||||
|
const admissions = (topology: Topology): Admission[] => {
|
||||||
|
if (!affected) return []
|
||||||
|
const filtering = flag(topology.bridge["vlan-filtering"]), vlanId = managementVid ?? numberVid(topology.bridge.pvid)
|
||||||
|
if (!filtering) return activePorts(topology).map(port => ({ interface: text(port, "interface"), mode: managementVid === undefined ? "untagged" as const : "tagged" as const }))
|
||||||
|
const cpuMode = managementVid === undefined ? "untagged" : "tagged"
|
||||||
|
const cpuFrameTypes = frames.parse(topology.bridge["frame-types"])
|
||||||
|
if (cpuMode === "untagged" && cpuFrameTypes === "admit-only-vlan-tagged" || cpuMode === "tagged" && cpuFrameTypes === "admit-only-untagged-and-priority-tagged") return []
|
||||||
|
if (filtering && !membership(topology, vlanId, cpuMode).has(bridgeName)) return []
|
||||||
|
if (membership(topology, vlanId, "tagged").has(bridgeName) && membership(topology, vlanId, "untagged").has(bridgeName)) return []
|
||||||
|
return activePorts(topology).flatMap(port => {
|
||||||
|
const name = text(port, "interface"), frameTypes = frames.parse(port["frame-types"]), result: Admission[] = []
|
||||||
|
if (membership(topology, vlanId, "tagged").has(name) && membership(topology, vlanId, "untagged").has(name)) return []
|
||||||
|
if (frameTypes !== "admit-only-untagged-and-priority-tagged" && membership(topology, vlanId, "tagged").has(name)) result.push({ interface: name, mode: "tagged" })
|
||||||
|
if (frameTypes !== "admit-only-vlan-tagged" && numberVid(port.pvid) === vlanId && (!filtering || membership(topology, vlanId, "untagged").has(name))) result.push({ interface: name, mode: "untagged" })
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
}
|
||||||
|
let current: Topology = structuredClone({ bridge, ports: oldPorts, vlans: oldVlans })
|
||||||
|
const baseline = admissions(current)
|
||||||
|
if (affected && !baseline.length) reject("Не подтверждён исходный CPU management-доступ", "MANAGEMENT_UNSAFE")
|
||||||
|
const preservesManagement = (topology: Topology) => !affected || baseline.every(old => admissions(topology).some(next => old.interface === next.interface && old.mode === next.mode))
|
||||||
|
if (!preservesManagement(target)) reject("План изменяет подтверждённые пути CPU management", "MANAGEMENT_UNSAFE")
|
||||||
|
const inverseDesired = bridgeVlanCurrentDesired(state, bridgeId)
|
||||||
|
for (const port of inverseDesired.ports) {
|
||||||
|
if (desired.removePortIds.includes(port.id!)) delete port.id
|
||||||
|
else if (port.comment === undefined && desired.ports.some(next => next.id === port.id && next.comment !== undefined)) port.comment = ""
|
||||||
|
}
|
||||||
|
for (const vlan of inverseDesired.vlans) {
|
||||||
|
if (desired.removeVlanIds.includes(vlan.id!)) delete vlan.id
|
||||||
|
else if (vlan.comment === undefined && desired.vlans.some(next => next.id === vlan.id && next.comment !== undefined)) vlan.comment = ""
|
||||||
|
}
|
||||||
|
const steps: RosStep[] = [], created: BridgeVlanConfigurationPlan["inverse"]["created"] = []
|
||||||
|
const append = (step: RosStep) => { if (step.action === "create") created.push({ stepIndex: steps.length, resource: step.resource as "bridge_port" | "bridge_vlan" }); steps.push(step) }
|
||||||
|
const mutation = (resource: "bridge_port" | "bridge_vlan", old: ConfigurationRow[], next: ConfigurationRow[], deleted: string[]) => {
|
||||||
|
const result: Array<{ step: RosStep; modelId: string }> = []
|
||||||
|
for (const row of next) {
|
||||||
|
const nativeId = String(row[".id"]), previous = old.find(value => value[".id"] === nativeId)
|
||||||
|
const fields = Object.fromEntries(Object.entries(row).filter(([key, value]) => key !== ".id" && (!previous || String(previous[key]) !== value)).map(([key, value]) => [key, String(value)]))
|
||||||
|
if (Object.keys(fields).length) result.push({ step: { serverId, resource, action: previous ? "update" : "create", ...(previous ? { id: nativeId } : {}), fields }, modelId: nativeId })
|
||||||
|
}
|
||||||
|
for (const nativeId of deleted) result.push({ step: { serverId, resource, action: "delete", id: nativeId, fields: {} }, modelId: nativeId })
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
const pending = [...mutation("bridge_vlan", staticVlans, vlanRows, desired.removeVlanIds), ...mutation("bridge_port", staticPorts, portRows, desired.removePortIds)]
|
||||||
|
// Independent management permits a disabled intermediate topology. A channel
|
||||||
|
// on this bridge instead requires every acknowledged prefix to preserve it.
|
||||||
|
if (flag(bridge["vlan-filtering"]) && (!desired.filtering || pending.length > 0) && !affected) {
|
||||||
|
append({ serverId, resource: "bridge", action: "update", id: bridgeId, fields: { "vlan-filtering": "false" } }); current.bridge["vlan-filtering"] = "false"
|
||||||
|
}
|
||||||
|
while (pending.length) {
|
||||||
|
let selected = -1, next: Topology | undefined
|
||||||
|
for (let index = 0; index < pending.length; index++) {
|
||||||
|
const item = pending[index], candidate = structuredClone(current), field = item.step.resource === "bridge_port" ? "ports" : "vlans"
|
||||||
|
if (item.step.action === "delete") candidate[field] = candidate[field].filter(row => row[".id"] !== item.modelId)
|
||||||
|
else if (item.step.action === "create") candidate[field].push({ ".id": item.modelId, ...item.step.fields })
|
||||||
|
else candidate[field] = candidate[field].map(row => row[".id"] === item.modelId ? { ...row, ...item.step.fields } : row)
|
||||||
|
// RouterOS permits only one bridge-port row per interface, including
|
||||||
|
// disabled rows. Explicit replacement must DELETE before CREATE, and
|
||||||
|
// swaps requiring an invented temporary interface are not admitted.
|
||||||
|
const claimed = candidate.ports.map(row => text(row, "interface"))
|
||||||
|
if (new Set(claimed).size !== claimed.length) continue
|
||||||
|
if (preservesManagement(candidate)) { selected = index; next = candidate; break }
|
||||||
|
}
|
||||||
|
if (selected < 0 || !next) reject("Не существует порядка без конфликта интерфейсов или потери management", "STATE_CONFLICT")
|
||||||
|
append(pending.splice(selected, 1)[0].step); current = next
|
||||||
|
}
|
||||||
|
const finalFields: Record<string, string> = {}
|
||||||
|
if (numberVid(current.bridge.pvid) !== desired.pvid) finalFields.pvid = String(desired.pvid)
|
||||||
|
if (desired.frameTypes !== undefined && frames.parse(current.bridge["frame-types"]) !== desired.frameTypes) finalFields["frame-types"] = desired.frameTypes
|
||||||
|
if (desired.ingressFiltering !== undefined && flag(current.bridge["ingress-filtering"]) !== desired.ingressFiltering) finalFields["ingress-filtering"] = String(desired.ingressFiltering)
|
||||||
|
if (flag(current.bridge["vlan-filtering"]) !== desired.filtering) finalFields["vlan-filtering"] = String(desired.filtering)
|
||||||
|
if (Object.keys(finalFields).length) {
|
||||||
|
if (!preservesManagement({ ...current, bridge: { ...current.bridge, ...finalFields } })) reject("Финальное переключение теряет management", "MANAGEMENT_UNSAFE")
|
||||||
|
append({ serverId, resource: "bridge", action: "update", id: bridgeId, fields: finalFields })
|
||||||
|
}
|
||||||
|
const objectMap = new Map<string, { kind: string; id: string; serverId: number }>()
|
||||||
|
for (const [kind, rows] of [["bridge", [bridge]], ["bridge_port", oldPorts], ["bridge_vlan", oldVlans]] as const) for (const row of rows) objectMap.set(`${kind}:${rowId(row)}`, { kind, id: rowId(row), serverId })
|
||||||
|
for (const ref of [...desired.ports.map(port => port.interface), ...desired.vlans.flatMap(vlan => [...vlan.tagged, ...vlan.untagged])]) objectMap.set(keyOf(ref), { kind: ref.resource, id: ref.id, serverId })
|
||||||
|
const oldMemberNames = new Set([...oldPorts.map(row => text(row, "interface")), ...oldVlans.flatMap(row => [...names(row, "tagged"), ...names(row, "untagged")])])
|
||||||
|
for (const name of oldMemberNames) {
|
||||||
|
const iface = state.interfaces.find(value => value.name === name) ?? reject("Не подтверждена прежняя зависимость bridge")
|
||||||
|
objectMap.set(keyOf(iface), { kind: iface.resource, id: iface.id, serverId })
|
||||||
|
}
|
||||||
|
const addressResource = state.ipv4Addresses.includes(address) ? "ipv4_address" : "ipv6_address"
|
||||||
|
objectMap.set(`${addressResource}:${rowId(address)}`, { kind: addressResource, id: rowId(address), serverId })
|
||||||
|
const managementVisited = new Set<string>()
|
||||||
|
const includeManagement = (name: string) => {
|
||||||
|
if (managementVisited.has(name)) return
|
||||||
|
managementVisited.add(name)
|
||||||
|
const iface = state.interfaces.find(value => value.name === name) ?? reject("Не подтверждена идентичность management dependency")
|
||||||
|
objectMap.set(keyOf(iface), { kind: iface.resource, id: iface.id, serverId })
|
||||||
|
const vlan = state.vlanInterfaces.find(row => row.name === name)
|
||||||
|
if (vlan) includeManagement(text(vlan, "interface"))
|
||||||
|
if (iface.resource === "bridge") for (const port of state.ports.filter(row => row.bridge === name && enabled(row))) {
|
||||||
|
objectMap.set(`bridge_port:${rowId(port)}`, { kind: "bridge_port", id: rowId(port), serverId })
|
||||||
|
includeManagement(text(port, "interface"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
includeManagement(addressInterface)
|
||||||
|
return { serverId, bridgeId, steps, objects: [...objectMap.values()], managementPath: { channelId: management.channelId, addressId: rowId(address), interfaceName: addressInterface, ...(affected ? { bridgeName, ...(managementVid === undefined ? {} : { vlanId: managementVid }) } : {}) }, inverse: { desired: inverseDesired, created } }
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameAddress(left: string, right: string): boolean {
|
||||||
|
if (isIP(left) === 4) return left === right
|
||||||
|
// WHATWG URL canonicalizes equivalent compressed IPv6 spellings.
|
||||||
|
try { return new URL(`http://[${left}]/`).hostname === new URL(`http://[${right}]/`).hostname } catch { return false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ACKs must come from successful executor read-back, never from user JSON.
|
||||||
|
* Re-prepare this desired state against fresh snapshots and obtain approval;
|
||||||
|
* this function grants no automatic compensation or replay authority. */
|
||||||
|
export function bridgeVlanInverseDesired(plan: BridgeVlanConfigurationPlan, createAcks: Array<{ stepIndex: number; id: string }>): BridgeVlanDesired {
|
||||||
|
unique(createAcks.map(ack => String(ack.stepIndex)), "create ACK")
|
||||||
|
if (createAcks.length !== plan.inverse.created.length) reject("Нет точных ACK всех созданных объектов")
|
||||||
|
const desired = structuredClone(plan.inverse.desired)
|
||||||
|
for (const created of plan.inverse.created) {
|
||||||
|
const ack = createAcks.find(value => value.stepIndex === created.stepIndex)
|
||||||
|
if (!ack) reject("Нет create ACK")
|
||||||
|
const nativeId = id.parse(ack.id)
|
||||||
|
if (created.resource === "bridge_port") desired.removePortIds.push(nativeId)
|
||||||
|
else desired.removeVlanIds.push(nativeId)
|
||||||
|
}
|
||||||
|
return desired
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/** RouterOS VLAN IDs and membership are sets, not row-order-sensitive strings.
|
||||||
|
* Never apply this normalization to interface names or arbitrary properties. */
|
||||||
|
export function canonicalBridgeVlanValue(field: string, value: unknown): unknown {
|
||||||
|
if (["tagged", "untagged"].includes(field)) {
|
||||||
|
if (value === undefined || value === "") return ""
|
||||||
|
if (typeof value !== "string") throw Error("Invalid VLAN members")
|
||||||
|
const names = value.split(",")
|
||||||
|
if (names.some(name => !name || name.trim() !== name) || new Set(names).size !== names.length) throw Error("Ambiguous VLAN members")
|
||||||
|
return names.sort().join(",")
|
||||||
|
}
|
||||||
|
if (field !== "vlan-ids") return value
|
||||||
|
if (typeof value !== "string" || !value.length) throw Error("Invalid VLAN IDs")
|
||||||
|
const ids = new Set<number>()
|
||||||
|
for (const part of value.split(",")) {
|
||||||
|
if (!/^[1-9]\d{0,3}(?:-[1-9]\d{0,3})?$/.test(part)) throw Error("Invalid VLAN IDs")
|
||||||
|
const [start, rawEnd] = part.split("-").map(Number), end = rawEnd ?? start
|
||||||
|
if (start < 1 || end > 4094 || end < start) throw Error("Invalid VLAN IDs")
|
||||||
|
for (let id = start; id <= end; id++) {
|
||||||
|
if (ids.has(id)) throw Error("Overlapping VLAN IDs")
|
||||||
|
ids.add(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sorted = [...ids].sort((a, b) => a - b), ranges: string[] = []
|
||||||
|
for (let index = 0; index < sorted.length;) {
|
||||||
|
const start = sorted[index]
|
||||||
|
let end = start
|
||||||
|
while (++index < sorted.length && sorted[index] === end + 1) end = sorted[index]
|
||||||
|
ranges.push(start === end ? String(start) : `${start}-${end}`)
|
||||||
|
}
|
||||||
|
return ranges.join(",")
|
||||||
|
}
|
||||||
@@ -236,7 +236,7 @@ export async function fetchIpsecState(server: ServerRow, reader?: MikrotikClient
|
|||||||
* 2) PSK-identity.
|
* 2) PSK-identity.
|
||||||
* Общая listener-identity (без remote-certificate) клиентом не считается — она обслуживает всех.
|
* Общая listener-identity (без remote-certificate) клиентом не считается — она обслуживает всех.
|
||||||
*/
|
*/
|
||||||
export function mapClients(state: IpsecServerState): IpsecClientDto[] {
|
export function mapClients(state: Pick<IpsecServerState, "server" | "peers" | "identities" | "certs" | "modeConfigs" | "active">): IpsecClientDto[] {
|
||||||
const server = state.server
|
const server = state.server
|
||||||
const ike2Peers = selectIke2Peers(state.peers)
|
const ike2Peers = selectIke2Peers(state.peers)
|
||||||
const ike2PeerNames = ike2Peers.map((p) => (p.name ?? "").trim()).filter(Boolean)
|
const ike2PeerNames = ike2Peers.map((p) => (p.name ?? "").trim()).filter(Boolean)
|
||||||
@@ -414,6 +414,13 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] {
|
|||||||
return out.sort((a, b) => a.name.localeCompare(b.name))
|
return out.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Binding selectors reuse the existing client eligibility/name/CN rules,
|
||||||
|
* including PSK and shared-listener exclusion. No online/static-IP observation
|
||||||
|
* is claimed: this projection returns keys only from the three native menus. */
|
||||||
|
export function ipsecClientBindingKeys(state: Pick<IpsecServerState, "server" | "peers" | "identities" | "certs">): string[] {
|
||||||
|
return mapClients({ ...state, modeConfigs: [], active: [] }).map(client => client.commonName ?? client.name)
|
||||||
|
}
|
||||||
|
|
||||||
/** Explicit namespaces prevent cross-menu RouterOS ID collisions. Legacy ambiguous IDs fail closed. */
|
/** Explicit namespaces prevent cross-menu RouterOS ID collisions. Legacy ambiguous IDs fail closed. */
|
||||||
export function resolveIpsecClientTarget(state: IpsecServerState, token: string) {
|
export function resolveIpsecClientTarget(state: IpsecServerState, token: string) {
|
||||||
const kind = token.startsWith("cert:") ? "cert" : token.startsWith("identity:") ? "identity" : undefined
|
const kind = token.startsWith("cert:") ? "cert" : token.startsWith("identity:") ? "identity" : undefined
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import assert from "node:assert/strict"
|
import assert from "node:assert/strict"
|
||||||
import { fetchIpsecState, getEnabledIpsecServerById, listIpsec, mapClients, mapServerSummary, resolveIpsecClientTarget } from "./ipsec-live.js"
|
import { fetchIpsecState, getEnabledIpsecServerById, listIpsec, mapClients, mapServerSummary, resolveIpsecClientTarget, ipsecClientBindingKeys } from "./ipsec-live.js"
|
||||||
import { removeManagedIpsecServer } from "./ipsec-cleanup.js"
|
import { removeManagedIpsecServer } from "./ipsec-cleanup.js"
|
||||||
import { deleteUserModeConfig, ensureIpsecPeer, identityRosBody, putOrPatchByName } from "./ipsec-ros.js"
|
import { deleteUserModeConfig, ensureIpsecPeer, identityRosBody, putOrPatchByName } from "./ipsec-ros.js"
|
||||||
import { ipsecManagedComment, isIpsecManagedComment } from "./ipsec-config.js"
|
import { ipsecManagedComment, isIpsecManagedComment } from "./ipsec-config.js"
|
||||||
@@ -244,4 +244,7 @@ assert.equal(mapClients(sanState)[0]?.online,false); checks++
|
|||||||
sanState.certs.pop()
|
sanState.certs.pop()
|
||||||
sanState.identities=[]
|
sanState.identities=[]
|
||||||
assert.equal(mapClients(sanState)[0]?.online,false); checks++
|
assert.equal(mapClients(sanState)[0]?.online,false); checks++
|
||||||
|
assert.deepEqual(ipsecClientBindingKeys(state), mapClients(state).map(client => client.commonName ?? client.name)); checks++
|
||||||
|
assert(ipsecClientBindingKeys(state).includes("PSK"), "Managed PSK selector survives the keys-only projection"); checks++
|
||||||
|
assert.deepEqual(ipsecClientBindingKeys(sessionState), mapClients(sessionState).map(client => client.commonName ?? client.name)); checks++
|
||||||
console.log(`ipsec-state: ${checks} checks passed`)
|
console.log(`ipsec-state: ${checks} checks passed`)
|
||||||
@@ -58,6 +58,13 @@ export interface MikrotikConnectParams {
|
|||||||
|
|
||||||
// ── low-level HTTP helpers ────────────────────────────────────────────────────
|
// ── low-level HTTP helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const preHeaderGetResets = new WeakSet<Error>()
|
||||||
|
|
||||||
|
/** Transport provenance for scoped GET callers. The original error stays intact. */
|
||||||
|
export function isRouterOsPreHeaderGetReset(error: unknown): boolean {
|
||||||
|
return error instanceof Error && preHeaderGetResets.has(error)
|
||||||
|
}
|
||||||
|
|
||||||
function rosRequest(
|
function rosRequest(
|
||||||
params: MikrotikConnectParams,
|
params: MikrotikConnectParams,
|
||||||
path: string,
|
path: string,
|
||||||
@@ -86,12 +93,15 @@ function rosRequest(
|
|||||||
options.agent = params.useSsl ? rosHttpsAgent : rosHttpAgent
|
options.agent = params.useSsl ? rosHttpsAgent : rosHttpAgent
|
||||||
|
|
||||||
let req: ReturnType<typeof lib.request> | undefined
|
let req: ReturnType<typeof lib.request> | undefined
|
||||||
|
let headersReceived = false, timedOut = false
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
|
timedOut = true
|
||||||
req?.destroy(new Error(`Connection to ${params.host}:${params.port} timed out after ${timeoutMs / 1000}s`))
|
req?.destroy(new Error(`Connection to ${params.host}:${params.port} timed out after ${timeoutMs / 1000}s`))
|
||||||
}, timeoutMs)
|
}, timeoutMs)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
req = lib.request(options, (res) => {
|
req = lib.request(options, (res) => {
|
||||||
|
headersReceived = true
|
||||||
watchRosResponseFailure(res, error => { clearTimeout(timer); reject(error) })
|
watchRosResponseFailure(res, error => { clearTimeout(timer); reject(error) })
|
||||||
let body = "", bytes = 0, failed = false
|
let body = "", bytes = 0, failed = false
|
||||||
const failSize = () => {
|
const failSize = () => {
|
||||||
@@ -128,8 +138,10 @@ function rosRequest(
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
req.once("information", () => { headersReceived = true })
|
||||||
req.on("error", (err) => {
|
req.on("error", (err) => {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
|
if (!headersReceived && !timedOut && !signal?.aborted && (err as NodeJS.ErrnoException).code === "ECONNRESET") preHeaderGetResets.add(err)
|
||||||
reject(err)
|
reject(err)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ assert.equal(parseRecursiveCatalog([{...row,disabled:"true"}])[0].disabled,true)
|
|||||||
const gateway=(id:string,active?:string)=>({".id":id,gateway:"192.0.2.1%ether1",active})
|
const gateway=(id:string,active?:string)=>({".id":id,gateway:"192.0.2.1%ether1",active})
|
||||||
assert.equal(parseRecursiveGatewayCatalog([gateway("a")])[0].status,"unknown")
|
assert.equal(parseRecursiveGatewayCatalog([gateway("a")])[0].status,"unknown")
|
||||||
assert.equal(parseRecursiveGatewayCatalog([gateway("a","false")])[0].status,"inactive")
|
assert.equal(parseRecursiveGatewayCatalog([gateway("a","false")])[0].status,"inactive")
|
||||||
|
assert.equal(parseRecursiveGatewayCatalog([{...gateway("a"),inactive:"true"}])[0].status,"inactive")
|
||||||
|
assert.equal(parseRecursiveGatewayCatalog([{...gateway("a"),inactive:true}])[0].status,"inactive")
|
||||||
|
assert.equal(parseRecursiveGatewayCatalog([{...gateway("a"),inactive:"false"}])[0].status,"unknown")
|
||||||
|
assert.equal(parseRecursiveGatewayCatalog([{...gateway("a","true"),inactive:"true"}])[0].status,"unknown")
|
||||||
assert.equal(parseRecursiveGatewayCatalog([gateway("a","false"),gateway("b")])[0].status,"unknown")
|
assert.equal(parseRecursiveGatewayCatalog([gateway("a","false"),gateway("b")])[0].status,"unknown")
|
||||||
assert.equal(parseRecursiveGatewayCatalog([gateway("a"),gateway("b","true")])[0].status,"active")
|
assert.equal(parseRecursiveGatewayCatalog([gateway("a"),gateway("b","true")])[0].status,"active")
|
||||||
assert.deepEqual(parseRecursiveGatewayCatalog([{".id":"a",gateway:"999.0.0.1"}]),[])
|
assert.deepEqual(parseRecursiveGatewayCatalog([{".id":"a",gateway:"999.0.0.1"}]),[])
|
||||||
|
|||||||
@@ -75,8 +75,12 @@ export function parseRecursiveGatewayCatalog(payload: unknown): RecursiveGateway
|
|||||||
if (typeof row.gateway !== "string") continue
|
if (typeof row.gateway !== "string") continue
|
||||||
const [ip, name = ip] = row.gateway.trim().split("%")
|
const [ip, name = ip] = row.gateway.trim().split("%")
|
||||||
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(ip) || ip.split(".").some(part => Number(part) > 255) || !name) continue
|
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(ip) || ip.split(".").some(part => Number(part) > 255) || !name) continue
|
||||||
const id = ip + "%" + name, active = observedBoolean(rosText(row.active))
|
const id = ip + "%" + name
|
||||||
const status = active === true ? "active" : active === false ? "inactive" : "unknown"
|
const active = typeof row.active === "boolean" ? row.active : observedBoolean(rosText(row.active))
|
||||||
|
const inactive = typeof row.inactive === "boolean" ? row.inactive : observedBoolean(rosText(row.inactive))
|
||||||
|
let status: RecursiveGatewayObservation["status"] = "unknown"
|
||||||
|
if (active === true && inactive !== true) status = "active"
|
||||||
|
if (active !== true && (active === false || inactive === true)) status = "inactive"
|
||||||
const previous = result.get(id)
|
const previous = result.get(id)
|
||||||
result.set(id, { id, ip, name, status: previous?.status === "active" || status === "active" ? "active" : previous?.status === "unknown" || status === "unknown" ? "unknown" : "inactive" })
|
result.set(id, { id, ip, name, status: previous?.status === "active" || status === "active" ? "active" : previous?.status === "unknown" || status === "unknown" ? "unknown" : "inactive" })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,11 +58,11 @@ export async function readLogContext(id: string) {
|
|||||||
const after = (await pool.query<LogEntry>(`SELECT ${logColumns} FROM router_log_entries WHERE server_id=$1 AND (effective_at,id)>($2::timestamptz,$3::bigint) ORDER BY effective_at,router_log_entries.id LIMIT 10`, [entry.serverId, entry.effectiveAt, id])).rows
|
const after = (await pool.query<LogEntry>(`SELECT ${logColumns} FROM router_log_entries WHERE server_id=$1 AND (effective_at,id)>($2::timestamptz,$3::bigint) ORDER BY effective_at,router_log_entries.id LIMIT 10`, [entry.serverId, entry.effectiveAt, id])).rows
|
||||||
return { entry, before: before.reverse(), after }
|
return { entry, before: before.reverse(), after }
|
||||||
}
|
}
|
||||||
export async function readLogStream(filters: LogFilters, cursor: string) {
|
export async function readLogStream(filters: LogFilters, cursor: string, limit = 200) {
|
||||||
// A committed upper bound is safe per device: writers are serialized by its advisory lock.
|
// A committed upper bound is safe per device: writers are serialized by its advisory lock.
|
||||||
// Advance across non-matching events too, so a quiet filtered stream never rescans its backlog.
|
// Advance across non-matching events too, so a quiet filtered stream never rescans its backlog.
|
||||||
const { rows: [upper] } = await pool.query<{ watermark: string }>(`SELECT GREATEST(COALESCE(max(id),0),$2::bigint)::text AS watermark FROM router_log_entries WHERE server_id=$1`, [filters.serverId, cursor])
|
const { rows: [upper] } = await pool.query<{ watermark: string }>(`SELECT GREATEST(COALESCE(max(id),0),$2::bigint)::text AS watermark FROM router_log_entries WHERE server_id=$1`, [filters.serverId, cursor])
|
||||||
const where = logWhere(filters, ["id>$1::bigint", "id<=$2::bigint"], [cursor, upper.watermark])
|
const where = logWhere(filters, ["id>$1::bigint", "id<=$2::bigint"], [cursor, upper.watermark])
|
||||||
const entries = (await pool.query<LogEntry>(`SELECT ${logColumns} FROM router_log_entries WHERE ${where.sql} ORDER BY router_log_entries.id LIMIT 200`, where.values)).rows
|
const entries = (await pool.query<LogEntry>(`SELECT ${logColumns} FROM router_log_entries WHERE ${where.sql} ORDER BY router_log_entries.id LIMIT $${where.values.length + 1}`, [...where.values, limit])).rows
|
||||||
return { entries, cursor: entries.length === 200 ? entries[entries.length - 1].id : upper.watermark, state: (await getLogStates(filters.serverId))[0] ?? null, enabled: (await getLogSettings()).enabled }
|
return { entries, cursor: entries.length === limit ? entries[entries.length - 1].id : upper.watermark, state: (await getLogStates(filters.serverId))[0] ?? null, enabled: (await getLogSettings()).enabled }
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { createServer, type RequestListener } from "node:http"
|
||||||
|
import { test } from "node:test"
|
||||||
|
import { MikrotikClient, isRouterOsPreHeaderGetReset } from "./mikrotik.js"
|
||||||
|
import { readRouterOsSnapshotGet } from "./routeros-snapshot-read.js"
|
||||||
|
|
||||||
|
async function loopback(work: (client: MikrotikClient, received: string[]) => Promise<void>, respond: RequestListener) {
|
||||||
|
const received: string[] = []
|
||||||
|
const server = createServer((request, response) => {
|
||||||
|
assert.equal(request.method, "GET")
|
||||||
|
received.push(request.url!)
|
||||||
|
respond(request, response)
|
||||||
|
})
|
||||||
|
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve))
|
||||||
|
const address = server.address(); assert(address && typeof address !== "string")
|
||||||
|
const client = new MikrotikClient({ host: "127.0.0.1", port: address.port, useSsl: false, verifySsl: false, username: "fixture", password: "fixture" })
|
||||||
|
try { await work(client, received) }
|
||||||
|
finally { server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function brandedReset(): Promise<Error> {
|
||||||
|
let error: unknown
|
||||||
|
await loopback(async client => {
|
||||||
|
try { await client.get("/interface/ethernet") } catch (failure) { error = failure }
|
||||||
|
}, request => request.destroy())
|
||||||
|
assert(error instanceof Error && isRouterOsPreHeaderGetReset(error))
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
|
||||||
|
test("actual pre-header reset retries the same GET once with fresh source/authority admission", async () => {
|
||||||
|
let requests = 0, guards = 0
|
||||||
|
await loopback(async (client, received) => {
|
||||||
|
assert.deepEqual(await readRouterOsSnapshotGet({ client, path: "/interface/ethernet", assertCurrent: async () => { guards++ } }), [{ ".id": "*1", name: "ether1" }])
|
||||||
|
assert.deepEqual(received, ["/rest/interface/ethernet", "/rest/interface/ethernet"])
|
||||||
|
assert.equal(guards, 4)
|
||||||
|
}, (request, response) => {
|
||||||
|
if (++requests === 1) request.destroy()
|
||||||
|
else response.end(JSON.stringify([{ ".id": "*1", name: "ether1" }]))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("repeated actual resets stop at two GETs and preserve the final transport error", async () => {
|
||||||
|
await loopback(async (client, received) => {
|
||||||
|
const failures: unknown[] = []
|
||||||
|
const wrapped = { async get<T>(...args: Parameters<MikrotikClient["get"]>) {
|
||||||
|
try { return await client.get<T>(...args) } catch (error) { failures.push(error); throw error }
|
||||||
|
} }
|
||||||
|
await assert.rejects(readRouterOsSnapshotGet({ client: wrapped, path: "/interface/ethernet", assertCurrent: async () => {} }), error => error === failures[1])
|
||||||
|
assert.equal(failures.length, 2); assert.equal(received.length, 2)
|
||||||
|
assert(failures.every(isRouterOsPreHeaderGetReset))
|
||||||
|
}, request => request.destroy())
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const outcome of ["headers-reset", "information-reset", "body-reset", "429", "bad-json", "timeout", "abort"] as const) {
|
||||||
|
test(`${outcome} cannot spend a retry slot`, async () => {
|
||||||
|
const abort = new AbortController()
|
||||||
|
await loopback(async (client, received) => {
|
||||||
|
let failure: unknown
|
||||||
|
try { await readRouterOsSnapshotGet({ client, path: "/interface/ethernet", timeoutMs: 80, signal: abort.signal, assertCurrent: async () => {} }) }
|
||||||
|
catch (error) { failure = error }
|
||||||
|
assert(failure instanceof Error)
|
||||||
|
assert.equal(isRouterOsPreHeaderGetReset(failure), false)
|
||||||
|
assert.equal(received.length, 1)
|
||||||
|
}, (request, response) => {
|
||||||
|
if (outcome === "timeout") return
|
||||||
|
if (outcome === "abort") { abort.abort(new Error("caller cancelled")); return }
|
||||||
|
if (outcome === "information-reset") { response.writeContinue(); setTimeout(() => request.destroy(), 10); return }
|
||||||
|
if (outcome === "headers-reset" || outcome === "body-reset") {
|
||||||
|
response.writeHead(200, { "content-type": "application/json", "content-length": "1000" }); response.flushHeaders()
|
||||||
|
if (outcome === "body-reset") response.write("[")
|
||||||
|
setTimeout(() => response.destroy(), 10); return
|
||||||
|
}
|
||||||
|
if (outcome === "429") { response.writeHead(429); response.end("{}"); return }
|
||||||
|
response.end("!")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test("spoofed reset, pre-abort and initial authority denial dispatch at most one or zero GETs", async () => {
|
||||||
|
const spoof = Object.assign(new Error("ECONNRESET"), { code: "ECONNRESET" })
|
||||||
|
let calls = 0
|
||||||
|
const client = { async get<T>(): Promise<T> { calls++; throw spoof } }
|
||||||
|
await assert.rejects(readRouterOsSnapshotGet({ client, path: "/interface", assertCurrent: async () => {} }), error => error === spoof)
|
||||||
|
assert.equal(calls, 1)
|
||||||
|
const abort = new AbortController(); const cancelled = new Error("cancelled"); abort.abort(cancelled)
|
||||||
|
await assert.rejects(readRouterOsSnapshotGet({ client, path: "/interface", signal: abort.signal, assertCurrent: async () => {} }), error => error === cancelled)
|
||||||
|
const denied = new Error("authority denied")
|
||||||
|
await assert.rejects(readRouterOsSnapshotGet({ client, path: "/interface", assertCurrent: async () => { throw denied } }), error => error === denied)
|
||||||
|
assert.equal(calls, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("both attempts share one decreasing budget including admission work", async () => {
|
||||||
|
const reset = await brandedReset()
|
||||||
|
let clock = 0, guards = 0, dispatches = 0
|
||||||
|
const timeouts: number[] = []
|
||||||
|
const client = { async get<T>(_path: string, timeout?: number, _signal?: AbortSignal, _maxBytes?: number, beforeDispatch?: () => Promise<unknown>): Promise<T> {
|
||||||
|
timeouts.push(timeout!)
|
||||||
|
await beforeDispatch!()
|
||||||
|
dispatches++
|
||||||
|
if (dispatches === 1) { clock += 20; throw reset }
|
||||||
|
return [] as T
|
||||||
|
} }
|
||||||
|
await readRouterOsSnapshotGet({ client, path: "/interface", timeoutMs: 100, now: () => clock, assertCurrent: async () => { guards++; clock += 10 } })
|
||||||
|
assert.deepEqual(timeouts, [90, 50]); assert.equal(guards, 4); assert.equal(dispatches, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("authority/source revocation and expiry between attempts forbid the second GET", async () => {
|
||||||
|
const reset = await brandedReset()
|
||||||
|
for (const outcome of ["authority", "source", "expiry"] as const) {
|
||||||
|
let clock = 0, guards = 0, calls = 0
|
||||||
|
const denied = new Error(outcome + " changed")
|
||||||
|
const client = { async get<T>(_path: string, _timeout?: number, _signal?: AbortSignal, _maxBytes?: number, beforeDispatch?: () => Promise<unknown>): Promise<T> {
|
||||||
|
await beforeDispatch!(); calls++; throw reset
|
||||||
|
} }
|
||||||
|
await assert.rejects(readRouterOsSnapshotGet({ client, path: "/interface", timeoutMs: 100, now: () => clock, assertCurrent: async () => {
|
||||||
|
if (++guards === 3) { if (outcome === "expiry") clock = 100; else throw denied }
|
||||||
|
} }), error => error === (outcome === "expiry" ? reset : denied))
|
||||||
|
assert.equal(calls, 1); assert.equal(guards, 3)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("deadline expiry during the final dispatch admission starts no HTTP request", async () => {
|
||||||
|
let clock = 0, guards = 0
|
||||||
|
await loopback(async (client, received) => {
|
||||||
|
await assert.rejects(readRouterOsSnapshotGet({ client, path: "/interface", timeoutMs: 100, now: () => clock, assertCurrent: async () => {
|
||||||
|
if (++guards === 2) clock = 100
|
||||||
|
} }), { name: "TimeoutError" })
|
||||||
|
assert.equal(received.length, 0)
|
||||||
|
}, (_request, response) => response.end("[]"))
|
||||||
|
})
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { isRouterOsPreHeaderGetReset } from "./mikrotik.js"
|
||||||
|
|
||||||
|
interface SnapshotGetClient {
|
||||||
|
get<T>(path: string, timeoutMs?: number, signal?: AbortSignal, maxResponseBytes?: number, beforeDispatch?: () => Promise<unknown>): Promise<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single catalogue read, pinned to the original client/path. Only a transport-
|
||||||
|
* branded reset before any response headers can spend its one retry slot. */
|
||||||
|
export async function readRouterOsSnapshotGet<T>(input: {
|
||||||
|
client: SnapshotGetClient
|
||||||
|
path: string
|
||||||
|
assertCurrent(): Promise<unknown>
|
||||||
|
timeoutMs?: number
|
||||||
|
signal?: AbortSignal
|
||||||
|
now?: () => number
|
||||||
|
}): Promise<T> {
|
||||||
|
const { client, path, assertCurrent, signal } = input
|
||||||
|
const timeoutMs = input.timeoutMs ?? 10_000, now = input.now ?? (() => performance.now())
|
||||||
|
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) throw new RangeError("Invalid RouterOS snapshot read timeout")
|
||||||
|
const deadline = now() + timeoutMs
|
||||||
|
let originalReset: unknown
|
||||||
|
const remaining = () => {
|
||||||
|
signal?.throwIfAborted()
|
||||||
|
const budget = deadline - now()
|
||||||
|
if (budget <= 0) throw originalReset ?? new DOMException("RouterOS snapshot read deadline expired", "TimeoutError")
|
||||||
|
return Math.ceil(budget)
|
||||||
|
}
|
||||||
|
for (let attempt = 0; attempt < 2; attempt++) {
|
||||||
|
remaining()
|
||||||
|
await assertCurrent()
|
||||||
|
const budget = remaining()
|
||||||
|
const deadlineSignal = AbortSignal.timeout(budget)
|
||||||
|
const requestSignal = signal ? AbortSignal.any([signal, deadlineSignal]) : deadlineSignal
|
||||||
|
try {
|
||||||
|
const result = await client.get<T>(path, budget, requestSignal, undefined, async () => {
|
||||||
|
await assertCurrent()
|
||||||
|
remaining()
|
||||||
|
requestSignal.throwIfAborted()
|
||||||
|
})
|
||||||
|
remaining()
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt !== 0 || !isRouterOsPreHeaderGetReset(error) || signal?.aborted) throw error
|
||||||
|
originalReset = error
|
||||||
|
remaining()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw originalReset
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import assert from "node:assert/strict"
|
import assert from "node:assert/strict"
|
||||||
import { createHash, randomUUID } from "node:crypto"
|
import { createHash, randomUUID } from "node:crypto"
|
||||||
|
import { mkdtemp, readdir, rm, rmdir } from "node:fs/promises"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import { join } from "node:path"
|
||||||
import type { ServerProbeExecutionSource } from "../modules/mcp/diagnostic-cancellation-source.js"
|
import type { ServerProbeExecutionSource } from "../modules/mcp/diagnostic-cancellation-source.js"
|
||||||
import { fingerprint } from "../modules/mcp/security.js"
|
import { fingerprint } from "../modules/mcp/security.js"
|
||||||
import type { ServerProbeJournalContext } from "./server-probe-journal.js"
|
import type { ServerProbeJournalContext } from "./server-probe-journal.js"
|
||||||
@@ -207,4 +210,25 @@ for (const field of ["operation_id", "connection_id", "actor_id"]) {
|
|||||||
const f = await fixture(), verify = createLegacyServerProbeVerifierForTests(f.deps, { ...f.crypto, proof: async () => { throw Error(secret) } })
|
const f = await fixture(), verify = createLegacyServerProbeVerifierForTests(f.deps, { ...f.crypto, proof: async () => { throw Error(secret) } })
|
||||||
const result = await verify(f.bundle); assert.equal(result.status, "unresolved"); assert.equal(JSON.stringify(result).includes(secret), false); checks++
|
const result = await verify(f.bundle); assert.equal(result.status, "unresolved"); assert.equal(JSON.stringify(result).includes(secret), false); checks++
|
||||||
}
|
}
|
||||||
console.log(`server-probe-legacy-verifier: ${checks} inert historical authentication checks passed; migration readiness always false`)
|
{
|
||||||
|
const state = await mkdtemp(join(tmpdir(), "mm-legacy-verifier-test-"))
|
||||||
|
const previousState = process.env.MCP_STATE_DIR
|
||||||
|
process.env.MCP_STATE_DIR = state
|
||||||
|
try {
|
||||||
|
const receipt = await fixture()
|
||||||
|
const quarantine = await fixture("rest", false)
|
||||||
|
await quarantine.quarantine()
|
||||||
|
const verify = createLegacyServerProbeVerifier(receipt.deps)
|
||||||
|
for (const bundle of [receipt.bundle, quarantine.bundle]) {
|
||||||
|
assert.equal((await verify(bundle)).status, "unresolved")
|
||||||
|
assert.deepEqual(await readdir(state), [], "Historical reads must leave missing encryption state untouched")
|
||||||
|
checks++
|
||||||
|
}
|
||||||
|
} 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(`server-probe-legacy-verifier: ${checks} historical authentication checks passed; migration readiness always false`)
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { isDeepStrictEqual } from "node:util"
|
import { isDeepStrictEqual } from "node:util"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import type { ServerProbeExecutionSource } from "../modules/mcp/diagnostic-cancellation-source.js"
|
import type { ServerProbeExecutionSource } from "../modules/mcp/diagnostic-cancellation-source.js"
|
||||||
import { decryptSecret, fingerprint, privateSecretFingerprint } from "../modules/mcp/security.js"
|
import { decryptSecretExisting, fingerprint, privateFingerprinterExisting } from "../modules/mcp/security.js"
|
||||||
import { parseServerProbeJournalContext, validateServerProbeJournalReceipt, type ServerProbeJournalContext } from "./server-probe-journal.js"
|
import { parseServerProbeJournalContext, validateServerProbeJournalReceipt, type ServerProbeJournalContext } from "./server-probe-journal.js"
|
||||||
import type { ServerProbeReceipt } from "./server-probe-execution.js"
|
import type { ServerProbeReceipt } from "./server-probe-execution.js"
|
||||||
import type { ProbeStorageScope } from "./server-probe-journal-maintenance.js"
|
import type { ProbeStorageScope } from "./server-probe-journal-maintenance.js"
|
||||||
@@ -89,8 +89,9 @@ function stages(rows: Record<string, unknown>[], ctx: ServerProbeJournalContext,
|
|||||||
}
|
}
|
||||||
return received
|
return received
|
||||||
}
|
}
|
||||||
const productionCrypto: LegacyServerProbeVerifierCrypto = { open: decryptSecret,
|
// Inspection must never recreate missing application key state.
|
||||||
proof: (domain, value) => privateSecretFingerprint(fingerprint(value), "server-probe-journal-v1:" + domain) }
|
const productionCrypto: LegacyServerProbeVerifierCrypto = { open: decryptSecretExisting,
|
||||||
|
proof: async (domain, value) => (await privateFingerprinterExisting("server-probe-journal-v1:" + domain))(fingerprint(value)) }
|
||||||
|
|
||||||
function verifier(dependencies: LegacyServerProbeVerifierDependencies, crypto: LegacyServerProbeVerifierCrypto) {
|
function verifier(dependencies: LegacyServerProbeVerifierDependencies, crypto: LegacyServerProbeVerifierCrypto) {
|
||||||
return async (raw: LegacyServerProbeBundle): Promise<LegacyServerProbeVerification> => {
|
return async (raw: LegacyServerProbeBundle): Promise<LegacyServerProbeVerification> => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as crypto from "node:crypto"
|
|||||||
import { isDeepStrictEqual } from "node:util"
|
import { isDeepStrictEqual } from "node:util"
|
||||||
import { createFlowIngestQuality, type FlowIngestQuality } from "./traffic-flow-ingest-quality.js"
|
import { createFlowIngestQuality, type FlowIngestQuality } from "./traffic-flow-ingest-quality.js"
|
||||||
import ts from "typescript"
|
import ts from "typescript"
|
||||||
|
import { DatabaseBackgroundWork } from "./database-background-work.js"
|
||||||
import * as owned from "./traffic-flow-owned-batch.js"
|
import * as owned from "./traffic-flow-owned-batch.js"
|
||||||
import * as proof from "./traffic-flow-batch-proof.js"
|
import * as proof from "./traffic-flow-batch-proof.js"
|
||||||
import * as accumulator from "./traffic-flow-fact-accumulator.js"
|
import * as accumulator from "./traffic-flow-fact-accumulator.js"
|
||||||
@@ -264,3 +265,60 @@ await explicitPrune.flushPending({force:true,prune:true})
|
|||||||
assert.equal(commits,pruneAttempts+1)
|
assert.equal(commits,pruneAttempts+1)
|
||||||
assert.ok(dispatches>pruneDispatches,"explicit retention/prune still dispatches on a fresh engine")
|
assert.ok(dispatches>pruneDispatches,"explicit retention/prune still dispatches on a fresh engine")
|
||||||
console.log("maintenance no-op: pristine/reset skip only; error-only unknown batch remains owned without replay; explicit prune preserved")
|
console.log("maintenance no-op: pristine/reset skip only; error-only unknown batch remains owned without replay; explicit prune preserved")
|
||||||
|
|
||||||
|
|
||||||
|
// A paused cold cache must not enqueue one pair of SQL loaders per datagram.
|
||||||
|
const coldWork = new DatabaseBackgroundWork()
|
||||||
|
let coldClock = 0
|
||||||
|
let topologyLoads = 0
|
||||||
|
let catalogLoads = 0
|
||||||
|
let catalogReady = false
|
||||||
|
let topologyReady = false
|
||||||
|
let rejectTopology!: (error: Error) => void
|
||||||
|
let releaseCatalog!: () => void
|
||||||
|
const firstTopology = new Promise<void>((_resolve, reject) => { rejectTopology = reject })
|
||||||
|
const firstCatalog = new Promise<void>(resolve => { releaseCatalog = resolve })
|
||||||
|
const coldDependencies = {
|
||||||
|
...dependencies,
|
||||||
|
"./database-background-work.js": { databaseBackgroundWork: coldWork },
|
||||||
|
"./traffic-flow-topology.js": {
|
||||||
|
peekFlowTopology: () => topologyReady ? {} : null,
|
||||||
|
peekServerCatalog: () => catalogReady ? { byId: new Map() } : null,
|
||||||
|
loadFlowTopology: async () => {
|
||||||
|
topologyLoads++
|
||||||
|
if (topologyLoads === 1) await firstTopology
|
||||||
|
topologyReady = true
|
||||||
|
},
|
||||||
|
getServerCatalog: async () => {
|
||||||
|
catalogLoads++
|
||||||
|
await firstCatalog
|
||||||
|
catalogReady = true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const coldExports: Record<string, unknown> = {}
|
||||||
|
runInNewContext(output, {
|
||||||
|
exports: coldExports,
|
||||||
|
require: (name: keyof typeof coldDependencies) => coldDependencies[name],
|
||||||
|
performance: { now: () => coldClock },
|
||||||
|
Buffer, console, setTimeout, clearTimeout,
|
||||||
|
})
|
||||||
|
const coldEngine = coldExports as unknown as Engine
|
||||||
|
for (let i = 0; i < 128; i++) coldEngine.queueParsedFlows(1, [])
|
||||||
|
assert.deepEqual([topologyLoads, catalogLoads], [1, 1])
|
||||||
|
let drained = false
|
||||||
|
const drain = coldWork.drain().then(() => { drained = true })
|
||||||
|
await Promise.resolve()
|
||||||
|
assert.equal(drained, false)
|
||||||
|
rejectTopology(new Error("Owned topology load refused"))
|
||||||
|
releaseCatalog()
|
||||||
|
await drain
|
||||||
|
assert.equal(drained, true)
|
||||||
|
coldClock = 999
|
||||||
|
for (let i = 0; i < 128; i++) coldEngine.queueParsedFlows(1, [])
|
||||||
|
assert.deepEqual([topologyLoads, catalogLoads], [1, 1])
|
||||||
|
coldClock = 1000
|
||||||
|
coldEngine.queueParsedFlows(1, [])
|
||||||
|
await coldWork.drain()
|
||||||
|
assert.deepEqual([topologyLoads, catalogLoads], [2, 1])
|
||||||
|
assert.equal(topologyReady, true)
|
||||||
@@ -404,12 +404,28 @@ export function getEngineStats(): EngineStats {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function coldSourceRefresh(load: () => Promise<unknown>): () => void {
|
||||||
|
let loading = false
|
||||||
|
let retryAfter = 0
|
||||||
|
return () => {
|
||||||
|
if (loading || performance.now() < retryAfter) return
|
||||||
|
loading = true
|
||||||
|
void databaseBackgroundWork.track(load()).then(
|
||||||
|
() => { loading = false },
|
||||||
|
() => { retryAfter = performance.now() + 1000; loading = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshColdTopology = coldSourceRefresh(loadFlowTopology)
|
||||||
|
const refreshColdCatalog = coldSourceRefresh(getServerCatalog)
|
||||||
|
|
||||||
export function queueParsedFlows(serverId: number, flows: ParsedFlowInput[]): void {
|
export function queueParsedFlows(serverId: number, flows: ParsedFlowInput[]): void {
|
||||||
if (flows.length) bumpDataEpoch()
|
if (flows.length) bumpDataEpoch()
|
||||||
const at=Date.now(),bucketAt=minuteBucketIso(at),hourAt=hourBucketIso(at),observedAt=new Date(at).toISOString(),ripeMisses:string[]=[]
|
const at=Date.now(),bucketAt=minuteBucketIso(at),hourAt=hourBucketIso(at),observedAt=new Date(at).toISOString(),ripeMisses:string[]=[]
|
||||||
const topo = peekFlowTopology(), catalog = peekServerCatalog()
|
const topo = peekFlowTopology(), catalog = peekServerCatalog()
|
||||||
if (!topo) void databaseBackgroundWork.track(loadFlowTopology()).catch(() => {})
|
if (!topo) refreshColdTopology()
|
||||||
if (!catalog) void databaseBackgroundWork.track(getServerCatalog()).catch(() => {})
|
if (!catalog) refreshColdCatalog()
|
||||||
const serverType = catalog?.byId.get(serverId)?.type
|
const serverType = catalog?.byId.get(serverId)?.type
|
||||||
for (const raw of flows) {
|
for (const raw of flows) {
|
||||||
const flow = normalizeParsedFlow(raw)
|
const flow = normalizeParsedFlow(raw)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { flowReadDbAll } from "./traffic-flow-read-policy.js"
|
import { flowReadBucketSnapshot } from "./traffic-flow-read-policy.js"
|
||||||
import {flowIngestQualitySchema} from "@mmapp/contracts/traffic-flow"
|
import {flowIngestQualitySchema} from "@mmapp/contracts/traffic-flow"
|
||||||
import { flowLifecycleBoundary, type FlowLifecycleAuthorization } from "./traffic-flow-lifecycle-fence.js"
|
import { flowLifecycleBoundary, type FlowLifecycleAuthorization } from "./traffic-flow-lifecycle-fence.js"
|
||||||
import { flowCounter } from "./traffic-flow-observed-values.js"
|
import { flowCounter } from "./traffic-flow-observed-values.js"
|
||||||
@@ -483,7 +483,7 @@ export async function listStoredFlowRows(sinceIso: string, serverId?: number, op
|
|||||||
const parameters: Array<string | number> = serverId === undefined ? [sinceIso, keep] : [sinceIso, keep, serverId]
|
const parameters: Array<string | number> = serverId === undefined ? [sinceIso, keep] : [sinceIso, keep, serverId]
|
||||||
const untilParameter = options?.untilIso ? parameters.push(options.untilIso) : undefined
|
const untilParameter = options?.untilIso ? parameters.push(options.untilIso) : undefined
|
||||||
if (options?.maxRows !== undefined) parameters.push(options.maxRows + 1)
|
if (options?.maxRows !== undefined) parameters.push(options.maxRows + 1)
|
||||||
const stored = await flowReadDbAll<StoredBucketRow>(`
|
const stored = await flowReadBucketSnapshot<StoredBucketRow>(`
|
||||||
SELECT
|
SELECT
|
||||||
server_id, bucket_at, COALESCE(host(src), '') AS src, COALESCE(host(dst), '') AS dst, proto,
|
server_id, bucket_at, COALESCE(host(src), '') AS src, COALESCE(host(dst), '') AS dst, proto,
|
||||||
src_port, dst_port, bytes, packets, in_iface, out_iface,
|
src_port, dst_port, bytes, packets, in_iface, out_iface,
|
||||||
|
|||||||
@@ -64,6 +64,46 @@ try{
|
|||||||
}
|
}
|
||||||
await app.register(trafficFlowRoutes,{prefix:'/api'});await app.register(mcpTransport)
|
await app.register(trafficFlowRoutes,{prefix:'/api'});await app.register(mcpTransport)
|
||||||
const endpoint=await app.listen({host:'127.0.0.1',port:0})
|
const endpoint=await app.listen({host:'127.0.0.1',port:0})
|
||||||
|
const flowUrl=endpoint+'/api/traffic/flow?range=15m'
|
||||||
|
const before=await fetch(flowUrl)
|
||||||
|
assert.equal(before.status,200)
|
||||||
|
const originalTalkers=(await before.json() as {talkers:unknown}).talkers
|
||||||
|
const withBucketMeasurement=async(action:()=>Promise<void>,read:()=>Promise<void>)=>{
|
||||||
|
const connect=pool.connect
|
||||||
|
let observed=false
|
||||||
|
pool.connect=(function(this:typeof pool,...args:unknown[]){
|
||||||
|
if(args.length)return Reflect.apply(connect,this,args)
|
||||||
|
return (Reflect.apply(connect,this,[]) as ReturnType<typeof connect>).then(connection=>{
|
||||||
|
const query=connection.query,release=connection.release
|
||||||
|
connection.query=(async (...queryArgs:unknown[])=>{
|
||||||
|
const result=await Reflect.apply(query,connection,queryArgs)
|
||||||
|
const statement=queryArgs[0]
|
||||||
|
if(!observed&&typeof statement==='string'&&statement.startsWith('WITH flow_read_source AS MATERIALIZED')&&statement.includes('FROM flow_buckets fb')){
|
||||||
|
observed=true
|
||||||
|
await action()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}) as typeof query
|
||||||
|
connection.release=(...releaseArgs)=>{
|
||||||
|
connection.query=query;connection.release=release;release.apply(connection,releaseArgs)
|
||||||
|
}
|
||||||
|
return connection
|
||||||
|
})
|
||||||
|
}) as typeof connect
|
||||||
|
try{await read();assert(observed,'The request must reach the source measurement')}finally{pool.connect=connect}
|
||||||
|
}
|
||||||
|
await withBucketMeasurement(async()=>{
|
||||||
|
await pool.query("INSERT INTO flow_buckets(server_id,bucket_at,src,dst,proto,src_port,dst_port,bytes,packets) VALUES(1,$1,'198.18.1.2','8.8.8.8',6,2,443,200,1)",[bucket])
|
||||||
|
},async()=>{
|
||||||
|
const overlapped=await fetch(flowUrl)
|
||||||
|
assert.equal(overlapped.status,200)
|
||||||
|
assert.deepEqual((await overlapped.json() as {talkers:unknown}).talkers,originalTalkers)
|
||||||
|
})
|
||||||
|
const after=await fetch(flowUrl)
|
||||||
|
assert.equal(after.status,200)
|
||||||
|
assert.notDeepEqual((await after.json() as {talkers:unknown}).talkers,originalTalkers)
|
||||||
|
await pool.query("DELETE FROM flow_buckets WHERE server_id=1 AND bucket_at=$1 AND src='198.18.1.2'::inet",[bucket])
|
||||||
|
console.log('PASS REST NetFlow retains one bounded bucket snapshot while the collector commits concurrently')
|
||||||
client=new Client({name:'owned-netflow-read',version:'1'})
|
client=new Client({name:'owned-netflow-read',version:'1'})
|
||||||
await client.connect(new StreamableHTTPClientTransport(new URL(endpoint+'/mcp'),{requestInit:{headers:{authorization:'Bearer '+access.token}}}))
|
await client.connect(new StreamableHTTPClientTransport(new URL(endpoint+'/mcp'),{requestInit:{headers:{authorization:'Bearer '+access.token}}}))
|
||||||
const call=()=>client!.callTool({name:'mm_netflow_talkers_get',arguments:{serverId:1}})
|
const call=()=>client!.callTool({name:'mm_netflow_talkers_get',arguments:{serverId:1}})
|
||||||
@@ -81,22 +121,19 @@ try{
|
|||||||
assert.equal((globalResult.structuredContent as {result:{dataQuality:{bytes:string}}}).result.dataQuality.bytes,'987654')
|
assert.equal((globalResult.structuredContent as {result:{dataQuality:{bytes:string}}}).result.dataQuality.bytes,'987654')
|
||||||
}finally{await globalClient.close()}
|
}finally{await globalClient.close()}
|
||||||
console.log('PASS actual MCP scoped collector counters withheld; existing explicit global telemetry grant receives genuine synthetic receiver quality')
|
console.log('PASS actual MCP scoped collector counters withheld; existing explicit global telemetry grant receives genuine synthetic receiver quality')
|
||||||
// The installed SDK's legacy stateless path accepts multiple valid calls per HTTP response.
|
// The production admission guard rejects multi-call batches before SDK dispatch.
|
||||||
const batch=await fetch(endpoint+'/mcp',{method:'POST',headers:{authorization:'Bearer '+access.token,'content-type':'application/json',accept:'application/json, text/event-stream','mcp-protocol-version':'2025-06-18'},body:JSON.stringify([1,2].map(id=>({jsonrpc:'2.0',id,method:'tools/call',params:{name:'mm_netflow_talkers_get',arguments:{serverId:1}}})))})
|
const batch=await fetch(endpoint+'/mcp',{method:'POST',headers:{authorization:'Bearer '+access.token,'content-type':'application/json',accept:'application/json, text/event-stream','mcp-protocol-version':'2025-06-18'},body:JSON.stringify([1,2].map(id=>({jsonrpc:'2.0',id,method:'tools/call',params:{name:'mm_netflow_talkers_get',arguments:{serverId:1}}})))})
|
||||||
assert.equal(batch.status,200);const batchText=await batch.text()
|
assert.equal(batch.status,400)
|
||||||
const messages=batchText.split('\n').filter(line=>line.startsWith('data: ')).map(line=>JSON.parse(line.slice(6)))
|
const rejectedBatch=await batch.json() as {id:unknown;error:{code:number}}
|
||||||
assert.equal(messages.length,2,batchText);assert.ok(messages.every(message=>!message.error&&!message.result.isError),batchText)
|
assert.equal(rejectedBatch.id,null)
|
||||||
assert.ok(!batchText.includes('FORBIDDEN_'))
|
assert.equal(rejectedBatch.error.code,-32600)
|
||||||
result=await client.callTool({name:'mm_netflow_talkers_get',arguments:{serverId:2}});assert.equal(result.isError,true)
|
result=await client.callTool({name:'mm_netflow_talkers_get',arguments:{serverId:2}});assert.equal(result.isError,true)
|
||||||
// Revocation during a real bounded source statement must prevent publication.
|
// Revocation during a real bounded source statement must prevent publication.
|
||||||
const original=pool.query.bind(pool);let revoked=false
|
await withBucketMeasurement(async()=>{await setConnectionStatus(access.connection.id,'paused',actor)},async()=>{
|
||||||
pool.query=((...args:unknown[])=>{
|
result=await call()
|
||||||
const text=typeof args[0]==='string'?args[0]:(args[0] as {text?:string})?.text
|
assert.equal(result.isError,true)
|
||||||
const pending=(original as (...args:unknown[])=>Promise<unknown>)(...args)
|
assert.ok(!JSON.stringify(result).includes('198.18.1.1'))
|
||||||
if(!revoked&&text?.includes('flow_read_source')&&text.includes('FROM flow_buckets')){revoked=true;return pending.then(async rows=>{await setConnectionStatus(access.connection.id,'paused',actor);return rows})}
|
})
|
||||||
return pending
|
|
||||||
}) as typeof pool.query
|
|
||||||
try{result=await call();assert.equal(revoked,true);assert.equal(result.isError,true);assert.ok(!JSON.stringify(result).includes('198.18.1.1'))}finally{pool.query=original as typeof pool.query}
|
|
||||||
await setConnectionStatus(access.connection.id,'active',actor)
|
await setConnectionStatus(access.connection.id,'active',actor)
|
||||||
// Four actual REST SSE streams retain their shared global owners. MCP and REST
|
// Four actual REST SSE streams retain their shared global owners. MCP and REST
|
||||||
// share the same admission pool; a rejected fifth read allocates no flow source.
|
// share the same admission pool; a rejected fifth read allocates no flow source.
|
||||||
@@ -115,7 +152,7 @@ try{
|
|||||||
for(let i=0;i<100&&flowReadBudget.stats().owners;i++)await delay(20)
|
for(let i=0;i<100&&flowReadBudget.stats().owners;i++)await delay(20)
|
||||||
assert.equal(flowReadBudget.stats().owners,0)
|
assert.equal(flowReadBudget.stats().owners,0)
|
||||||
result=await call();assert.ok(!result.isError)
|
result=await call();assert.ok(!result.isError)
|
||||||
console.log('PASS actual production HTTP SDK: scoped source/no hidden rows, two-call legacy batch sharing one owner, source-time revocation, four REST SSE owners versus MCP/REST rejection, abort cleanup and successful readmission')
|
console.log('PASS actual production HTTP SDK: scoped source/no hidden rows, multi-call batch refusal, source-time revocation, four REST SSE owners versus MCP/REST rejection, abort cleanup and successful readmission')
|
||||||
}finally{await client?.close();console.log('Owned HTTP SDK client closed');app.server.closeAllConnections();await app.close();console.log('Owned HTTP Fastify server closed');await closePool();console.log('Owned HTTP application pool closed')}
|
}finally{await client?.close();console.log('Owned HTTP SDK client closed');app.server.closeAllConnections();await app.close();console.log('Owned HTTP Fastify server closed');await closePool();console.log('Owned HTTP application pool closed')}
|
||||||
// Imported application maintenance timers are outside this isolated read test.
|
// Imported application maintenance timers are outside this isolated read test.
|
||||||
// Required transport/server/pool cleanup above must complete before terminal exit.
|
// Required transport/server/pool cleanup above must complete before terminal exit.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getFlowReadResourceProfile } from "./traffic-flow-read-resource-profile.js"
|
import { getFlowReadResourceProfile } from "./traffic-flow-read-resource-profile.js"
|
||||||
import { bindSql } from "../db/sql-bind.js"
|
import { bindSql } from "../db/sql-bind.js"
|
||||||
import { pool } from "../db/index.js"
|
import { pool } from "../db/index.js"
|
||||||
import { createFlowReadBudget } from "./traffic-flow-read-budget.js"
|
import { createFlowReadBudget, trackFlowReadComputation } from "./traffic-flow-read-budget.js"
|
||||||
import { readBoundedFlowSql } from "./traffic-flow-bounded-sql.js"
|
import { readBoundedFlowSql } from "./traffic-flow-bounded-sql.js"
|
||||||
import type { QueryResultRow } from "pg"
|
import type { QueryResultRow } from "pg"
|
||||||
const flowReadResourceProfile=getFlowReadResourceProfile()
|
const flowReadResourceProfile=getFlowReadResourceProfile()
|
||||||
@@ -11,7 +11,37 @@ export const FLOW_READ_RESERVATION_BYTES=flowReadResourceProfile.reservationByte
|
|||||||
export const FLOW_SSE_MAX_FRAME_BYTES=flowReadResourceProfile.responseOutputBytes
|
export const FLOW_SSE_MAX_FRAME_BYTES=flowReadResourceProfile.responseOutputBytes
|
||||||
export const flowReadBudget=createFlowReadBudget({maxOwners:flowReadResourceProfile.maxOwners,maxReservedBytes:flowReadResourceProfile.maxOwners*FLOW_READ_RESERVATION_BYTES})
|
export const flowReadBudget=createFlowReadBudget({maxOwners:flowReadResourceProfile.maxOwners,maxReservedBytes:flowReadResourceProfile.maxOwners*FLOW_READ_RESERVATION_BYTES})
|
||||||
export const flowResponseReadLimits={maxOwners:1,maxReservedBytes:FLOW_READ_RESERVATION_BYTES}
|
export const flowResponseReadLimits={maxOwners:1,maxReservedBytes:FLOW_READ_RESERVATION_BYTES}
|
||||||
export async function flowReadDbAll<T extends QueryResultRow=QueryResultRow>(sql:string,params?:unknown[]|Record<string,unknown>):Promise<T[]> {
|
const sqlSourceLimits = { maxRows: 50000, maxEncodedBytes: 16 * 1024 * 1024 }
|
||||||
const bound=bindSql(sql,params)
|
|
||||||
return readBoundedFlowSql<T>(pool,bound.text,bound.values??[],{maxRows:50000,maxEncodedBytes:16*1024*1024})
|
export async function flowReadDbAll<T extends QueryResultRow = QueryResultRow>(
|
||||||
|
sql: string, params?: unknown[] | Record<string, unknown>,
|
||||||
|
): Promise<T[]> {
|
||||||
|
const bound = bindSql(sql, params)
|
||||||
|
return readBoundedFlowSql<T>(pool, bound.text, bound.values ?? [], sqlSourceLimits)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A collector commit must not change the bucket rows between budget measurement and decode. */
|
||||||
|
export async function flowReadBucketSnapshot<T extends QueryResultRow>(sql: string, params: unknown[]): Promise<T[]> {
|
||||||
|
const bound = bindSql(sql, params)
|
||||||
|
return trackFlowReadComputation(async () => {
|
||||||
|
const client = await pool.connect()
|
||||||
|
let discard = true
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY")
|
||||||
|
const rows = await readBoundedFlowSql<T>(client, bound.text, bound.values ?? [], sqlSourceLimits)
|
||||||
|
await client.query("COMMIT")
|
||||||
|
discard = false
|
||||||
|
return rows
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
await client.query("ROLLBACK")
|
||||||
|
discard = false
|
||||||
|
} catch {
|
||||||
|
// A connection with an uncertain transaction state must not be reused.
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
client.release(discard)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import {readFile} from 'node:fs/promises'
|
||||||
|
import ts from 'typescript'
|
||||||
|
|
||||||
|
// Execute the real emitted executor body with only its imported I/O dependencies
|
||||||
|
// substituted. No SQL, HTTP, config files or live authority can be contacted.
|
||||||
|
const compile=async name=>ts.transpileModule(await readFile(new URL(name,import.meta.url),'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ES2022}}).outputText
|
||||||
|
const uri=source=>'data:text/javascript;base64,'+Buffer.from(source).toString('base64')
|
||||||
|
// Preserve offline I/O substitution while linking pure relative leaf modules.
|
||||||
|
const pureModules=new Map()
|
||||||
|
async function pureModuleUri(name){
|
||||||
|
if(pureModules.has(name))return pureModules.get(name)
|
||||||
|
const loading=(async()=>{
|
||||||
|
let code=await compile(name)
|
||||||
|
const ast=ts.createSourceFile(name,code,ts.ScriptTarget.ES2022,true,ts.ScriptKind.JS)
|
||||||
|
const imports=ast.statements.filter(ts.isImportDeclaration).filter(node=>node.moduleSpecifier.text.startsWith('./'))
|
||||||
|
const edits=await Promise.all(imports.map(async node=>({start:node.moduleSpecifier.getStart(ast),end:node.moduleSpecifier.end,value:await pureModuleUri(node.moduleSpecifier.text.replace(/\.js$/,'.ts'))})))
|
||||||
|
for(const edit of edits.reverse())code=code.slice(0,edit.start)+JSON.stringify(edit.value)+code.slice(edit.end)
|
||||||
|
return uri(code)
|
||||||
|
})()
|
||||||
|
pureModules.set(name,loading);return loading
|
||||||
|
}
|
||||||
|
const diagnosticUri=await pureModuleUri('./vrf-execution-diagnostic.ts')
|
||||||
|
const diagnostic=await import(diagnosticUri)
|
||||||
|
const id='12345678-1234-4123-8123-123456789abc'
|
||||||
|
let cases=0
|
||||||
|
for(const [error,expected]of [[Error('SECRET'),'Error'],[new TypeError('SECRET'),'TypeError'],[new RangeError('SECRET'),'RangeError'],[new SyntaxError('SECRET'),'SyntaxError'],[new ReferenceError('SECRET'),'ReferenceError'],[new URIError('SECRET'),'URIError'],[new EvalError('SECRET'),'EvalError'],[{message:'SECRET'},'NonError'],[new Proxy(Error('SECRET'),{get(){throw Error('getter')}}),'NonError']]){
|
||||||
|
const value=diagnostic.vrfExecutionDiagnostic(id,'create','raw-before',error,false,{serverId:1,catalog:'vrf',boundary:'native-get'})
|
||||||
|
assert.equal(value.errorClass,expected);assert.equal(JSON.stringify(value).includes('SECRET'),false);cases++
|
||||||
|
}
|
||||||
|
const trap=Error('SECRET')
|
||||||
|
for(const key of ['message','stack','code','cause'])Object.defineProperty(trap,key,{get(){throw Error('SECRET getter')}})
|
||||||
|
assert.equal(diagnostic.vrfExecutionDiagnostic(id,'create','raw-before',trap,false).category,'other');cases++
|
||||||
|
for(const code of ['ECONNRESET','ETIMEDOUT','40P01','SECRET']){
|
||||||
|
const value=diagnostic.vrfExecutionDiagnostic(id,'delete','ack-journal',Object.assign(Error('SECRET'),{code}),true)
|
||||||
|
assert.equal(value.category,({ECONNRESET:'reset',ETIMEDOUT:'timeout','40P01':'pg-deadlock'})[code]??'other')
|
||||||
|
assert.equal(JSON.stringify(value).includes('SECRET'),false);cases++
|
||||||
|
}
|
||||||
|
assert.equal(diagnostic.vrfExecutionDiagnostic('SECRET','create','raw-before',Error(),false),undefined)
|
||||||
|
assert.equal(diagnostic.vrfExecutionDiagnostic(id,'SECRET','raw-before',Error(),false),undefined)
|
||||||
|
assert.equal(diagnostic.vrfExecutionDiagnostic(id,'create','SECRET',Error(),false),undefined)
|
||||||
|
assert.equal(diagnostic.vrfExecutionDiagnostic(id,'create','raw-before',Error(),false,{serverId:1,catalog:'SECRET',boundary:'native-get'}).read,undefined);cases++
|
||||||
|
diagnostic.notifyVrfDiagnostic(()=>{throw Error('observer')},{})
|
||||||
|
diagnostic.notifyVrfDiagnostic(async()=>{throw Error('observer')},{})
|
||||||
|
await new Promise(resolve=>setImmediate(resolve));cases++
|
||||||
|
|
||||||
|
const key='__vrfDiagnosticOfflineFixture'
|
||||||
|
class McpError extends Error{constructor(code,message){super(message);this.code=code}}
|
||||||
|
globalThis[key]={McpError}
|
||||||
|
const functions=['readVrfProductionSnapshot','claimStoredVrfProductionTicket','inspectVrfProductionAdmission','assertVrfProductionHistoryProvenance','assertVrfProductionTransitionCheckpoint','classifyVrfProductionTransition','openVrfProductionTransitionCheckpoint','sealVrfProductionTransitionCheckpoint','vrfProductionCheckpointCatalogs','assertVerifiedConfigurationAccess','withVrfClaimQuarantine','assertVrfActionContext','vrfNativeActionFor','vrfActionContractsCurrent','findMcpOperation','decryptSecretExisting','encryptSecret','fingerprint','withVrfProductionSealingKey','configurationRows','resourceById','routerClient','rosSnapshotSourceProof']
|
||||||
|
const stubUri=uri(`export const McpError=globalThis.${key}.McpError;export const pool={query:(...args)=>globalThis.${key}.query(...args)};`+functions.map(name=>`export const ${name}=(...args)=>globalThis.${key}.${name}(...args);`).join('\n'))
|
||||||
|
let emitted=await compile('./vrf-production-execution.ts')
|
||||||
|
const ast=ts.createSourceFile('executor.js',emitted,ts.ScriptTarget.ES2022,true,ts.ScriptKind.JS)
|
||||||
|
const edits=ast.statements.filter(ts.isImportDeclaration).map(node=>({start:node.moduleSpecifier.getStart(ast),end:node.moduleSpecifier.end,value:node.moduleSpecifier.text.endsWith('/vrf-execution-diagnostic.js')?diagnosticUri:stubUri}))
|
||||||
|
for(const edit of edits.reverse())emitted=emitted.slice(0,edit.start)+JSON.stringify(edit.value)+emitted.slice(edit.end)
|
||||||
|
const api=await import(uri(emitted))
|
||||||
|
function fixture(options={}){
|
||||||
|
const failure=Object.assign(Error('PRIVATE_PASSWORD native-body SQL /private/path'),{code:options.errno??'ETIMEDOUT'})
|
||||||
|
const events=[],calls=[];let reads=0,deps=0,access=0,inspect=0,routers=0,guard,clientCalls=0
|
||||||
|
const proof='a'.repeat(64),snapshot={projection:'raw-native-v1',serverId:1,sourceProof:proof,catalogs:{vrf:[{'.id':'*0',name:'main',interfaces:'all',disabled:'false'}],interface_inventory:[]}}
|
||||||
|
const fail=name=>{calls.push(name);if(options.fail===name)throw failure}
|
||||||
|
const f={McpError,
|
||||||
|
async query(sql){
|
||||||
|
if(sql.includes('SELECT cp.after_sealed')){fail('history-load');return{rows:[]}}
|
||||||
|
if(sql.includes('SELECT ticket_id')){fail('ticket-lookup');return{rows:[{ticket_id:'ticket'}]}}
|
||||||
|
if(sql.includes('SET before_sealed'))fail('checkpoint-before')
|
||||||
|
if(sql.includes("SET state='applied'"))fail('ack-journal')
|
||||||
|
if(sql.includes('SET after_sealed'))fail('checkpoint-after')
|
||||||
|
return{rows:[{id,operation_id:id}]}
|
||||||
|
},
|
||||||
|
async assertVerifiedConfigurationAccess(){fail('access-'+(++access))},
|
||||||
|
async routerClient(){fail('router-'+(++routers));return{server:{},client:{
|
||||||
|
async put(){clientCalls++;fail('client-before-guard');await guard();fail('client-after-guard');return options.badAck?{}:{'.id':'*1'}},
|
||||||
|
async patch(){clientCalls++;await guard();return{'.id':'*1'}},
|
||||||
|
async delete(_path,_timeout,_before,onBody){clientCalls++;await guard();onBody(options.badDeleteBody?'PRIVATE_INVALID_JSON':'{}')},
|
||||||
|
}}},
|
||||||
|
async rosSnapshotSourceProof(){return proof},
|
||||||
|
vrfNativeActionFor(){return'ros.vrf.create'},assertVrfActionContext(){},vrfActionContractsCurrent(){return true},findMcpOperation(){return{version:1}},
|
||||||
|
async withVrfProductionSealingKey(work){fail('sealing-key');return work(Buffer.alloc(32))},
|
||||||
|
async readVrfProductionSnapshot(input){reads++;input.observeRead?.({serverId:1,catalog:'gre',boundary:'native-get'});fail('read-'+reads);return structuredClone(snapshot)},
|
||||||
|
configurationRows(_spec,rows){return rows},resourceById(){return{}},fingerprint(){return'current-fingerprint'},
|
||||||
|
async withVrfClaimQuarantine(input){return input.claim()},async claimStoredVrfProductionTicket(){fail('ticket-claim');return{privateSealed:'PRIVATE_SEALED'}},
|
||||||
|
async encryptSecret(){return'PRIVATE_CIPHERTEXT'},async decryptSecretExisting(){throw Error('not used')},
|
||||||
|
inspectVrfProductionAdmission(){fail('inspect-'+(++inspect));return{admission:{}}},
|
||||||
|
assertVrfProductionHistoryProvenance(){},assertVrfProductionTransitionCheckpoint(){},openVrfProductionTransitionCheckpoint(){},vrfProductionCheckpointCatalogs(){return[]},
|
||||||
|
classifyVrfProductionTransition(){fail('classification');return{checkpoint:{}}},sealVrfProductionTransitionCheckpoint(){return'PRIVATE_HISTORY'},
|
||||||
|
}
|
||||||
|
globalThis[key]=f
|
||||||
|
const input={operationId:id,planId:'plan',connectionId:'connection',ownerId:'owner',operationName:options.action==='delete'?'ros.vrf.delete':'ros.vrf.create',operationVersion:1,stepIndex:0,
|
||||||
|
step:{serverId:1,resource:'vrf',action:options.action??'create',...(options.action==='delete'?{id:'*1'}:{}),fields:options.action==='delete'?{}:{name:'customers',interfaces:'none'},placeBeforeId:options.action==='delete'?undefined:'*0'},sourceProof:proof,
|
||||||
|
originalSnapshots:options.originalDrift?[{serverId:1,resource:'vrf',sourceProof:proof,fingerprint:'old-fingerprint'}]:[],
|
||||||
|
async authorize(){},async assertDependencies(){fail('dependencies-'+(++deps))},installBeforeDispatch(value){fail('install');guard=value},
|
||||||
|
onDiagnostic(value){events.push(value);if(options.observerThrows)throw Error('logging failed');if(options.observerRejects)return Promise.reject(Error('async logging failed'))},
|
||||||
|
}
|
||||||
|
return{failure,events,calls,input,clientCalls:()=>clientCalls,run:()=>api.executeVrfProductionStep(input)}
|
||||||
|
}
|
||||||
|
for(const [fail,stage,ack]of [
|
||||||
|
['access-1','initial-authorization',false],['sealing-key','sealing-key',false],['read-1','raw-before',false],
|
||||||
|
['ticket-lookup','ticket-lookup',false],['ticket-claim','ticket-claim',false],['checkpoint-before','checkpoint-before',false],['install','install-predispatch',false],
|
||||||
|
['access-2','explicit-predispatch-authorization',false],['dependencies-1','explicit-predispatch-dependencies',false],['read-2','explicit-predispatch-read',false],['inspect-1','explicit-predispatch-admission',false],['access-3','explicit-predispatch-final-authorization',false],
|
||||||
|
['router-4','dispatch-source',false],['client-before-guard','client-call',false],['access-4','installed-predispatch-authorization',false],['dependencies-2','installed-predispatch-dependencies',false],['read-3','installed-predispatch-read',false],['inspect-2','installed-predispatch-admission',false],['access-5','installed-predispatch-final-authorization',false],['client-after-guard','client-call',false],
|
||||||
|
['ack-journal','ack-journal',true],['access-6','post-ack-authorization',true],['read-4','raw-after',true],['classification','transition-classification',true],['dependencies-3','post-ack-dependencies',true],['access-7','post-ack-final-authorization',true],['checkpoint-after','checkpoint-after',true],
|
||||||
|
]){
|
||||||
|
const f=fixture({fail});let thrown;try{await f.run()}catch(error){thrown=error}
|
||||||
|
assert.ok(thrown,fail);assert.equal(f.events.length,1,fail);assert.equal(f.events[0].stage,stage,fail);assert.equal(f.events[0].executorAckObserved,ack,fail)
|
||||||
|
assert.equal(f.events[0].category,'timeout',fail)
|
||||||
|
if(fail.startsWith('read-'))assert.deepEqual(f.events[0].read,{serverId:1,catalog:'gre',boundary:'native-get'},fail)
|
||||||
|
else assert.equal(f.events[0].read,undefined,fail)
|
||||||
|
if(!ack)assert.equal(thrown,f.failure,fail)
|
||||||
|
else assert.ok(thrown instanceof api.VrfProductionAcknowledgedFailure,fail)
|
||||||
|
if(fail==='ack-journal')assert.equal(thrown.code,'VRF_ACK_JOURNAL_UNCONFIRMED')
|
||||||
|
if(fail==='checkpoint-after')assert.equal(thrown.code,'VRF_VERIFIED_CHECKPOINT_UNCONFIRMED')
|
||||||
|
assert.equal(JSON.stringify(f.events).includes('PRIVATE'),false);cases++
|
||||||
|
}
|
||||||
|
for(const observer of ['observerThrows','observerRejects']){
|
||||||
|
const f=fixture({fail:'read-1',[observer]:true});await assert.rejects(f.run(),error=>error===f.failure)
|
||||||
|
await new Promise(resolve=>setImmediate(resolve));assert.equal(f.events.length,1);cases++
|
||||||
|
}
|
||||||
|
const original=fixture({originalDrift:true});await assert.rejects(original.run(),error=>error.code==='STATE_CONFLICT');assert.equal(original.events[0].stage,'original-dependencies');cases++
|
||||||
|
const history=fixture({action:'delete',fail:'history-load'});await assert.rejects(history.run(),error=>error===history.failure);assert.equal(history.events[0].stage,'history-load');cases++
|
||||||
|
const malformed=fixture({badAck:true});await assert.rejects(malformed.run(),error=>error.code==='STATE_CONFLICT'&&!(error instanceof api.VrfProductionAcknowledgedFailure));assert.equal(malformed.events[0].stage,'ack-identity');assert.equal(malformed.events[0].executorAckObserved,true);cases++
|
||||||
|
const malformedDelete=fixture({action:'delete',badDeleteBody:true});await assert.rejects(malformedDelete.run(),error=>error instanceof api.VrfProductionAcknowledgedFailure&&error.code==='VERIFICATION_FAILED');assert.equal(malformedDelete.events[0].executorAckObserved,true);assert.equal(malformedDelete.events[0].stage,'ack-identity');cases++
|
||||||
|
const happy=fixture();const receipt=await happy.run();assert.equal(receipt.configurationVerified,true);assert.equal(happy.events.length,0);assert.equal(happy.clientCalls(),1);cases++
|
||||||
|
const silent=fixture({fail:'read-1'});delete silent.input.onDiagnostic;await assert.rejects(silent.run(),error=>error===silent.failure);cases++
|
||||||
|
// Exercise the exact emitted runtime policy call/callback/fallback/clear wiring.
|
||||||
|
// Only its surrounding policy and executor I/O are synthetic. Extracting the
|
||||||
|
// live AST statements means this test fails if the runtime stops wiring them.
|
||||||
|
const runtimeSource=await compile('../modules/mcp/runtime.ts')
|
||||||
|
const runtimeAst=ts.createSourceFile('runtime.js',runtimeSource,ts.ScriptTarget.ES2022,true,ts.ScriptKind.JS)
|
||||||
|
const setup=[],clear=[],policyCalls=[],fallback=[]
|
||||||
|
function visit(node){
|
||||||
|
if(ts.isExpressionStatement(node)){
|
||||||
|
const text=node.getText(runtimeAst)
|
||||||
|
if(text.startsWith('vrfDiagnosticFallback = productionVrf'))setup.push(text)
|
||||||
|
if(text==='vrfDiagnosticFallback = undefined;')clear.push(text)
|
||||||
|
if(text==='vrfDiagnosticFallback?.failure(error);')fallback.push(text)
|
||||||
|
}
|
||||||
|
if(ts.isVariableStatement(node)&&node.declarationList.declarations.length===1){
|
||||||
|
const value=node.declarationList.declarations[0]
|
||||||
|
if(value.name.getText(runtimeAst)==='result'&&value.initializer&&ts.isAwaitExpression(value.initializer)&&ts.isCallExpression(value.initializer.expression)&&value.initializer.expression.expression.getText(runtimeAst)==='withVerifiedConfigurationChange'&&node.getText(runtimeAst).includes('executeVrfProductionStep'))policyCalls.push(node.getText(runtimeAst))
|
||||||
|
}
|
||||||
|
ts.forEachChild(node,visit)
|
||||||
|
}
|
||||||
|
visit(runtimeAst)
|
||||||
|
for(const statements of [setup,clear,policyCalls,fallback])assert.equal(statements.length,1)
|
||||||
|
const runtimeBoundary=new Function(`return async function(env){
|
||||||
|
const {productionVrf,step,plan,operationId,connection,serverIds,evidence,serviceExecution,reservedStep,authorizeCatalog,authorize,prefix,McpError,executeVrfProductionStep,executeRosStep,withVerifiedConfigurationChange,createVrfRuntimeDiagnosticBoundary,console}=env;
|
||||||
|
const index=0;let vrfDiagnosticFallback,vrfBeforeDispatch;
|
||||||
|
try{${setup[0]}\n${policyCalls[0]}\n${clear[0]}\nif(env.failAfterWrapper)throw env.failure;return result}
|
||||||
|
catch(error){${fallback[0]}\nthrow error}
|
||||||
|
}`)()
|
||||||
|
for(const mode of ['runtime-policy-entry','policy-authorize','executor-entry','executor-clone','executor-binding','executor-reported','runtime-policy-exit','after-wrapper','non-production','success']){
|
||||||
|
for(const loggerThrows of [false,true]){
|
||||||
|
const error=Object.assign(Error('PRIVATE_RUNTIME_POLICY_FAILURE'),{code:'ECONNRESET'}),events=[]
|
||||||
|
const env={failure:error,failAfterWrapper:mode==='after-wrapper',productionVrf:mode!=='non-production',
|
||||||
|
operationId:id,connection:{owner_id:'owner',id:'connection'},serverIds:[1],evidence:{objects:[],snapshots:[{resource:'vrf',serverId:1,sourceProof:'a'.repeat(64)}]},serviceExecution:undefined,reservedStep:{},
|
||||||
|
plan:{id:'plan',operation_id:'ros.vrf.create',operation_version:1,approved_by:'owner'},step:{kind:'ros',step:{serverId:1,resource:'vrf',action:'create',fields:{name:'customers',interfaces:'none'}}},
|
||||||
|
prefix:{async check(){}},async authorize(){},async authorizeCatalog(){if(mode==='policy-authorize')throw error},McpError,createVrfRuntimeDiagnosticBoundary:diagnostic.createVrfRuntimeDiagnosticBoundary,
|
||||||
|
console:{error(raw){events.push(JSON.parse(raw));if(loggerThrows)throw Error('PRIVATE_LOGGER_FAILURE')}},
|
||||||
|
async executeRosStep(){throw error},
|
||||||
|
async executeVrfProductionStep(input){
|
||||||
|
if(mode==='executor-entry')throw error
|
||||||
|
if(mode==='executor-clone'){input.step.fields.uncloneable=()=>{};return api.executeVrfProductionStep(input)}
|
||||||
|
if(mode==='executor-binding'){fixture();globalThis[key].assertVrfActionContext=()=>{throw error};return api.executeVrfProductionStep(input)}
|
||||||
|
if(mode==='executor-reported'){input.onDiagnostic(diagnostic.vrfExecutionDiagnostic(id,'create','raw-before',error,false,{serverId:1,catalog:'vrf',boundary:'native-get'}));throw error}
|
||||||
|
return{kind:'vrf-production-execution-v1',configurationVerified:true,nativeId:'*1'}
|
||||||
|
},
|
||||||
|
async withVerifiedConfigurationChange(context,work){
|
||||||
|
if(mode==='runtime-policy-entry')throw error
|
||||||
|
if(mode==='policy-authorize')await context.authorize()
|
||||||
|
const receipt=await work();if(mode==='runtime-policy-exit')throw error;return receipt
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if(mode==='success'){assert.equal((await runtimeBoundary(env)).configurationVerified,true);assert.equal(events.length,0)}
|
||||||
|
else{
|
||||||
|
await assert.rejects(runtimeBoundary(env),value=>mode==='executor-clone'?value.name==='DataCloneError':value===error)
|
||||||
|
if(['after-wrapper','non-production'].includes(mode))assert.equal(events.length,0)
|
||||||
|
else{assert.equal(events.length,1);assert.equal(events[0].stage,mode==='executor-reported'?'raw-before':['executor-clone','executor-binding'].includes(mode)?'executor-entry':mode==='policy-authorize'?'runtime-policy-entry':mode);assert.equal(events[0].executorAckObserved,mode==='runtime-policy-exit');assert.equal(events[0].category,mode==='executor-clone'?'other':'reset')}
|
||||||
|
}
|
||||||
|
assert.equal(JSON.stringify(events).includes('PRIVATE'),false);cases++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete globalThis[key]
|
||||||
|
console.log('VRF private execution diagnostics, actual executor body with offline imported I/O, closed projection cases:',cases)
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { types } from "node:util"
|
||||||
|
import { readVrfTransitionRefusal, type VrfTransitionRefusalTag } from "./vrf-transition-refusal.js"
|
||||||
|
|
||||||
|
export const VRF_EXECUTION_STAGES = [
|
||||||
|
"runtime-policy-entry", "executor-entry", "runtime-policy-exit",
|
||||||
|
"initial-authorization", "sealing-key", "history-load", "raw-before", "original-dependencies", "history-provenance", "supported-shape", "ticket-lookup", "ticket-claim", "checkpoint-before", "install-predispatch",
|
||||||
|
"explicit-predispatch-authorization", "explicit-predispatch-dependencies", "explicit-predispatch-read", "explicit-predispatch-admission", "explicit-predispatch-final-authorization",
|
||||||
|
"dispatch-source", "client-call", "installed-predispatch-authorization", "installed-predispatch-dependencies", "installed-predispatch-read", "installed-predispatch-admission", "installed-predispatch-final-authorization",
|
||||||
|
"ack-identity", "ack-journal", "post-ack-authorization", "raw-after", "transition-classification", "post-ack-dependencies", "post-ack-final-authorization", "checkpoint-after",
|
||||||
|
] as const
|
||||||
|
export type VrfExecutionStage = typeof VRF_EXECUTION_STAGES[number]
|
||||||
|
export const VRF_READ_BOUNDARIES = ["authority-before", "native-get", "authority-after", "shape", "snapshot-validation", "final-authority"] as const
|
||||||
|
export interface VrfRawReadObservation { serverId: number; catalog?: string; boundary: typeof VRF_READ_BOUNDARIES[number] }
|
||||||
|
const catalogs = new Set(["bridge", "bridge_port", "bridge_vlan", "vrf", "ipv4_address", "ipv6_address", "ipv4_route", "ipv6_route", "routing_table", "ethernet", "ip_filter", "ipv6_filter", "routing_rule", "ip_settings", "ipv6_settings", "interface_inventory", "interface_list_members", "vlan", "bonding", "wireguard", "wireguard_peer", "gre", "ipip", "eoip", "vxlan", "vxlan_vtep", "pppoe_client"])
|
||||||
|
const classes = [[Error.prototype, "Error"], [TypeError.prototype, "TypeError"], [RangeError.prototype, "RangeError"], [SyntaxError.prototype, "SyntaxError"], [ReferenceError.prototype, "ReferenceError"], [URIError.prototype, "URIError"], [EvalError.prototype, "EvalError"]] as const
|
||||||
|
const categories = Object.freeze({ ECONNRESET: "reset", ECONNREFUSED: "refused", ETIMEDOUT: "timeout", ENOTFOUND: "dns", EAI_AGAIN: "dns", EPIPE: "pipe", ABORT_ERR: "aborted", ERR_STREAM_DESTROYED: "stream", "40001": "pg-serialization", "40P01": "pg-deadlock", "57014": "pg-cancelled", "08003": "pg-connection", "08006": "pg-connection", "57P01": "pg-connection" } as const)
|
||||||
|
export interface VrfExecutionDiagnostic {
|
||||||
|
event: "mcp.vrf.execution-failed"
|
||||||
|
operationId: string
|
||||||
|
action: "create" | "patch" | "delete"
|
||||||
|
stage: VrfExecutionStage
|
||||||
|
errorClass: "Error" | "TypeError" | "RangeError" | "SyntaxError" | "ReferenceError" | "URIError" | "EvalError" | "OtherNativeError" | "NonError"
|
||||||
|
category: "other" | typeof categories[keyof typeof categories]
|
||||||
|
/** Executor response observation only, never write-fence/dispatch proof. */
|
||||||
|
executorAckObserved: boolean
|
||||||
|
read?: VrfRawReadObservation
|
||||||
|
transition?: VrfTransitionRefusalTag
|
||||||
|
}
|
||||||
|
/** Closed private diagnostic only. Never read error messages, stacks, getters,
|
||||||
|
* causes, request bodies, SQL or native values. No outcome/authority is minted. */
|
||||||
|
export function vrfExecutionDiagnostic(operationId: string, action: string, stage: VrfExecutionStage, error: unknown, executorAckObserved: boolean, read?: VrfRawReadObservation): VrfExecutionDiagnostic | undefined {
|
||||||
|
if (typeof operationId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(operationId) || !["create", "patch", "delete"].includes(action) || !VRF_EXECUTION_STAGES.includes(stage) || typeof executorAckObserved !== "boolean") return undefined
|
||||||
|
const result: VrfExecutionDiagnostic = { event: "mcp.vrf.execution-failed", operationId, action: action as VrfExecutionDiagnostic["action"], stage, errorClass: "NonError", category: "other", executorAckObserved }
|
||||||
|
if (types.isNativeError(error)) {
|
||||||
|
result.errorClass = classes.find(([prototype]) => Object.getPrototypeOf(error) === prototype)?.[1] ?? "OtherNativeError"
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(error, "code")
|
||||||
|
if (descriptor && Object.hasOwn(descriptor, "value") && typeof descriptor.value === "string" && Object.hasOwn(categories, descriptor.value)) result.category = categories[descriptor.value as keyof typeof categories]
|
||||||
|
}
|
||||||
|
if (read && Number.isSafeInteger(read.serverId) && read.serverId > 0 && VRF_READ_BOUNDARIES.includes(read.boundary) && (read.catalog === undefined || typeof read.catalog === "string" && catalogs.has(read.catalog))) result.read = { serverId: read.serverId, boundary: read.boundary, ...(read.catalog === undefined ? {} : { catalog: read.catalog }) }
|
||||||
|
const transition = readVrfTransitionRefusal(error)
|
||||||
|
if (transition) result.transition = transition
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
/** Observers are in-process only and cannot fail, await, or replace the work. */
|
||||||
|
export function notifyVrfDiagnostic<T>(observer: ((value: T) => unknown) | undefined, value: T): void {
|
||||||
|
try {
|
||||||
|
if (!observer) return
|
||||||
|
const result = observer(value)
|
||||||
|
if (types.isPromise(result)) void Promise.prototype.then.call(result, undefined, () => {})
|
||||||
|
} catch { /* Private observation never changes the original control flow. */ }
|
||||||
|
}
|
||||||
|
/** Private per-step fallback for failures before the executor's own try or in
|
||||||
|
* its surrounding policy context. Executor diagnostics always take precedence.
|
||||||
|
* Runtime discards this object immediately when the policy wrapper succeeds. */
|
||||||
|
export function createVrfRuntimeDiagnosticBoundary(operationId: string, action: string, observer: (value: VrfExecutionDiagnostic) => unknown) {
|
||||||
|
let stage: VrfExecutionStage = "runtime-policy-entry", emitted = false, executorAckObserved = false
|
||||||
|
return {
|
||||||
|
executorEntered() { stage = "executor-entry" },
|
||||||
|
executorReturned() { stage = "runtime-policy-exit"; executorAckObserved = true },
|
||||||
|
observed(value: VrfExecutionDiagnostic) {
|
||||||
|
if (value.operationId !== operationId || value.action !== action || emitted) return
|
||||||
|
emitted = true
|
||||||
|
notifyVrfDiagnostic(observer, value)
|
||||||
|
},
|
||||||
|
failure(error: unknown) {
|
||||||
|
if (emitted) return
|
||||||
|
emitted = true
|
||||||
|
try {
|
||||||
|
const value = vrfExecutionDiagnostic(operationId, action, stage, error, executorAckObserved)
|
||||||
|
if (value) notifyVrfDiagnostic(observer, value)
|
||||||
|
} catch { /* Fallback logging cannot replace the outer runtime error. */ }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,11 +3,11 @@ import { assertVrfActionContext, dedupeVrfAliasSnapshots, vrfNativeActionFor, vr
|
|||||||
import type { VrfTicketContext } from "./vrf-production-ticket-store.js"
|
import type { VrfTicketContext } from "./vrf-production-ticket-store.js"
|
||||||
import type { VrfProductionRequest } from "./vrf-production-admission.js"
|
import type { VrfProductionRequest } from "./vrf-production-admission.js"
|
||||||
import type { RosSnapshot } from "../modules/mcp/resources.js"
|
import type { RosSnapshot } from "../modules/mcp/resources.js"
|
||||||
const context = { actionId: "ros.vrf.update", actionVersion: 3, authorityAction: { id: "routing.vrf.update", version: 3 } } as VrfTicketContext
|
const context = { actionId: "ros.vrf.update", actionVersion: 4, authorityAction: { id: "routing.vrf.update", version: 4 } } as VrfTicketContext
|
||||||
const request: VrfProductionRequest = { action: "patch", id: "*a", fields: { interfaces: "owned-a,owned-b" } }
|
const request: VrfProductionRequest = { action: "patch", id: "*a", fields: { interfaces: "owned-a,owned-b" } }
|
||||||
let cases = 0
|
let cases = 0
|
||||||
assertVrfActionContext(context, request); cases++
|
assertVrfActionContext(context, request); cases++
|
||||||
assert.deepEqual(vrfPublicAuthority(context), { id: "routing.vrf.update", version: 3 }); cases++
|
assert.deepEqual(vrfPublicAuthority(context), { id: "routing.vrf.update", version: 4 }); cases++
|
||||||
assert.equal(vrfNativeActionFor("routing.vrf.update", request), "ros.vrf.update"); cases++
|
assert.equal(vrfNativeActionFor("routing.vrf.update", request), "ros.vrf.update"); cases++
|
||||||
const invalidRequests: VrfProductionRequest[] = [
|
const invalidRequests: VrfProductionRequest[] = [
|
||||||
{ ...request, action: "create" as const }, { ...request, action: "delete" as const }, { ...request, fields: { name: "other" } },
|
{ ...request, action: "create" as const }, { ...request, action: "delete" as const }, { ...request, fields: { name: "other" } },
|
||||||
@@ -15,6 +15,7 @@ const invalidRequests: VrfProductionRequest[] = [
|
|||||||
]
|
]
|
||||||
for (const changed of invalidRequests) { assert.throws(() => assertVrfActionContext(context, changed)); cases++ }
|
for (const changed of invalidRequests) { assert.throws(() => assertVrfActionContext(context, changed)); cases++ }
|
||||||
assert.throws(() => assertVrfActionContext({ ...context, authorityAction: { id: "routing.vrf.update", version: 2 } } as unknown as VrfTicketContext, request)); cases++
|
assert.throws(() => assertVrfActionContext({ ...context, authorityAction: { id: "routing.vrf.update", version: 2 } } as unknown as VrfTicketContext, request)); cases++
|
||||||
|
assert.throws(() => assertVrfActionContext({ ...context, authorityAction: { id: "routing.vrf.update", version: 3 } } as unknown as VrfTicketContext, request)); cases++
|
||||||
assert.throws(() => assertVrfActionContext({ ...context, actionId: "ros.vrf.delete" }, request)); cases++
|
assert.throws(() => assertVrfActionContext({ ...context, actionId: "ros.vrf.delete" }, request)); cases++
|
||||||
const snapshot: RosSnapshot = { serverId: 1, resource: "vrf", rows: [{ ".id": "*a", interfaces: "owned-a" }], fingerprint: "a".repeat(64), sourceProof: "b".repeat(64), projectionVersion: 3 }
|
const snapshot: RosSnapshot = { serverId: 1, resource: "vrf", rows: [{ ".id": "*a", interfaces: "owned-a" }], fingerprint: "a".repeat(64), sourceProof: "b".repeat(64), projectionVersion: 3 }
|
||||||
assert.equal(dedupeVrfAliasSnapshots([snapshot, structuredClone(snapshot)]).length, 1); cases++
|
assert.equal(dedupeVrfAliasSnapshots([snapshot, structuredClone(snapshot)]).length, 1); cases++
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export interface VrfActionContext {
|
|||||||
actionId: VrfNativeActionId
|
actionId: VrfNativeActionId
|
||||||
actionVersion: number
|
actionVersion: number
|
||||||
/** Actual public authority, never a canonical permission substitution. */
|
/** Actual public authority, never a canonical permission substitution. */
|
||||||
authorityAction?: { id: "routing.vrf.update"; version: 3 }
|
authorityAction?: { id: "routing.vrf.update"; version: 4 }
|
||||||
}
|
}
|
||||||
const refuse = (reason: string): never => { throw Error("VRF action binding refused: " + reason) }
|
const refuse = (reason: string): never => { throw Error("VRF action binding refused: " + reason) }
|
||||||
export function vrfPublicAuthority(context: VrfActionContext): { id: VrfAuthorityActionId; version: number } {
|
export function vrfPublicAuthority(context: VrfActionContext): { id: VrfAuthorityActionId; version: number } {
|
||||||
@@ -26,7 +26,7 @@ export function vrfNativeActionFor(publicId: string, request: VrfProductionReque
|
|||||||
export function assertVrfActionContext(context: VrfActionContext, request: VrfProductionRequest): void {
|
export function assertVrfActionContext(context: VrfActionContext, request: VrfProductionRequest): void {
|
||||||
const authority = vrfPublicAuthority(context)
|
const authority = vrfPublicAuthority(context)
|
||||||
if (!Number.isSafeInteger(context.actionVersion) || context.actionVersion < 1 || vrfNativeActionFor(authority.id, request) !== context.actionId) refuse("native action/version")
|
if (!Number.isSafeInteger(context.actionVersion) || context.actionVersion < 1 || vrfNativeActionFor(authority.id, request) !== context.actionId) refuse("native action/version")
|
||||||
if (context.authorityAction && (context.authorityAction.id !== "routing.vrf.update" || context.authorityAction.version !== 3 || request.action !== "patch" || !request.id || !/^\*[a-f0-9]+$/i.test(request.id) || request.placeBeforeId !== undefined || Object.keys(request.fields).length !== 1 || typeof request.fields.interfaces !== "string")) refuse("alias must be one exact interfaces PATCH")
|
if (context.authorityAction && (context.authorityAction.id !== "routing.vrf.update" || context.authorityAction.version !== 4 || request.action !== "patch" || !request.id || !/^\*[a-f0-9]+$/i.test(request.id) || request.placeBeforeId !== undefined || Object.keys(request.fields).length !== 1 || typeof request.fields.interfaces !== "string")) refuse("alias must be one exact interfaces PATCH")
|
||||||
}
|
}
|
||||||
/** Registry versions are protocol fences, not permission grants. */
|
/** Registry versions are protocol fences, not permission grants. */
|
||||||
export function vrfActionContractsCurrent(context: VrfActionContext): boolean {
|
export function vrfActionContractsCurrent(context: VrfActionContext): boolean {
|
||||||
|
|||||||
Loaded 100 of 134 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user