fix(vxlan): корректно экспортировать параметры RouterOS

This commit is contained in:
Denozordec committed 2026-09-30 15:21:55 +07:00
1 parent d200e87e3d
commit 0e0f98e0f4
9 files changed
+82 -38

No files matched your search

+1
View File
@@ -213,6 +213,7 @@ export interface VxlanTunnel {
remoteVteps: string[] // remote VTEP IPs (unicast-flood)
l2mtu: number
arpProxy: boolean
arpMode?: "disabled" | "enabled" | "local-proxy-arp" | "proxy-arp" | "reply-only"
macLearning: boolean
comment: string
enabled: boolean
+18
View File
@@ -0,0 +1,18 @@
import assert from "node:assert/strict"
import { generateVxlanRsc, quoteRosString } from "./vxlan-export"
import type { VxlanTunnel } from "./data"
assert.equal(quoteRosString('edge "one"\\$value\r\n\t'), '"edge \\"one\\"\\\\\\$value\\0D\\0A\\09"')
const tunnel: VxlanTunnel = { id: "1-*1", serverId: "1", name: 'vx "edge" $name\n/system reboot', vni: 200, port: 0, dstPort: 4789, vtepIp: "192.0.2.1", remoteVteps: ["198.51.100.1"], l2mtu: 1500, arpProxy: false, arpMode: "reply-only", macLearning: false, comment: 'note "quoted"\\$value\n/system reboot', enabled: false, status: "down" }
const code = generateVxlanRsc(tunnel, {})
const commands = code.split("\n").filter(line => line.startsWith("/"))
assert.equal(commands.length, 2)
assert.match(commands[0], /vni=200 port=4789 arp="reply-only" learning=no disabled=yes/)
assert.ok(commands[0].includes('local-address="192.0.2.1"'))
assert.ok(commands[0].includes('comment="note \\"quoted\\"\\\\\\$value\\0A/system reboot"'))
assert.ok(commands[1].includes('interface="vx \\"edge\\" \\$name\\0A/system reboot" remote-ip="198.51.100.1"'))
for (const unsupported of ["vtep-mac-address", "arp-proxy=", "mac-learning=", "l2mtu=", "/interface/bridge/port/add", "\n/system reboot"]) assert.ok(!code.includes(unsupported), unsupported)
const noLocal = generateVxlanRsc({ ...tunnel, vtepIp: "", arpMode: undefined, arpProxy: true }, {})
assert.ok(!noLocal.includes("local-address="))
assert.ok(noLocal.includes('arp="proxy-arp"'))
console.log("vxlan-export: quoted strings, supported properties and no invented bridge passed")
+29
View File
@@ -0,0 +1,29 @@
import type { Server, VxlanTunnel } from "./data"
/** RouterOS string literals: https://help.mikrotik.com/docs/spaces/ROS/pages/47579229/Scripting */
export function quoteRosString(value: string): string {
return '"' + value.replace(/[\\"$\x00-\x1f\x7f]/g, char => {
if (char === "\\" || char === '"' || char === "$") return "\\" + char
return "\\" + char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")
}) + '"'
}
const commentLine = (value: string) => value.replace(/[\r\n\x00-\x1f\x7f]/g, " ")
/** A configuration fragment, not a full RouterOS export: bridge/FDB are not available in this API. */
export function generateVxlanRsc(t: VxlanTunnel, servers: Record<string, Server>): string {
const server = servers[t.serverId]
const lines = [`# VXLAN — ${commentLine(t.name)} · VNI ${t.vni}`]
if (server) lines.push(`# Сервер: ${commentLine(server.name)} (${commentLine(server.host)})`)
lines.push("# RouterOS 7.x · интерфейс и статические VTEP; bridge/FDB не включены", "")
const args = [
`name=${quoteRosString(t.name)}`, `vni=${t.vni}`, `port=${t.dstPort}`,
`arp=${quoteRosString(t.arpMode ?? (t.arpProxy ? "proxy-arp" : "enabled"))}`,
`learning=${t.macLearning ? "yes" : "no"}`, `disabled=${t.enabled ? "no" : "yes"}`,
`comment=${quoteRosString(t.comment)}`,
]
if (t.vtepIp) args.push(`local-address=${quoteRosString(t.vtepIp)}`)
lines.push("/interface/vxlan/add " + args.join(" "))
for (const remote of t.remoteVteps) lines.push(`/interface/vxlan/vteps/add interface=${quoteRosString(t.name)} remote-ip=${quoteRosString(remote)}`)
return lines.join("\n") + "\n"
}
+1 -1
View File
@@ -6,7 +6,7 @@ export const vxlanResponseSchema = z.object({
vni: z.number().int().min(0).max(16777215),
port: z.number().int().min(0).max(65535), dstPort: z.number().int().min(1).max(65535),
vtepIp: z.string(), remoteVteps: z.array(z.string()), l2mtu: z.number().int().nonnegative(),
arpProxy: z.boolean(), macLearning: z.boolean(), comment: z.string(), enabled: z.boolean(),
arpProxy: z.boolean(), arpMode: z.enum(["disabled", "enabled", "local-proxy-arp", "proxy-arp", "reply-only"]).optional(), macLearning: z.boolean(), comment: z.string(), enabled: z.boolean(),
status: z.enum(["up", "down"]),
})).refine(rows => new Set(rows.map(row => JSON.stringify([row.serverId, row.id]))).size === rows.length),
failures: z.array(z.object({ serverId: z.string().min(1), serverName: z.string().optional(), error: z.string() })).default([]),