344 lines
35 KiB
TypeScript
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 требуют подтверждения человеком."] }
|
|
},
|
|
})
|
|
}
|