Files
MikrotikManager/backend/src/modules/mcp/routing-original-effects.ts
T

175 lines
16 KiB
TypeScript

import { isIP } from "node:net"
import { z } from "zod"
import type { RouteAiProfile } from "@mmapp/contracts/mcp-routing"
import { routeGatewayContext } from "./routing-community-context-model.js"
import { fingerprint } from "./security.js"
type Row = Record<string, unknown>
const id = z.string().regex(/^\*[a-z0-9]+$/i).max(100)
const text = z.string().min(1).max(200)
const ip = text.refine(value => isIP(value) !== 0)
const flag = (value: unknown): boolean | undefined => [true, "true", "yes"].includes(value as string | boolean) ? true : [false, "false", "no"].includes(value as string | boolean) ? false : undefined
const communities = (row: Row) => Array.isArray(row["bgp.communities"]) ? row["bgp.communities"] as unknown[] : typeof row["bgp.communities"] === "string" ? row["bgp.communities"].split(",") : []
const family = (row: Row) => ["ip", "ip4"].includes(String(row.afi)) ? "ipv4" : ["ipv6", "ip6"].includes(String(row.afi)) ? "ipv6" : null
const unique = (rows: Row[], nativeId: string) => rows.filter(row => row[".id"] === nativeId)
const tupleSchema = z.object({ connectionId: id, sessionId: id, instance: text, table: text, vrf: text,
localAddress: ip, remoteAddress: ip, remoteAsn: z.number().int().positive().max(4294967295), afi: z.enum(["ip", "ipv6"]) }).strict()
const prepared = z.object({ status: z.literal("prepared"), revision: z.literal(1), serverId: z.number().int().positive(),
routeSelectionScope: z.literal("table-afi-community-observation"),
candidateId: text, family: z.enum(["ipv4", "ipv6"]), table: text, community: text, expectedGateway: text,
expectedImmediate: z.object({ address: ip, interface: text }).strict(), binding: tupleSchema,
bfd: z.object({ applicability: z.literal("not-configured"), evidence: z.literal("explicit-use-bfd-false") }).strict(),
routes: z.array(z.object({ nativeId: id, prefix: text, originalGateway: text }).strict()).min(1).max(32),
probe: z.object({ key: text, serverId: z.number().int().positive(), destination: ip, source: ip, interface: text, probeVrf: text,
expectedHops: z.array(ip).min(1).max(8) }).strict(),
}).strict()
export const originalEffectSelectionSchema = z.union([prepared, z.object({ status: z.literal("unknown"), reason: text }).strict()])
export type OriginalEffectSelection = z.infer<typeof originalEffectSelectionSchema>
type Prepared = z.infer<typeof prepared>
const state = z.enum(["observed-expected", "observed-other", "unknown"])
const roundSchema = z.object({ observedAt: z.string().datetime(), routes: z.array(z.object({ prefix: text, status: state }).strict()).max(32),
bgp: state, bfd: z.enum(["not-configured", "unknown"]), controlProbe: z.enum(["passed", "failed", "unknown"]),
relatedTopicRows: z.number().int().nonnegative().max(1000) }).strict()
export const originalEffectReportSchema = z.object({ scope: z.literal("prepared-route-fib-adjacency-control-effects"), status: state,
reason: text.nullable(), rounds: z.array(roundSchema).max(3), logSource: z.literal("routeros-rest-ring"), logCoverage: z.literal("unknown"),
selectionIdentity: z.string().regex(/^[a-f0-9]{64}$/).nullable(),
routeSelectionScope: z.literal("table-afi-community-observation"), transitionAttribution: z.literal("unproven"),
switchEffectObserved: z.boolean(),
transition: z.object({ baselineObservedAt: z.string().datetime(), changedPrefixes: z.array(text).max(32) }).strict().optional(),
budget: z.object({ deadline: z.number().int().positive(), calls: z.number().int().nonnegative().max(256), bytes: z.number().int().nonnegative().max(8 * 1048576),
rows: z.number().int().nonnegative().max(10000) }).strict().optional(),
semanticProof: z.literal(false), routeOriginVerified: z.literal(false), recursiveChainVerified: z.literal(false), forwardingVerified: z.literal(false), autoRecoveryAllowed: z.literal(false) }).strict()
export type OriginalEffectReport = z.infer<typeof originalEffectReportSchema>
export const unknownOriginalEffects = (reason: string): OriginalEffectReport => ({ scope: "prepared-route-fib-adjacency-control-effects", status: "unknown", reason,
rounds: [], logSource: "routeros-rest-ring", logCoverage: "unknown", selectionIdentity: null, routeSelectionScope: "table-afi-community-observation", transitionAttribution: "unproven", switchEffectObserved: false, semanticProof: false, routeOriginVerified: false, recursiveChainVerified: false, forwardingVerified: false, autoRecoveryAllowed: false })
function tuple(row: Row, expected: Prepared["binding"], session: boolean) {
return row.instance === expected.instance && row["routing-table"] === expected.table && row.vrf === expected.vrf &&
row["local.address"] === expected.localAddress && row["remote.address"] === expected.remoteAddress && Number(row["remote.as"]) === expected.remoteAsn &&
(session ? row["local.afi"] === expected.afi && row["remote.afi"] === expected.afi : row.afi === expected.afi)
}
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))
}
/** Called only by the real private context reader. This selects observed route
* effects by table/AFI/Community, never a route→session provenance relationship.
* The initial bounded subset requires explicit flat peer identity, explicit
* BFD-off and an already configured explicit probe source. Other manual paths
* keep their existing configuration/probe behavior, with effects unknown. */
export function prepareOriginalEffectSelection(profile: RouteAiProfile, candidateId: string, binding: { connectionId: string; instance: string | null; vrf: string; table: string } | null,
raw: { routes: Row[]; connections: Row[]; sessions: Row[]; candidate: Row | undefined; probe: { destination: string; expected: string[]; source?: string } | undefined }): OriginalEffectSelection {
try {
if (!binding || !binding.instance) throw Error()
const candidate = profile.candidates.find(value => value.id === candidateId), probe = raw.probe
if (!candidate?.controlProbe || !probe?.source || !raw.candidate) throw Error()
const connection = unique(raw.connections, binding.connectionId)
if (connection.length !== 1 || !(connection[0].templates === "" || Array.isArray(connection[0].templates) && connection[0].templates.length === 0) ||
flag(connection[0].disabled) !== false || flag(connection[0]["use-bfd"]) !== false) throw Error()
const peer = connection[0], afi = profile.family === "ipv4" ? "ip" : "ipv6"
const wanted = tupleSchema.parse({ connectionId: peer[".id"], sessionId: "*0", instance: binding.instance, table: binding.table, vrf: binding.vrf,
localAddress: peer["local.address"], remoteAddress: peer["remote.address"], remoteAsn: Number(peer["remote.as"]), afi })
if (!tuple(peer, wanted, false)) throw Error()
const sessions = raw.sessions.filter(row => tuple(row, wanted, true))
if (sessions.length !== 1 || flag(sessions[0].established) === undefined) throw Error()
wanted.sessionId = id.parse(sessions[0][".id"])
const affected = selectedRows(raw.routes, profile)
if (!affected.length || affected.length > 32 || new Set(affected.map(row => row["dst-address"])).size !== affected.length || new Set(affected.map(row => row[".id"])).size !== affected.length) throw Error()
for (const row of affected) {
const [address, mask, extra] = String(row["dst-address"]).split("/")
if (extra !== undefined || isIP(address) !== (profile.family === "ipv4" ? 4 : 6) || !/^\d+$/.test(mask ?? "") || Number(mask) > (profile.family === "ipv4" ? 32 : 128)) throw Error()
routeGatewayContext(String(row.gateway))
}
// A selected destination cannot silently choose among competing origins.
if (affected.some(row => raw.routes.filter(other => other["routing-table"] === profile.table && other["dst-address"] === row["dst-address"]).length !== 1)) throw Error()
const endpoint = candidate.gatewayRef.resource === "gre" ? { address: routeGatewayContext(candidate.gateway).address, interface: String(raw.candidate.name) } : routeGatewayContext(String(raw.candidate["immediate-gw"]))
if (!endpoint.interface) throw Error()
return prepared.parse({ status: "prepared", revision: 1, serverId: profile.serverId, candidateId, family: profile.family, table: profile.table,
routeSelectionScope: "table-afi-community-observation",
community: profile.community, expectedGateway: candidate.gateway, expectedImmediate: { address: endpoint.address, interface: endpoint.interface }, binding: wanted,
bfd: { applicability: "not-configured", evidence: "explicit-use-bfd-false" },
routes: affected.map(row => ({ nativeId: row[".id"], prefix: row["dst-address"], originalGateway: row.gateway })).sort((a, b) => String(a.prefix).localeCompare(String(b.prefix))),
probe: { key: candidateId, serverId: profile.serverId, destination: probe.destination, source: probe.source, interface: candidate.wanInterface,
probeVrf: profile.probeVrf, expectedHops: probe.expected } })
} catch { return { status: "unknown", reason: "prepared-effect-selection-unsupported-or-ambiguous" } }
}
type Snapshot = { observedAt: string; servers: Array<{ serverId: number; routes: { payload: Row[] }; bgp: { payload: Row[]; connectionsPayload: Row[] }; logs: { payload: Row[] } }>;
probes: Array<{ key: string; pingPayload: Row[]; tracePayload?: Row[] }> }
export function originalEffectBaselineMatches(selection: Prepared, sample: Snapshot) {
const source = sample.servers.find(row => row.serverId === selection.serverId)
if (!source) return false
const actual = selectedRows(source.routes.payload, selection)
return actual.length === selection.routes.length && selection.routes.every(wanted => actual.filter(row => row[".id"] === wanted.nativeId && row["dst-address"] === wanted.prefix && row.gateway === wanted.originalGateway).length === 1)
}
/** Actual bounded native captures only; all stronger causal/whole-path claims
* remain false. Related log inspection is reported without completeness. */
export async function inspectOriginalEffectRounds(rawSelection: unknown, samples: Snapshot[], baseline: Snapshot) {
const selection = prepared.parse(rawSelection), rounds: z.infer<typeof roundSchema>[] = []
if (!originalEffectBaselineMatches(selection, baseline)) return unknownOriginalEffects("prepared-effect-baseline-drift")
const { inspectControlPing, inspectTrace } = await import("./path-verification.js")
for (const sample of samples) {
const source = sample.servers.find(row => row.serverId === selection.serverId)
if (!source) return unknownOriginalEffects("effect-source-missing")
const scope = selectedRows(source.routes.payload, selection)
const scopeChanged = fingerprint(scope.map(row => row["dst-address"]).sort()) !== fingerprint(selection.routes.map(row => row.prefix).sort())
const routes = selection.routes.map(wanted => {
const matching = source.routes.payload.filter(row => row["routing-table"] === selection.table && row["dst-address"] === wanted.prefix && family(row) === selection.family)
let status: z.infer<typeof state> = "unknown"
if (!scopeChanged && matching.length === 1) {
const row = matching[0]
if (flag(row.bgp) === true && flag(row.connect) !== true && flag(row.static) !== true && flag(row.active) !== undefined && row.ecmp !== undefined && flag(row.ecmp) === false &&
["disabled", "filtered", "unreachable", "blackhole", "invalid"].every(key => row[key] === undefined || flag(row[key]) === false)) {
try {
const final = routeGatewayContext(String(row["immediate-gw"]))
status = flag(row.active) === true && row.contribution === "active" && row.gateway === selection.expectedGateway && final.address === selection.expectedImmediate.address && final.interface === selection.expectedImmediate.interface ? "observed-expected" : "observed-other"
} catch { /* no final resolution evidence */ }
}
}
return { prefix: wanted.prefix, status }
})
const connections = unique(source.bgp.connectionsPayload, selection.binding.connectionId)
const sessions = source.bgp.payload.filter(row => tuple(row, selection.binding, true))
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 &&
sessions.length === 1 && sessions[0][".id"] === selection.binding.sessionId && flag(sessions[0].established) !== undefined) bgp = flag(sessions[0].established) ? "observed-expected" : "observed-other"
const probe = sample.probes.find(row => row.key === selection.probe.key)
let controlProbe: "passed" | "failed" | "unknown" = "unknown"
if (probe) {
const ping = inspectControlPing(z.array(z.record(z.string(), z.string())).max(256).parse(probe.pingPayload))
if (ping.confirmedFailure) controlProbe = "failed"
else if (ping.ping.measured && ping.ping.loss === 0 && inspectTrace(probe.tracePayload, selection.probe.destination, selection.probe.expectedHops).verified) controlProbe = "passed"
}
rounds.push(roundSchema.parse({ observedAt: sample.observedAt, routes, bgp,
bfd: connections.length === 1 && flag(connections[0]["use-bfd"]) === false ? "not-configured" : "unknown", controlProbe,
relatedTopicRows: source.logs.payload.filter(row => typeof row.topics === "string" && row.topics.split(",").some(topic => ["bgp", "bfd", "route", "interface"].includes(topic))).length }))
}
if (rounds.length !== 2) return unknownOriginalEffects("two-effect-rounds-required")
const all = rounds.flatMap(round => [...round.routes.map(route => route.status), round.bgp, round.bfd === "unknown" ? "unknown" : "observed-expected", round.controlProbe === "passed" ? "observed-expected" : round.controlProbe === "failed" ? "observed-other" : "unknown"])
const before = baseline.servers.find(row => row.serverId === selection.serverId)!
// Native IDs alone may churn; they never count as a forwarding transition.
// Require an actual selected active destination's final forwarding endpoint
// to differ from the expected post-state seen consistently in both rounds.
// A changed logical gateway resolving to the SAME endpoint is not a FIB switch.
const changedPrefixes = selection.routes.filter(wanted => {
if (!rounds.every(round => round.routes.find(row => row.prefix === wanted.prefix)?.status === "observed-expected")) return false
const row = before.routes.payload.find(row => row[".id"] === wanted.nativeId && row["dst-address"] === wanted.prefix)!
if (flag(row.bgp) !== true || flag(row.active) !== true || row.contribution !== "active" || flag(row.connect) === true || flag(row.static) === true || flag(row.ecmp) !== false ||
["disabled", "filtered", "unreachable", "blackhole", "invalid"].some(key => row[key] !== undefined && flag(row[key]) !== false)) return false
try {
const endpoint = routeGatewayContext(String(row["immediate-gw"]))
routeGatewayContext(String(row.gateway))
if (!endpoint.interface) return false
return endpoint.address !== selection.expectedImmediate.address || endpoint.interface !== selection.expectedImmediate.interface
} catch { return false }
}).map(row => row.prefix)
const postState = all.includes("unknown") ? "unknown" : all.includes("observed-other") ? "observed-other" : "observed-expected"
return originalEffectReportSchema.parse({ ...unknownOriginalEffects(""), rounds, reason: null,
selectionIdentity: fingerprint(selection),
transition: { baselineObservedAt: baseline.observedAt, changedPrefixes }, switchEffectObserved: postState === "observed-expected" && changedPrefixes.length > 0,
status: postState === "observed-expected" && !changedPrefixes.length ? "unknown" : postState,
...(postState === "observed-expected" && !changedPrefixes.length ? { reason: "poststate-matched-without-selected-fib-transition" } : {}) })
}