262 lines
30 KiB
TypeScript
262 lines
30 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 { readRosSnapshot, routerClient, type ConfigurationRow } from "./resources.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, 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 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 entries = await Promise.all(Object.entries(preflightPaths(selectedFamily)).map(async ([key, path]) => {
|
|
try { return [key, preflightObservation(await client.get<unknown>(path), key === "settings" || key === "connectionTracking")] as const }
|
|
catch { return [key, { rows: [], error: "Устройство не подтвердило чтение ресурса" }] as const }
|
|
}))
|
|
return { serverId: input.serverId, observedAt: new Date().toISOString(), ...analyzeEcmpPreflight(selectedFamily, input.routeIds as string[], Object.fromEntries(entries) as Record<PreflightSource, PreflightObservation>) }
|
|
},
|
|
})
|
|
const 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: 2,
|
|
prepare: async input => {
|
|
const sid = Number(input.serverId), resource = input.family === "ipv4" ? "ipv4_route" : "ipv6_route"
|
|
const { client } = await routerClient(sid), snapshot = await readRosSnapshot(sid, resource), ids = input.routeIds as string[]
|
|
const raw = await client.get<ConfigurationRow[]>(input.family === "ipv4" ? "/ip/route" : "/ipv6/route")
|
|
const rows = ids.map(id => raw.find(row => row[".id"] === id))
|
|
if (rows.some(row => !row || bool(row.dynamic) || bool(row.disabled) || bool(row.invalid) || !hasManagedCommentPrefix(String(row.comment ?? "")))) throw new McpError("PROTECTED_OBJECT", "ECMP требует разрешаемые статические маршруты приложения")
|
|
const routes = rows as ConfigurationRow[], first = routes[0]
|
|
if (routes.some(row => row["dst-address"] !== first["dst-address"] || String(row["routing-table"] ?? "main") !== String(first["routing-table"] ?? "main"))) throw new McpError("STATE_CONFLICT", "ECMP требует одинаковые prefix и routing table", 409)
|
|
if (new Set(routes.map(row => String(row.gateway))).size !== ids.length || routes.some(row => !row.gateway || !row["immediate-gw"])) throw new McpError("MEASUREMENT_UNKNOWN", "Нужны разные подтверждённые next-hop и immediate gateway", 409)
|
|
const steps: ChangeEvidence["steps"] = ids.map(id => ({ kind: "ros", step: { serverId: sid, resource, id, action: "update", fields: { distance: String(input.distance) } } }))
|
|
const snapshots = [snapshot]
|
|
const tableSnapshot = await readRosSnapshot(sid, "routing_table"), table = tableSnapshot.rows.find(row => row.name === String(first["routing-table"] ?? "main"))
|
|
if (!table || typeof table[".id"] !== "string" || bool(table.disabled)) throw new McpError("STATE_CONFLICT", "Таблица ECMP отсутствует или отключена", 409)
|
|
snapshots.push(tableSnapshot)
|
|
const objects: ChangeEvidence["objects"] = [...ids.map(id => ({ kind: resource, id, serverId: sid })), { kind: "routing_table", id: table[".id"], serverId: sid }]
|
|
if (input.hashPolicy) {
|
|
const settingsResource = input.family === "ipv4" ? "ip_settings" : "ipv6_settings", key = input.family === "ipv4" ? "ipv4-multipath-hash-policy" : "multipath-hash-policy"
|
|
const settings = await readRosSnapshot(sid, settingsResource)
|
|
if (!(key in (settings.rows[0] ?? {}))) throw new McpError("UNSUPPORTED_CAPABILITY", "Устройство не подтвердило выбранную hash policy", 409)
|
|
snapshots.push(settings); steps.push({ kind: "ros", step: { serverId: sid, resource: settingsResource, action: "update", fields: { [key]: String(input.hashPolicy) } } })
|
|
objects.push({ kind: settingsResource, id: "singleton", serverId: sid }, { kind: settingsResource, id: "new", serverId: sid })
|
|
}
|
|
const impacts = await Promise.all(["ip_settings", "ip_filter", "ip_nat", "ip_mangle"].map(resource => readRosSnapshot(sid, resource)))
|
|
snapshots.push(...impacts)
|
|
return { target: { scope: "servers", serverIds: [sid] }, objects, snapshots, steps, requiresHuman: true,
|
|
healthChecks: routes.flatMap(row => [{ serverId: sid, resource, id: String(row[".id"]), field: "resolved" as const, identity: { gateway: String(row.gateway), "immediate-gw": String(row["immediate-gw"]) } }, { serverId: sid, resource, id: String(row[".id"]), field: "active" as const, phase: "after" as const }]),
|
|
warnings: ["Равные стоимости проверяются как конфигурация. Распределение реального трафика требует отдельного наблюдения.", "Проверьте NAT, FastTrack, маркировки и асимметрию; RPF автоматически не меняется."] }
|
|
},
|
|
})
|
|
register("routing.rpf.update", "mm_rpf_plan", "Явное изменение IPv4 reverse path filter", "network", "mm:network:write", "write", "servers", z.object({ serverId, mode: z.enum(["no", "loose", "strict"]), acknowledgeAsymmetry: z.literal(true) }).strict(), {
|
|
version: 2,
|
|
prepare: async input => {
|
|
const sid = Number(input.serverId), snapshots = await Promise.all(["ip_settings", "routing_table", "vrf", "ipv4_route"].map(resource => readRosSnapshot(sid, resource)))
|
|
if (!("rp-filter" in (snapshots[0].rows[0] ?? {}))) throw new McpError("UNSUPPORTED_CAPABILITY", "RPF не подтверждён устройством", 409)
|
|
if (input.mode === "strict" && (snapshots[1].rows.some(row => row.name !== "main") || snapshots[2].rows.some(row => row.name !== "main"))) throw new McpError("STATE_CONFLICT", "Strict RPF не допускается этим планом при нескольких таблицах или VRF", 409)
|
|
return { target: { scope: "servers", serverIds: [sid] }, objects: [{ kind: "server", id: String(sid), serverId: sid }, { kind: "ip_settings", id: "singleton", serverId: sid }, { kind: "ip_settings", id: "new", serverId: sid }], snapshots, steps: [{ kind: "ros", step: { serverId: sid, resource: "ip_settings", action: "update", fields: { "rp-filter": String(input.mode) } } }], requiresHuman: true,
|
|
warnings: ["RPF является IPv4-настройкой. Изменение может затронуть асимметричный трафик; снижение защиты требует компенсирующих правил."] }
|
|
},
|
|
})
|
|
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,
|
|
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 требуют подтверждения человеком."] }
|
|
},
|
|
})
|
|
}
|