200 lines
25 KiB
TypeScript
200 lines
25 KiB
TypeScript
import { assertNoCallerCreateId } from "./native-create-identity.js"
|
|
import { z } from "zod"
|
|
import { isIP } from "node:net"
|
|
import { eq } from "drizzle-orm"
|
|
import { db } from "../../db/index.js"
|
|
import { servers } from "../../db/schema.js"
|
|
import { MikrotikClient } from "../../services/mikrotik.js"
|
|
import { McpError, fingerprint, privateSecretFingerprint } from "./security.js"
|
|
import { readOspfEffectiveDisabled } from "../../services/ospf-effective-disabled.js"
|
|
import { greNativeFieldsSchema } from "../../services/gre-mutation-input.js"
|
|
import { canonicalVrfMembers } from "../../services/vrf-members.js"
|
|
|
|
const text = z.string().max(1000)
|
|
const name = z.string().trim().min(1).max(100)
|
|
const bool = z.boolean()
|
|
const integer = (min: number, max: number) => z.number().int().min(min).max(max)
|
|
const common = { name: name.optional(), comment: text.optional(), disabled: bool.optional() }
|
|
const ipv4 = name.refine(value => isIP(value) === 4, "Требуется IPv4 router/area ID")
|
|
const interval = z.string().regex(/^(?:[1-9]|[1-5][0-9]|60)s$/)
|
|
const firewall = {
|
|
chain: name.optional(), action: name.optional(), protocol: text.optional(), "src-address": text.optional(), "dst-address": text.optional(),
|
|
"src-port": text.optional(), "dst-port": text.optional(), "in-interface": text.optional(), "out-interface": text.optional(),
|
|
"src-address-list": text.optional(), "dst-address-list": text.optional(), "connection-state": text.optional(),
|
|
comment: text.optional(), disabled: bool.optional(), log: bool.optional(), "log-prefix": text.optional(),
|
|
}
|
|
const route = {
|
|
"dst-address": name.optional(), gateway: name.optional(), distance: integer(1, 255).optional(), scope: integer(0, 255).optional(),
|
|
"target-scope": integer(0, 255).optional(), "routing-table": name.optional(), "check-gateway": z.enum(["none", "ping", "arp", "bfd"]).optional(),
|
|
disabled: bool.optional(), comment: text.optional(),
|
|
}
|
|
export interface RosResourceSpec {
|
|
id: string; path: string; title: string; permission: string; fields: z.ZodObject;
|
|
create: boolean; remove: boolean; requiredOnCreate?: string[]; readOnly?: boolean
|
|
}
|
|
export const rosResources: RosResourceSpec[] = [
|
|
{ id: "routing_filter", path: "/routing/filter/rule", title: "Фильтры маршрутизации", permission: "mm:filters", fields: z.object({ chain: text.optional(), rule: text.optional(), comment: text.optional(), disabled: bool.optional() }).strict(), create: false, remove: false, readOnly: true },
|
|
{ id: "ethernet", path: "/interface/ethernet", title: "Ethernet", permission: "mm:network", fields: z.object({ ...common, mtu: integer(68, 65535).optional(), "auto-negotiation": bool.optional() }).strict(), create: false, remove: false },
|
|
{ id: "bridge", path: "/interface/bridge", title: "Bridge", permission: "mm:network", fields: z.object({ ...common, mtu: integer(68, 65535).optional(), "vlan-filtering": bool.optional(), pvid: integer(1, 4094).optional(), "protocol-mode": z.enum(["none", "stp", "rstp", "mstp"]).optional() }).strict(), create: true, remove: true, requiredOnCreate: ["name"] },
|
|
{ id: "bridge_port", path: "/interface/bridge/port", title: "Порты bridge", permission: "mm:network", fields: z.object({ bridge: name.optional(), interface: name.optional(), pvid: integer(1, 4094).optional(), "ingress-filtering": bool.optional(), "frame-types": z.enum(["admit-all", "admit-only-vlan-tagged", "admit-only-untagged-and-priority-tagged"]).optional(), disabled: bool.optional(), comment: text.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["bridge", "interface"] },
|
|
{ id: "bridge_vlan", path: "/interface/bridge/vlan", title: "Bridge VLAN membership", permission: "mm:network", fields: z.object({ bridge: name.optional(), "vlan-ids": name.optional(), tagged: text.optional(), untagged: text.optional(), disabled: bool.optional(), comment: text.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["bridge", "vlan-ids"] },
|
|
{ id: "vlan", path: "/interface/vlan", title: "VLAN", permission: "mm:network", fields: z.object({ ...common, interface: name.optional(), "vlan-id": integer(1, 4094).optional(), mtu: integer(68, 65535).optional(), "use-service-tag": bool.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["name", "interface", "vlan-id"] },
|
|
{ id: "ipv4_address", path: "/ip/address", title: "IPv4-адреса", permission: "mm:network", fields: z.object({ address: name.optional(), interface: name.optional(), network: name.optional(), comment: text.optional(), disabled: bool.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["address", "interface"] },
|
|
{ id: "ipv6_address", path: "/ipv6/address", title: "IPv6-адреса", permission: "mm:network", fields: z.object({ address: name.optional(), interface: name.optional(), advertise: bool.optional(), comment: text.optional(), disabled: bool.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["address", "interface"] },
|
|
{ id: "ipv4_route", path: "/ip/route", title: "Маршруты IPv4", permission: "mm:network", fields: z.object(route).strict(), create: true, remove: true, requiredOnCreate: ["dst-address"] },
|
|
{ id: "ipv6_route", path: "/ipv6/route", title: "Маршруты IPv6", permission: "mm:network", fields: z.object(route).strict(), create: true, remove: true, requiredOnCreate: ["dst-address"] },
|
|
{ id: "routing_table", path: "/routing/table", title: "Таблицы маршрутизации", permission: "mm:network", fields: z.object({ name: name.optional(), fib: bool.optional(), disabled: bool.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["name"] },
|
|
{ id: "routing_rule", path: "/routing/rule", title: "Правила маршрутизации", permission: "mm:network", fields: z.object({ "src-address": text.optional(), "dst-address": text.optional(), interface: text.optional(), action: z.enum(["lookup", "lookup-only-in-table", "drop", "unreachable"]).optional(), table: name.optional(), disabled: bool.optional(), comment: text.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["action"] },
|
|
{ id: "vrf", path: "/ip/vrf", title: "VRF", permission: "mm:network", fields: z.object({ name: name.optional(), interfaces: text.optional(), disabled: bool.optional(), comment: text.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["name", "interfaces"] },
|
|
{ id: "ip_settings", path: "/ip/settings", title: "RPF и IPv4 hash-policy", permission: "mm:network", fields: z.object({ "rp-filter": z.enum(["no", "loose", "strict"]).optional(), "ipv4-multipath-hash-policy": z.enum(["l3", "l4", "l3-inner"]).optional() }).strict(), create: false, remove: false },
|
|
{ id: "ipv6_settings", path: "/ipv6/settings", title: "IPv6 hash-policy", permission: "mm:network", fields: z.object({ "multipath-hash-policy": z.enum(["l3", "l4", "l3-inner"]).optional() }).strict(), create: false, remove: false },
|
|
...(["ip", "ipv6"] as const).flatMap(family => (["filter", "nat", "mangle", "raw"] as const).map(table => ({ id: `${family}_${table}`, path: `/${family}/firewall/${table}`, title: `${family} firewall ${table}`, permission: "mm:network", fields: z.object(firewall).strict(), create: true, remove: true, requiredOnCreate: ["chain", "action"] }))),
|
|
{ id: "gre", path: "/interface/gre", title: "GRE", permission: "mm:network", fields: greNativeFieldsSchema, create: true, remove: true, requiredOnCreate: ["name", "remote-address"] },
|
|
{ id: "wireguard", path: "/interface/wireguard", title: "WireGuard", permission: "mm:network", fields: z.object({ ...common, "listen-port": integer(1, 65535).optional(), mtu: integer(68, 65535).optional() }).strict(), create: true, remove: true, requiredOnCreate: ["name"] },
|
|
{ id: "wireguard_peer", path: "/interface/wireguard/peers", title: "WireGuard peers", permission: "mm:network", fields: z.object({ interface: name.optional(), "public-key": name.optional(), "allowed-address": text.optional(), "endpoint-address": text.optional(), "endpoint-port": integer(0, 65535).optional(), "persistent-keepalive": text.optional(), disabled: bool.optional(), comment: text.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["interface", "public-key", "allowed-address"] },
|
|
{ id: "vxlan", path: "/interface/vxlan", title: "VXLAN", permission: "mm:network", fields: z.object({ ...common, vni: integer(1, 16777215).optional(), port: integer(1, 65535).optional(), "local-address": text.optional(), interface: text.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["name", "vni"] },
|
|
{ id: "ospf_template", path: "/routing/ospf/interface-template", title: "OSPF templates", permission: "mm:network", fields: z.object({ interfaces: text.optional(), area: name.optional(), cost: integer(1, 65535).optional(), type: z.enum(["broadcast", "ptp", "ptmp", "ptp-unnumbered"]).optional(), passive: bool.optional(), "use-bfd": bool.optional(), "hello-interval": interval.optional(), "dead-interval": interval.optional(), disabled: bool.optional(), comment: text.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["area", "interfaces"] },
|
|
{ id: "ospf_neighbor", path: "/routing/ospf/neighbor", title: "Соседи OSPF", permission: "mm:network", fields: z.object({ "router-id": text.optional(), interface: text.optional(), state: text.optional(), address: text.optional(), area: text.optional(), instance: text.optional() }).strict(), create: false, remove: false, readOnly: true },
|
|
{ id: "ospf_instance", path: "/routing/ospf/instance", title: "OSPF instances", permission: "mm:network", fields: z.object({ ...common, "router-id": ipv4.optional(), version: z.enum(["2", "3"]).optional(), "routing-table": name.optional(), vrf: name.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["name", "router-id", "version", "vrf"] },
|
|
{ id: "ospf_area", path: "/routing/ospf/area", title: "OSPF areas", permission: "mm:network", fields: z.object({ ...common, instance: name.optional(), "area-id": ipv4.optional(), type: z.enum(["default", "stub", "nssa"]).optional() }).strict(), create: true, remove: true, requiredOnCreate: ["name", "instance", "area-id"] },
|
|
{ id: "bfd_configuration", path: "/routing/bfd/configuration", title: "Правила BFD", permission: "mm:network", fields: z.object({ interfaces: name.optional(), addresses: name.optional(), vrf: name.optional(), "forbid-bfd": bool.optional(), "min-rx": interval.optional(), "min-tx": interval.optional(), multiplier: integer(2, 50).optional(), disabled: bool.optional(), comment: text.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["interfaces", "addresses", "vrf"] },
|
|
{ id: "bfd_session", path: "/routing/bfd/session", title: "Наблюдаемые сессии BFD", permission: "mm:network", fields: z.object({ "remote-address": text.optional(), "local-address": text.optional(), vrf: text.optional(), interface: text.optional(), state: text.optional() }).strict(), create: false, remove: false, readOnly: true },
|
|
{ id: "bgp_session", path: "/routing/bgp/session", title: "BGP sessions", permission: "mm:bgp", fields: z.object({}).strict(), create: false, remove: false, readOnly: true },
|
|
{ id: "bgp_instance", path: "/routing/bgp/instance", title: "BGP instances", permission: "mm:bgp", fields: z.object({ ...common, as: integer(1, 4294967295).optional(), "router-id": ipv4.optional(), vrf: name.optional(), "routing-table": name.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["name", "as", "router-id", "vrf", "routing-table"] },
|
|
{ id: "bgp_connection", path: "/routing/bgp/connection", title: "BGP connections", permission: "mm:bgp", fields: z.object({ ...common, instance: name.optional(), "remote.address": name.refine(value => isIP(value) > 0, "Требуется конкретный адрес BGP-соседа").optional(), "local.address": name.refine(value => isIP(value) > 0).optional(), "remote.as": integer(1, 4294967295).optional(), "local.role": z.enum(["ibgp", "ibgp-rr", "ebgp", "ebgp-provider", "ebgp-customer", "ebgp-peer", "ebgp-rs", "ebgp-rs-client"]).optional(), connect: bool.optional(), listen: bool.optional(), afi: z.enum(["ip", "ipv6", "ip,ipv6"]).optional(), "use-bfd": bool.optional(), "routing-table": name.optional(), vrf: name.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["name", "instance", "remote.address", "remote.as", "local.address", "local.role"] },
|
|
{ id: "ipsec_peer", path: "/ip/ipsec/peer", title: "IPsec peers", permission: "mm:network", fields: z.object({ ...common, address: text.optional(), "exchange-mode": z.enum(["ike2", "main", "aggressive"]).optional(), profile: name.optional(), passive: bool.optional() }).strict(), create: true, remove: true, requiredOnCreate: ["name", "address"] },
|
|
{ id: "container", path: "/container", title: "Контейнеры", permission: "mm:network", fields: z.object({ comment: text.optional(), "start-on-boot": bool.optional() }).strict(), create: false, remove: true },
|
|
{ id: "certificate", path: "/certificate", title: "Сертификаты", permission: "mm:certificates", fields: z.object({ name: name.optional(), trusted: bool.optional() }).strict(), create: false, remove: true },
|
|
]
|
|
|
|
export type ConfigurationRow = Record<string, unknown>
|
|
export interface RosStep { serverId: number; resource: string; action: "create" | "update" | "delete"; id?: string; fields: Record<string, string>; placeBeforeId?: string; expectedCost?: number; expectedServerProof?: string }
|
|
export const ROS_SNAPSHOT_VERSION = 3
|
|
export interface RosSnapshot { serverId: number; resource: string; rows: ConfigurationRow[]; fingerprint: string; projectionVersion?: number; sourceProof?: string }
|
|
export function resourceById(id: string): RosResourceSpec {
|
|
const resource = rosResources.find(item => item.id === id)
|
|
if (!resource) throw new McpError("UNSUPPORTED_CAPABILITY", "Неизвестный тип сетевого объекта", 400)
|
|
return resource
|
|
}
|
|
export async function routerClient(serverId: number) {
|
|
const [server] = await db.select().from(servers).where(eq(servers.id, serverId)).limit(1)
|
|
if (!server || !server.enabled) throw new McpError("DEVICE_UNAVAILABLE", "Сервер отключён или не найден", 409)
|
|
return { server, client: MikrotikClient.fromServer(server) }
|
|
}
|
|
/** Preserve rule/member order and the observed OSPF template listing order.
|
|
* Sorting templates by ID would hide a native reordering from source proofs. */
|
|
export function isOrderedRosResource(resourceId: string): boolean {
|
|
return ["ip_filter", "ip_nat", "ip_mangle", "ip_raw", "ipv6_filter", "ipv6_nat", "ipv6_mangle", "ipv6_raw", "routing_rule", "vrf", "bfd_configuration", "ospf_template"].includes(resourceId)
|
|
}
|
|
export function configurationRows(resource: RosResourceSpec, payload: unknown): ConfigurationRow[] {
|
|
const rows = Array.isArray(payload) ? payload : payload && typeof payload === "object" ? [payload] : null
|
|
if (!rows || rows.some(row => !row || typeof row !== "object" || Array.isArray(row))) throw new McpError("STATE_CONFLICT", "Недостоверная конфигурация RouterOS", 409)
|
|
const fields = Object.keys(resource.fields.shape)
|
|
// RouterOS creates/removes IPv6 link-local rows with interface runtime state.
|
|
// They cannot be configured independently and must not invalidate a sealed
|
|
// inverse plan when its own GRE is disabled. Explicit addresses remain sealed.
|
|
const configuration = rows.filter(row => !(resource.id === "ipv6_address" && [true, "true"].includes((row as ConfigurationRow).dynamic as boolean | string) && /^fe80:/i.test(String((row as ConfigurationRow).address))))
|
|
const selectedRows = configuration.map(row => {
|
|
const selected = Object.fromEntries(Object.entries(row as ConfigurationRow).filter(([key]) => [".id", "dynamic", "builtin", "default", ...fields].includes(key)))
|
|
// RouterOS 7.24.5 returns an explicit empty fib flag for non-FIB tables.
|
|
// Normalize only this observed field; missing values must remain unknown.
|
|
if (resource.id === "routing_table" && selected.fib === "") selected.fib = "false"
|
|
if (resource.id === "vrf" && selected.interfaces !== undefined) selected.interfaces = canonicalVrfMembers(selected.interfaces)
|
|
return selected
|
|
})
|
|
// Reordering an ordered catalog changes configuration even with unchanged rows.
|
|
return isOrderedRosResource(resource.id) ? selectedRows : selectedRows.sort((a, b) => String(a[".id"] ?? a.name ?? "").localeCompare(String(b[".id"] ?? b.name ?? "")))
|
|
}
|
|
export async function readRosSnapshot(serverId: number, resourceId: string, beforeRead: () => Promise<unknown> = async () => {}, expectedSourceProof?: string): Promise<RosSnapshot> {
|
|
await beforeRead()
|
|
const resource = resourceById(resourceId), { client, server } = await routerClient(serverId)
|
|
const sourceProof = await rosSnapshotSourceProof(server)
|
|
if (expectedSourceProof !== undefined && expectedSourceProof !== sourceProof) throw new McpError("STATE_CONFLICT", "Источник сетевого снимка изменился до опроса", 409)
|
|
const checkSource = async () => {
|
|
await beforeRead()
|
|
if (await rosSnapshotSourceProof((await routerClient(serverId)).server) !== sourceProof) throw new McpError("STATE_CONFLICT", "Источник сетевого снимка изменился во время чтения", 409)
|
|
}
|
|
await checkSource()
|
|
const payload = resourceId === "ospf_template" ? await client.getOspfInterfaceTemplates(checkSource) : ["ospf_instance", "ospf_area", "bgp_instance", "bgp_connection"].includes(resourceId) ? await readOspfEffectiveDisabled(await client.get<unknown>(resource.path), async id => {
|
|
await checkSource()
|
|
return client.post(resource.path + "/get", { ".id": id, "value-name": "disabled" })
|
|
}) : await client.get<unknown>(resource.path)
|
|
await checkSource()
|
|
const rows = configurationRows(resource, payload)
|
|
return { serverId, resource: resourceId, rows, fingerprint: fingerprint(rows), projectionVersion: ROS_SNAPSHOT_VERSION, sourceProof }
|
|
}
|
|
export async function rosSnapshotSourceProof(server: Awaited<ReturnType<typeof routerClient>>["server"]): Promise<string> {
|
|
return privateSecretFingerprint(fingerprint({ id: server.id, enabled: server.enabled, host: server.host, port: server.port, useSsl: server.useSsl, verifySsl: server.verifySsl, username: server.username, password: server.password, epoch: server.routingEndpointEpoch }), "mcp-ros-snapshot-source-v3")
|
|
}
|
|
export function validateRosStep(resource: RosResourceSpec, action: RosStep["action"], fields: unknown, id?: string) {
|
|
assertNoCallerCreateId({ action, id })
|
|
if (["ip_settings", "ipv6_settings"].includes(resource.id) && id !== undefined) throw new McpError("INVALID_INPUT", "Настройки устройства не имеют RouterOS ID строки", 400)
|
|
if (resource.readOnly || action === "create" && !resource.create || action === "delete" && !resource.remove) throw new McpError("UNSUPPORTED_CAPABILITY", "Действие не поддерживается для объекта", 400)
|
|
if (action !== "create" && resource.id !== "ip_settings" && resource.id !== "ipv6_settings" && !id) throw new McpError("INVALID_INPUT", "Требуется стабильный RouterOS ID", 400)
|
|
const parsed = resource.fields.parse(fields ?? {}) as Record<string, unknown>
|
|
if (action === "create" && resource.requiredOnCreate?.some(field => parsed[field] === undefined)) throw new McpError("INVALID_INPUT", "Не заполнены обязательные поля создания", 400)
|
|
if (action !== "delete" && !Object.keys(parsed).length) throw new McpError("INVALID_INPUT", "Нет параметров для изменения", 400)
|
|
return Object.fromEntries(Object.entries(parsed).map(([key, value]) => [key, resource.id === "vrf" && key === "interfaces" ? canonicalVrfMembers(value) : String(value)]))
|
|
}
|
|
/** An unset is a separate native command and must have its own intent/checkpoint. */
|
|
export function splitOspfPassiveUpdate(step: RosStep): RosStep[] {
|
|
if (step.resource !== "ospf_template" || step.action !== "update" || step.fields.passive !== "false" || Object.keys(step.fields).length === 1) return [step]
|
|
const { passive: _passive, ...fields } = step.fields
|
|
return [{ ...step, fields }, { ...step, fields: { passive: "false" } }]
|
|
}
|
|
export async function executeRosStep(step: RosStep): Promise<unknown> {
|
|
assertNoCallerCreateId(step)
|
|
const resource = resourceById(step.resource)
|
|
// These resources require full private dependency proofs. Retired generic
|
|
// steps (including previously approved inverses) must not bypass the shared
|
|
// services by reaching the raw native dispatcher.
|
|
if (["ipsec_peer", "certificate"].includes(resource.id)) throw new McpError("UNSUPPORTED_CAPABILITY", "IPsec peer и сертификаты изменяются только через сервис с полным proof зависимостей; старый native шаг недоступен", 409)
|
|
if (resource.id === "vrf") throw new McpError("UNSUPPORTED_CAPABILITY", "VRF изменяется только через production companion executor; raw/legacy шаг недоступен", 409)
|
|
if (["ip_settings", "ipv6_settings"].includes(resource.id) && step.id !== undefined) throw new McpError("INVALID_INPUT", "Настройки устройства не имеют RouterOS ID строки", 400)
|
|
const { client, server } = await routerClient(step.serverId)
|
|
if (resource.id === "wireguard" && step.action === "delete") {
|
|
const { deleteWireguardInterfaceGuarded } = await import("../../services/wireguard-interface-delete.js")
|
|
const { assertVerifiedConfigurationAccess } = await import("../../services/config-mutation-policy.js")
|
|
return deleteWireguardInterfaceGuarded(server, step.id!, undefined, assertVerifiedConfigurationAccess, { cascade: false, recordRevision: false })
|
|
}
|
|
if (resource.id === "wireguard_peer" && step.action === "update") {
|
|
const { updateWireguardPeer, wireguardPeerNativeUpdate } = await import("../../services/wireguard-peer-update.js")
|
|
const { assertVerifiedConfigurationAccess } = await import("../../services/config-mutation-policy.js")
|
|
return updateWireguardPeer(server, step.id!, wireguardPeerNativeUpdate(step.fields), undefined, assertVerifiedConfigurationAccess, { recordRevision: false })
|
|
}
|
|
if (resource.id === "wireguard_peer" && step.action === "delete") {
|
|
const { deleteWireguardPeer } = await import("../../services/wireguard-peer-delete.js")
|
|
const { assertVerifiedConfigurationAccess } = await import("../../services/config-mutation-policy.js")
|
|
return deleteWireguardPeer(server, step.id!, undefined, assertVerifiedConfigurationAccess, { recordRevision: false })
|
|
}
|
|
if (resource.id === "ospf_template" && step.action === "update" && Object.keys(step.fields).length === 1 && step.fields.cost !== undefined && step.id) {
|
|
await client.setOspfInterfaceTemplateCost(step.id, Number(step.fields.cost), step.expectedCost, step.expectedServerProof)
|
|
return { cost: step.fields.cost, readBackVerified: true }
|
|
}
|
|
if(resource.id==="gre"&&(step.action==="delete"||step.action==="update"&&step.fields.name!==undefined)){
|
|
const {mutateGreNativeTunnel,greNativeMutationSchema}=await import("../../services/gre-native-mutation.js")
|
|
const fields=Object.fromEntries(Object.entries(step.fields).map(([key,value])=>[key,key==="disabled"?["true","yes"].includes(value)?true:["false","no"].includes(value)?false:value:key==="mtu"?Number(value):value]))
|
|
const mutation=greNativeMutationSchema.parse({operation:step.action,id:step.id,...(step.action==="update"?{fields}:{})})
|
|
const receipt=await mutateGreNativeTunnel(server,mutation,undefined,{recordRevision:false})
|
|
if(!receipt.verified)throw new McpError("VERIFICATION_FAILED","Native GRE изменение не подтверждено; повтор запрещён",409)
|
|
return receipt
|
|
}
|
|
if (resource.id === "container" && step.action === "delete") {
|
|
const { removeStoppedContainer } = await import("../../services/container-removal.js")
|
|
await removeStoppedContainer(client, step.id!)
|
|
return { deleted: true }
|
|
}
|
|
if (resource.id === "ospf_template" && step.action === "update" && step.fields.passive === "false") {
|
|
if (!step.id || Object.keys(step.fields).length !== 1) throw new McpError("INVALID_INPUT", "Снятие passive должно иметь отдельный шаг плана", 400)
|
|
return client.post(resource.path + "/unset", { ".id": step.id, "value-name": "passive" })
|
|
}
|
|
if (step.placeBeforeId && (resource.id !== "vrf" || step.action !== "create")) throw new McpError("INVALID_INPUT", "Порядок создания поддерживается только для VRF", 400)
|
|
if (step.action === "create") {
|
|
const fields = resource.id === "ospf_template" && step.fields.passive === "false" ? Object.fromEntries(Object.entries(step.fields).filter(([key]) => key !== "passive")) : step.fields
|
|
return client.put(resource.path, { ...fields, ...(step.placeBeforeId ? { "place-before": step.placeBeforeId } : {}) })
|
|
}
|
|
const destination = step.id ? `${resource.path}/${encodeURIComponent(step.id)}` : resource.path
|
|
if (step.action === "delete") return client.delete(destination)
|
|
return client.patch(destination, step.fields)
|
|
}
|