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

344 lines
35 KiB
TypeScript

import { readRoutingTopology } from "../../services/routing-topology-observation.js"
import { createRoutingSourceContext, readRoutingEndpoints } from "./routing-observation-source.js"
import { z } from "zod"
import { dedupeVrfAliasSnapshots } from "../../services/vrf-production-action-binding.js"
import { pool } from "../../db/index.js"
import { hasManagedCommentPrefix } from "../../managed-markers.js"
import { configurationRows, readRosSnapshot, readRoutingPolicySnapshot, resourceById, routerClient, rosSnapshotSourceProof, type ConfigurationRow, type RosSnapshot } from "./resources.js"
import { readRouterOsSnapshotGet } from "../../services/routeros-snapshot-read.js"
import { McpError, fingerprint } from "./security.js"
import { effectiveMcpConnection } from "./store.js"
import { hasPermission } from "../../lib/permissions.js"
import { getServerRowById } from "../servers/repository/servers-repository.js"
import type { add } from "./actions.js"
import type { ChangeEvidence } from "./action-types.js"
import { catalogPaging, catalogDevices } from "./catalog-scope.js"
import { readOspfCatalogForServer } from "../../services/ospf-state-read.js"
import { pollOspfCatalog } from "../../services/ospf-catalog-poll.js"
import { pollBgpCatalog } from "../../services/bgp-catalog-poll.js"
import { readBgpSessionsForServer, readBgpCatalogForServer, readRawBgpSessionsForServer } from "../../services/bgp-session-read.js"
import { pollGreCatalog } from "../../services/gre-catalog-poll.js"
import { fetchServerFilters, readFilterRulesCatalog } from "../../routes/filters.js"
import { readRecursiveCatalogForServer, readRecursiveGatewayCatalogForServer, readRecursiveRoutesForServer, readRecursiveGatewaysForServer } from "../../routes/recursive-routes.js"
import { analyzeEcmpPreflight, confirmedEcmpInterfaces, confirmedEcmpTable, duplicateEcmpAdjacency, ecmpAdjacency, preflightPaths, preflightObservation, type PreflightObservation, type PreflightSource } from "./ecmp-preflight.js"
import { readLatestInternetPathSnapshot } from "../../services/internet-path-read.js"
import { projectInternetPathObservation } from "../../services/internet-path-observation.js"
import { readOspfOptimizationPreview } from "../../routes/ospf.js"
const serverId = z.number().int().positive(), objectId = z.string().min(1).max(100)
const text = z.string().min(1).max(100), family = z.enum(["ipv4", "ipv6"])
const bool = (value: unknown) => value === true || value === "true" || value === "yes"
export function checkRpfPolicy(serverId: number, snapshots: RosSnapshot[], mode: string) {
const find = (resource: string) => snapshots.find(snapshot => snapshot.serverId === serverId && snapshot.resource === resource)
const tables = find("routing_table"), vrfs = find("vrf"), rules = find("routing_policy:routing_rule"), mangle = find("routing_policy:ip_mangle")
if (!tables || !vrfs || !rules || !mangle || !find("ipv4_route")) {
throw new McpError("PLAN_EXPIRED", "План не содержит актуальные зависимости RPF; подготовьте его заново", 409)
}
const policyRouting = rules.rows.some(row => !bool(row.disabled))
|| mangle.rows.some(row => !bool(row.disabled) && (row.action === "mark-routing" || typeof row.action !== "string" || !row.action.trim()))
const separateTables = [...tables.rows, ...vrfs.rows].some(row => row.name !== "main")
if (mode === "strict" && (policyRouting || separateTables)) throw new McpError("STATE_CONFLICT", "Strict RPF требует main без отдельных таблиц, VRF и действующих правил policy routing", 409)
return policyRouting || separateTables
}
export async function readRpfDependencies(serverId: number, mode: string, sourceProof: string | undefined) {
if (!sourceProof) throw new McpError("STATE_CONFLICT", "Источник IPv4-настроек RPF не подтверждён", 409)
const [tables, vrfs, routes] = await Promise.all(
["routing_table", "vrf", "ipv4_route"].map(resource => readRosSnapshot(serverId, resource, undefined, sourceProof)),
)
const snapshots = [tables, vrfs, routes]
const warnings = ["RPF является IPv4-настройкой. Изменение может затронуть асимметричный трафик; снижение защиты требует компенсирующих правил."]
if (mode !== "no") {
const [rules, mangle] = await Promise.all(["routing_rule", "ip_mangle"].map(resource => readRoutingPolicySnapshot(serverId, resource, sourceProof)))
snapshots.push(rules, mangle)
if (checkRpfPolicy(serverId, snapshots, mode)) warnings.push("Обнаружена policy routing или отдельные таблицы. Проверьте обратный путь для трафика в каждой затронутой таблице.")
}
return { snapshots, warnings }
}
export function registerRoutingControls(register: typeof add) {
register("routing.ospf.application.preview","mm_ospf_application_preview","Модельный OSPF preview приложения с отдельными наблюдаемыми метриками","network","mm:network:read","read","servers",z.object({serverId:z.number().int().positive().safe(),pingWeight:z.number().int().min(0).max(100).default(60),peerServerIds:z.array(z.number().int().positive().safe()).max(10).refine(ids=>new Set(ids).size===ids.length).optional(),offset:z.number().int().min(0).max(100000).default(0),limit:z.number().int().min(1).max(200).default(100)}).strict(),{
read:async(input,context)=>{
const deadlineAt=Date.now()+45000,signal=AbortSignal.any([AbortSignal.timeout(45000),...(context.signal?[context.signal]:[])])
const expectedPolicy=fingerprint(context.connection.policy)
const assertAccess=async()=>{
if(signal.aborted)throw new McpError("READ_CANCELLED","Чтение OSPF отменено или превысило срок",409)
const current=await effectiveMcpConnection(context.principal)
if(fingerprint(current.policy)!==expectedPolicy||!hasPermission(current.owner_permissions,"mm:network:read")||!hasPermission(current.policy.permissions,"mm:network:read"))throw new McpError("STATE_CONFLICT","Доступ к OSPF изменился во время чтения",409)
if(signal.aborted)throw new McpError("READ_CANCELLED","Чтение OSPF отменено или превысило срок",409)
}
await assertAccess()
const allowed=context.connection.policy.scope.serverIds
const selected=[...new Set([Number(input.serverId),...(input.peerServerIds as number[]|undefined??allowed)])].sort((a,b)=>a-b)
if(selected.some(id=>!allowed.includes(id)))throw new McpError("SERVER_DENIED","Сосед OSPF вне разрешённой области",403)
if(selected.length>10)throw new McpError("LIMIT_EXCEEDED","Выберите через peerServerIds не более 10 устройств вместе с источником",409)
const devices=(await pool.query<{id:number;enabled:boolean}>("SELECT id,enabled FROM servers WHERE id=ANY($1::bigint[])",[selected])).rows
if(devices.length!==selected.length)throw new McpError("NOT_FOUND","Выбранное устройство OSPF отсутствует в каталоге",404)
const {server}=await routerClient(Number(input.serverId))
try{
const result=await readOspfOptimizationPreview(server,Number(input.pingWeight),{serverIds:selected,signal,deadlineAt,beforeRequest:async source=>{
await assertAccess()
const fresh=await getServerRowById(source.id)
if(!fresh?.enabled||fingerprint(fresh)!==fingerprint(source))throw new McpError("STATE_CONFLICT","Источник OSPF изменился во время чтения",409)
if(signal.aborted)throw new McpError("READ_CANCELLED","Чтение OSPF отменено или превысило срок",409)
}})
await assertAccess()
const offset=Number(input.offset),limit=Number(input.limit)
const view=(rows:typeof result.interfaces)=>{const items=rows.slice(offset,offset+limit).map(({id,interface:iface,currentCost,observed,...modeled})=>({id,interface:iface,currentCost,observed:observed??null,modeled}));return{items,total:rows.length,nextOffset:offset+limit<rows.length?offset+limit:null}}
const interfaces=view(result.interfaces),changes=view(result.changes)
return{serverId:server.id,serverName:server.name||null,pingWeight:result.pingWeight,interfacesTotal:result.interfacesTotal,changedCount:result.changedCount,unchangedCount:result.unchangedCount,interfaces,changes,
allowedMetricServerIds:selected,disabledPeerIds:devices.filter(row=>!row.enabled).map(row=>row.id),peerSelectionCoversGrant:selected.length===allowed.length,
proposalOnly:true,modelContainsAssumptions:true,metricContextVerified:false,executionAvailable:false,newApprovedPlanRequired:true,forwardingVerified:false,
incomplete:interfaces.items.length<interfaces.total||changes.items.length<changes.total,observedAt:new Date().toISOString(),
warnings:["Модель приложения может использовать округления, минимумы и fallback-значения. Они вынесены в modeled, наблюдения — отдельно.","Рейтинг ограничен выбранными peers и не подтверждает контекст instance/family/VRF, свежесть bandwidth или пригодность пути.","Этот preview не создаёт план записи. Для применения нужны отдельные наблюдения и подтверждённый OSPF-план."]}
}catch(error){await assertAccess();if(error instanceof McpError)throw error;throw new McpError("STATE_UNAVAILABLE","Предпросмотр OSPF выбранной области не подтверждён",503)}
},
})
register("routing.internet-path.latest.read", "mm_internet_path_latest_get", "Сохранённый Internet path выбранных серверов, без подтверждения текущего пути", "network", "mm:network:read", "read", "servers", catalogPaging.safeExtend({ maxAgeSec:z.number().int().min(30).max(604800).default(900) }), {
read: async (input,context) => {
const {selected,...page}=await catalogDevices(input,context,{includeDisabled:true})
const row=await readLatestInternetPathSnapshot(selected.map(server=>server.id))
if(!row)return {...page,snapshot:null,source:"stored-application-interpretation",live:false,forwardingVerified:false}
try {
const snapshot=projectInternetPathObservation(row,selected.map(server=>server.id),Number(input.offset),Number(input.limit),Number(input.maxAgeSec))
return {...page,snapshot,observedAt:new Date().toISOString(),incomplete:page.incomplete||snapshot.incomplete}
} catch {throw new McpError("STATE_UNAVAILABLE","Сохранённый Internet path выбранной области не подтверждён",503)}
},
})
register("routing.filters.catalog.read", "mm_bgp_filter_rules_get", "BGP-in: сервисный каталог правил и явный cache fallback", "filters", "mm:filters:read", "read", "servers", catalogPaging.safeExtend({ source: z.enum(["live", "cache"]).default("live") }), {
read: async (input, context) => {
const { selected, ...page } = await catalogDevices(input, context, { includeDisabled: input.source === "cache" })
const rows = await readFilterRulesCatalog(selected, { source: input.source as "live" | "cache" })
const offset = Number(input.offset), limit = Number(input.limit)
const itemsPage = <T,>(items: T[]) => ({ items: items.slice(offset, offset + limit), total: items.length, nextOffset: offset + limit < items.length ? offset + limit : null })
const rulesets = rows.map(row => ({ serverId: row.serverId, live: row.live, stale: row.stale, source: row.source, error: row.error,
rules: itemsPage(row.rules), nativeRules: row.nativeRules === null ? null : itemsPage(row.nativeRules),
greTunnels: itemsPage(row.greTunnels.map(tunnel => ({ id: tunnel.id, name: tunnel.name, serverId: tunnel.serverId, localAddress: tunnel.localAddress, remoteAddress: tunnel.remoteAddress }))),
cacheFreshnessVerified: false, recursiveReferencesVerified: false, ruleEffectsVerified: false,
}))
return { ...page, rulesets, incomplete: page.incomplete || rulesets.some(row => row.error !== null || [row.rules, row.greTunnels, ...(row.nativeRules ? [row.nativeRules] : [])].some(value => value.items.length < value.total)),
observedAt: new Date().toISOString(), writesPerformed: false, annotationsSource: "manager-metadata-or-legacy-native-comments", parsedRulesMeaning: "heuristic business hints, including disabled/unknown native rows; not effective routing policy",
nativeOrderMeaning: "relative bgp-in order only; does not evaluate chain jumps", greDetailsTool: "mm_gre_state_get", forwardingVerified: false, prefixExchangeVerified: false }
},
})
register("routing.ecmp.preflight.read", "mm_ecmp_preflight_get", "Предварительная проверка ECMP: NAT/FastTrack/RPF/маркировки без записи", "network", "mm:network:read", "read", "servers", z.object({ serverId, family, routeIds: z.array(objectId).min(2).max(8).refine(ids => new Set(ids).size === ids.length) }).strict(), {
read: async input => {
const { client } = await routerClient(Number(input.serverId)), selectedFamily = input.family as "ipv4" | "ipv6"
const read = async (path: string, singleton = false): Promise<PreflightObservation> => {
try { return preflightObservation(await client.get<unknown>(path), singleton) }
catch { return { rows: [], error: "Устройство не подтвердило чтение ресурса" } }
}
const entries = await Promise.all(Object.entries(preflightPaths(selectedFamily)).map(async ([key, path]) => [key, await read(path, key === "settings" || key === "connectionTracking")] as const))
const observed = Object.fromEntries(entries) as Record<PreflightSource, PreflightObservation>
const ids = input.routeIds as string[]
const needsInventory = observed.routes.rows.some(row => ids.includes(String(row[".id"])) && typeof row["immediate-gw"] === "string" && row["immediate-gw"].length > 0 && !ecmpAdjacency(row["immediate-gw"]))
const inventory = needsInventory ? await read("/interface") : undefined
return { serverId: input.serverId, observedAt: new Date().toISOString(), ...analyzeEcmpPreflight(selectedFamily, ids, observed, inventory) }
},
})
const recursivePaging = z.object({ serverId, offset: z.number().int().min(0).max(100000).default(0), limit: z.number().int().min(1).max(200).default(100) }).strict()
register("routing.recursive.routes.read", "mm_recursive_routes_get", "Рекурсивные маршруты приложения с явным признаком устаревшего кеша", "network", "mm:network:read", "read", "servers", recursivePaging, {
read: async input => {
const { server } = await routerClient(Number(input.serverId))
const result = await readRecursiveRoutesForServer(server, undefined, { persistObserved: false })
const offset = Number(input.offset), limit = Number(input.limit)
return { ...result, routes: result.routes.slice(offset, offset + limit), total: result.routes.length, nextOffset: offset + limit < result.routes.length ? offset + limit : null, observedAt: new Date().toISOString(), incomplete: result.stale || result.routes.length > limit, forwardingVerified: false }
},
})
register("routing.recursive.gateways.read", "mm_recursive_gateways_get", "Шлюзы приложения с up/down/unknown без предположений о передаче пакетов", "network", "mm:network:read", "read", "servers", recursivePaging, {
read: async input => {
const { server } = await routerClient(Number(input.serverId))
const result = await readRecursiveGatewaysForServer(server)
const offset = Number(input.offset), limit = Number(input.limit)
return { ...result, gateways: result.gateways.slice(offset, offset + limit), total: result.gateways.length, nextOffset: offset + limit < result.gateways.length ? offset + limit : null, observedAt: new Date().toISOString(), incomplete: result.gateways.length > limit, forwardingVerified: false }
},
})
register("routing.recursive.catalog.read", "mm_recursive_routes_catalog_get", "Подтверждённый каталог управляемых рекурсивных маршрутов", "network", "mm:network:read", "read", "servers", recursivePaging, {
read: async input => {
const { server } = await routerClient(Number(input.serverId))
const result = await readRecursiveCatalogForServer(server, undefined, { persistObserved: false })
const offset = Number(input.offset), limit = Number(input.limit)
return { ...result, routes: result.routes.slice(offset, offset + limit), total: result.routes.length, nextOffset: offset + limit < result.routes.length ? offset + limit : null, observedAt: new Date().toISOString(), incomplete: result.routes.length > limit }
},
})
register("routing.recursive.gateways.catalog.read", "mm_recursive_gateways_catalog_get", "Подтверждённые шлюзы с active/inactive/unknown", "network", "mm:network:read", "read", "servers", recursivePaging, {
read: async input => {
const { server } = await routerClient(Number(input.serverId))
const result = await readRecursiveGatewayCatalogForServer(server)
const offset = Number(input.offset), limit = Number(input.limit)
return { ...result, gateways: result.gateways.slice(offset, offset + limit), total: result.gateways.length, nextOffset: offset + limit < result.gateways.length ? offset + limit : null, observedAt: new Date().toISOString(), incomplete: result.gateways.length > limit }
},
})
register("routing.gre.catalog.read", "mm_gre_catalog_get", "Каталог GRE для выбора путей маршрутизации", "filters", "mm:filters:read", "read", "servers", catalogPaging, {
read: async (input, context) => {
const { selected, ...page } = await catalogDevices(input, context)
const result = await pollGreCatalog(selected, fetchServerFilters)
const offset = Number(input.offset), limit = Number(input.limit)
return { ...page, tunnels: result.tunnels.slice(offset, offset + limit), total: result.tunnels.length, nextOffset: offset + limit < result.tunnels.length ? offset + limit : null, failures: result.failures, observedAt: new Date().toISOString(), incomplete: page.incomplete || result.failures.length > 0 || result.tunnels.length > limit }
},
})
register("routing.bgp.catalog.read", "mm_bgp_catalog_get", "Каталог BGP приложения с ограничением серверов до опроса", "bgp", "mm:bgp:read", "read", "servers", catalogPaging, {
read: async (input, context) => {
const { selected, ...page } = await catalogDevices(input, context)
const result = await pollBgpCatalog(selected, readBgpCatalogForServer)
const offset = Number(input.offset), limit = Number(input.limit)
return { ...page, items: result.sessions.slice(offset, offset + limit), total: result.sessions.length, nextOffset: offset + limit < result.sessions.length ? offset + limit : null, failures: result.failures, observedAt: new Date().toISOString(), incomplete: page.incomplete || result.failures.length > 0 || result.sessions.length > limit }
},
})
register("routing.bgp.sessions.read", "mm_bgp_sessions_get", "BGP-сессии приложения: состояние и счётчики с сохранением ошибок", "bgp", "mm:bgp:read", "read", "servers", catalogPaging, {
read: async (input, context) => {
const { selected, ...page } = await catalogDevices(input, context)
const result = await pollBgpCatalog(selected, readBgpSessionsForServer)
const offset = Number(input.offset), limit = Number(input.limit)
return { ...page, sessions: result.sessions.slice(offset, offset + limit), total: result.sessions.length, nextOffset: offset + limit < result.sessions.length ? offset + limit : null, failures: result.failures,
observedAt: new Date().toISOString(), incomplete: page.incomplete || result.failures.length > 0 || result.sessions.slice(offset, offset + limit).length < result.sessions.length }
},
})
register("routing.bgp.sessions.raw.read", "mm_bgp_sessions_raw_get", "Диагностический ответ BGP выбранного сервера с маскированием секретов", "bgp", "mm:bgp:read", "read", "servers", z.object({ serverId, offset: z.number().int().min(0).max(100000).default(0), limit: z.number().int().min(1).max(200).default(100) }).strict(), {
read: async input => {
const { server } = await routerClient(Number(input.serverId))
const raw = await readRawBgpSessionsForServer(server), offset = Number(input.offset), limit = Number(input.limit)
return { server: { id: server.id, host: server.host, name: server.name }, raw: raw.slice(offset, offset + limit), total: raw.length, nextOffset: offset + limit < raw.length ? offset + limit : null,
incomplete: raw.slice(offset, offset + limit).length < raw.length, observedAt: new Date().toISOString(), forwardingVerified: false }
},
})
register("routing.ospf.catalog.read", "mm_ospf_catalog_get", "Каталог OSPF/BFD приложения с ограниченной областью опроса", "network", "mm:network:read", "read", "servers", catalogPaging, {
read: async (input, context) => {
const { selected, ...page } = await catalogDevices(input, context)
const result = await pollOspfCatalog(selected, readOspfCatalogForServer)
const entries = ["neighbors", "interfaces", "instances", "bfdSessions", "routes"] as const
const offset = Number(input.offset), limit = Number(input.limit)
const collections = Object.fromEntries(entries.map(key => [key, { items: result[key].slice(offset, offset + limit), total: result[key].length, nextOffset: offset + limit < result[key].length ? offset + limit : null }]))
return { ...page, collections, failures: result.failures, observedAt: new Date().toISOString(), incomplete: result.failures.length > 0 || page.incomplete || entries.some(key => result[key].length > limit) }
},
})
register("routing.topology.read", "mm_routing_topology_get", "Наблюдаемая конфигурация маршрутизации", "network", "mm:network:read", "read", "servers", z.object({ serverId }).strict(), {
read: async (input, context) => {
const sid = Number(input.serverId)
if (!context.connection.policy.scope.serverIds.includes(sid)) throw new McpError("SERVER_DENIED", "Источник топологии вне разрешённой области", 403)
const signal = AbortSignal.any([AbortSignal.timeout(30000), ...(context.signal ? [context.signal] : [])])
const authorize = async () => {
if (signal.aborted) throw new McpError("READ_CANCELLED", "Чтение топологии отменено или превысило срок", 409)
const current = await effectiveMcpConnection(context.principal)
if (!hasPermission(current.owner_permissions, "mm:network:read") || !hasPermission(current.policy.permissions, "mm:network:read")) throw new McpError("PERMISSION_DENIED", "Право чтения сети отозвано", 403)
if (fingerprint(current.policy) !== fingerprint(context.connection.policy) || fingerprint(current.owner_permissions) !== fingerprint(context.connection.owner_permissions)) throw new McpError("RESET_REQUIRED", "Допуск изменился; повторите чтение топологии", 409)
}
await authorize()
const source = await createRoutingSourceContext(await readRoutingEndpoints([sid]))
if (source.expected.length !== 1) throw new McpError("DEVICE_UNAVAILABLE", "Источник топологии отсутствует или выключен", 409)
const beforeRead = async () => {
await authorize(); await source.assertCurrent()
if (signal.aborted) throw new McpError("READ_CANCELLED", "Чтение топологии отменено или превысило срок", 409)
}
await beforeRead()
const { client } = await routerClient(sid)
return { serverId: sid, ...await readRoutingTopology(client, beforeRead, signal) }
},
})
register("routing.ecmp.update", "mm_ecmp_plan", "Равная стоимость маршрутов ECMP и hash policy", "network", "mm:network:write", "write", "servers", z.object({ serverId, family, routeIds: z.array(objectId).min(2).max(8).refine(ids => new Set(ids).size === ids.length), distance: z.number().int().min(1).max(255).default(1), hashPolicy: z.enum(["l3", "l4", "l3-inner"]).optional() }).strict(), {
version: 7,
prepare: async input => {
const sid = Number(input.serverId), ipv4 = input.family === "ipv4", resource = ipv4 ? "ipv4_route" : "ipv6_route"
const base = ipv4 ? "ip" : "ipv6", settingsResource = `${base}_settings`
const { client, server } = await routerClient(sid)
const sourceProof = await rosSnapshotSourceProof(server)
const assertSource = async () => {
if (await rosSnapshotSourceProof((await routerClient(sid)).server) !== sourceProof) {
throw new McpError("STATE_CONFLICT", "Источник маршрутов ECMP изменился при подготовке", 409)
}
}
const read = (resourceId: string) => readRosSnapshot(sid, resourceId, undefined, sourceProof)
const readNative = async (path: string) => {
const value = await readRouterOsSnapshotGet<unknown>({ client, path, assertCurrent: assertSource })
await assertSource()
return preflightObservation(value)
}
const snapshot = await read(resource), ids = input.routeIds as string[]
const observed = await readNative(`/${base}/route`)
if (observed.error) throw new McpError("MEASUREMENT_UNKNOWN", observed.error, 409)
if (fingerprint(configurationRows(resourceById(resource), observed.rows)) !== snapshot.fingerprint) {
throw new McpError("STATE_CONFLICT", "Каталог маршрутов ECMP изменился при подготовке", 409)
}
const raw = observed.rows
const rows = ids.map(id => raw.find(row => row[".id"] === id))
if (rows.some(row => !row || bool(row.dynamic) || bool(row.disabled) || bool(row.invalid) || !hasManagedCommentPrefix(String(row.comment ?? "")))) throw new McpError("PROTECTED_OBJECT", "ECMP требует разрешаемые статические маршруты приложения")
const routes = rows as ConfigurationRow[], first = routes[0]
if (routes.some(row => row["dst-address"] !== first["dst-address"] || String(row["routing-table"] ?? "main") !== String(first["routing-table"] ?? "main"))) throw new McpError("STATE_CONFLICT", "ECMP требует одинаковые prefix и routing table", 409)
const gateways = routes.map(row => row.gateway)
if (gateways.some(gateway => typeof gateway !== "string" || !gateway.trim()) || new Set(gateways).size !== ids.length) throw new McpError("MEASUREMENT_UNKNOWN", "Нужны разные подтверждённые gateway", 409)
const immediate = routes.map(row => row["immediate-gw"])
let hops = immediate.map(value => ecmpAdjacency(value))
let interfaceIds: Map<string, string> | undefined
if (hops.some(hop => hop === null)) {
const inventory = await readNative("/interface")
if (inventory.error) throw new McpError("MEASUREMENT_UNKNOWN", inventory.error, 409)
interfaceIds = confirmedEcmpInterfaces(inventory.rows)
const names = new Set(interfaceIds.keys())
hops = immediate.map(value => ecmpAdjacency(value, names))
}
if (hops.some(hop => hop === null)) throw new McpError("MEASUREMENT_UNKNOWN", "Не подтверждён одиночный immediate gateway каждого маршрута", 409)
if (duplicateEcmpAdjacency(hops)) throw new McpError("STATE_CONFLICT", "Маршруты ECMP разрешаются в один immediate gateway", 409)
const steps: ChangeEvidence["steps"] = ids.map(id => ({ kind: "ros", step: { serverId: sid, resource, id, action: "update", fields: { distance: String(input.distance) } } }))
const snapshots = [snapshot]
const tableSnapshot = await read("routing_table")
const table = confirmedEcmpTable(tableSnapshot.rows, String(first["routing-table"] ?? "main"))
if (!table) {
throw new McpError("STATE_CONFLICT", "Для ECMP нужна однозначно определённая активная таблица FIB", 409)
}
snapshots.push(tableSnapshot)
const objects: ChangeEvidence["objects"] = [...ids.map(id => ({ kind: resource, id, serverId: sid })), { kind: "routing_table", id: table.id, serverId: sid }]
const [settings, vrf, ...impacts] = await Promise.all([
read(settingsResource),
read("vrf"),
...[`${base}_filter`, `${base}_nat`, `${base}_mangle`, `${base}_raw`, "routing_rule"].map(resource => readRoutingPolicySnapshot(sid, resource, sourceProof)),
])
snapshots.push(settings, vrf, ...impacts)
if (input.hashPolicy) {
const key = ipv4 ? "ipv4-multipath-hash-policy" : "multipath-hash-policy"
if (!(key in (settings.rows[0] ?? {}))) throw new McpError("UNSUPPORTED_CAPABILITY", "Устройство не подтвердило выбранную hash policy", 409)
steps.push({ kind: "ros", step: { serverId: sid, resource: settingsResource, action: "update", fields: { [key]: String(input.hashPolicy) } } })
objects.push({ kind: settingsResource, id: "singleton", serverId: sid }, { kind: settingsResource, id: "new", serverId: sid })
}
await assertSource()
const healthChecks = routes.flatMap((row, index) => {
const hop = hops[index], name = hop?.address === null ? hop.interface : null
const interfaceId = name ? interfaceIds?.get(name) : undefined
const nextHopInterface = name && interfaceId ? { name, id: interfaceId } : undefined
return [{ serverId: sid, resource, id: String(row[".id"]), field: "resolved" as const, identity: { gateway: String(row.gateway), "immediate-gw": String(row["immediate-gw"]) }, nextHopInterface },
{ serverId: sid, resource, id: String(row[".id"]), field: "active" as const, phase: "after" as const }]
})
return { target: { scope: "servers", serverIds: [sid] }, objects, snapshots, steps, requiresHuman: true, healthChecks,
warnings: ["Равные стоимости проверяются как конфигурация. Распределение реального трафика требует отдельного наблюдения.", ipv4 ? "Проверьте NAT, FastTrack, маркировки и асимметрию. Настройка RPF сохраняется." : "Проверьте IPv6 NAT, маркировки, правила маршрутизации и асимметрию."] }
},
})
register("routing.rpf.update", "mm_rpf_plan", "Явное изменение IPv4 reverse path filter", "network", "mm:network:write", "write", "servers", z.object({ serverId, family: family.default("ipv4"), mode: z.enum(["no", "disabled", "loose", "strict"]), acknowledgeAsymmetry: z.literal(true) }).strict(), {
version: 4,
prepare: async input => {
if (input.family === "ipv6") throw new McpError("UNSUPPORTED_CAPABILITY", "RouterOS rp-filter доступен только для IPv4", 409)
const sid = Number(input.serverId)
const mode = input.mode === "disabled" ? "no" : String(input.mode)
const settings = await readRosSnapshot(sid, "ip_settings")
if (!("rp-filter" in (settings.rows[0] ?? {}))) throw new McpError("UNSUPPORTED_CAPABILITY", "RPF не подтверждён устройством", 409)
const { snapshots, warnings } = await readRpfDependencies(sid, mode, settings.sourceProof)
return { target: { scope: "servers", serverIds: [sid] }, objects: [{ kind: "server", id: String(sid), serverId: sid }, { kind: "ip_settings", id: "singleton", serverId: sid }, { kind: "ip_settings", id: "new", serverId: sid }], snapshots: [settings, ...snapshots], steps: [{ kind: "ros", step: { serverId: sid, resource: "ip_settings", action: "update", fields: { "rp-filter": mode } } }], requiresHuman: true, warnings }
},
})
register("routing.vrf.update", "mm_vrf_plan", "Проверенный состав существующего VRF", "network", "mm:network:write", "write", "servers", z.object({ serverId, id: objectId, interfaces: z.array(text).min(1).max(100).refine(values => new Set(values).size === values.length) }).strict(), {
version: 4,
prepare: async (input, context) => {
const sid = Number(input.serverId), { client } = await routerClient(sid), snapshot = await readRosSnapshot(sid, "vrf"), current = snapshot.rows.find(row => row[".id"] === input.id)
if (!current || current.name === "main" || !hasManagedCommentPrefix(String(current.comment ?? ""))) throw new McpError("PROTECTED_OBJECT", "Изменять состав можно только у существующего VRF приложения")
const interfaces = input.interfaces as string[], inventory = await client.get<ConfigurationRow[]>("/interface")
if (interfaces.some(name => name === "all" || !inventory.some(row => row.name === name))) throw new McpError("NOT_FOUND", "Интерфейс VRF отсутствует или указан selector all", 400)
if (snapshot.rows.some(row => row[".id"] !== input.id && row.name !== "main" && String(row.interfaces).split(",").some(name => interfaces.includes(name)))) throw new McpError("STATE_CONFLICT", "Интерфейс уже назначен другому VRF", 409)
const dependencies = await Promise.all(["ipv4_address", "ipv6_address", "routing_rule", "routing_table", "ospf_template", "gre", "vlan", "bridge_port"].map(resource => readRosSnapshot(sid, resource)))
const bindings = await pool.query("SELECT count(*)::int AS count FROM user_interface_bindings WHERE server_id=$1", [sid]).catch(() => { throw new McpError("STATE_UNAVAILABLE", "Не удалось проверить пользовательские зависимости", 503) })
const native = await (await import("./actions.js")).findAction("mm_vrf_update_plan").prepare!({ serverId: sid, id: String(input.id), productionAdmission: true, fields: { interfaces: interfaces.join(",") } }, context)
const affected = new Set([...interfaces, ...String(current.interfaces ?? "").split(",")])
return { ...native, objects: [...native.objects, ...inventory.filter(row => affected.has(String(row.name))).map(row => ({ kind: "interface", id: String(row[".id"]), serverId: sid }))], snapshots: dedupeVrfAliasSnapshots([snapshot, ...native.snapshots, ...dependencies]),
warnings: [...native.warnings, `Привязок пользователей на устройстве: ${bindings.rows[0].count}. Проверьте их назначение.`, "Порядок VRF сохраняется. Показаны снимки адресов, правил, OSPF и туннелей; route leaking не добавляется.", "Независимый management-канал и проверки из обоих VRF требуют подтверждения человеком."] }
},
})
}