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 -34
View File
@@ -20,6 +20,7 @@ import {
} from "lucide-react"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
import { useDataSource } from "@/lib/data-source"
import { generateVxlanRsc } from "@/lib/vxlan-export"
import { vxlanResponseSchema, vxlanServersSchema } from "@/lib/vxlan-response"
import { requestJson } from "@/shared/api/http-client"
import { cn } from "@/lib/utils"
@@ -62,40 +63,6 @@ function mapBackendServer(s: BackendServer): Server {
}
}
function generateVxlanRsc(t: VxlanTunnel, serverById: Record<string, Server>): string {
const srv = serverById[t.serverId]
const lines: string[] = []
lines.push(`# VXLAN — ${t.name} · VNI ${t.vni}`)
if (srv) lines.push(`# Сервер: ${srv.name} (${srv.host})`)
lines.push(`# RouterOS 7.x · /interface/vxlan`)
lines.push(``)
lines.push(`/interface/vxlan/add \\`)
lines.push(` name=${t.name} \\`)
lines.push(` vni=${t.vni} \\`)
lines.push(` port=${t.dstPort} \\`)
lines.push(` vtep-mac-address=auto \\`)
lines.push(` arp-proxy=${t.arpProxy ? "yes" : "no"} \\`)
lines.push(` mac-learning=${t.macLearning ? "yes" : "no"} \\`)
lines.push(` l2mtu=${t.l2mtu} \\`)
if (t.comment) lines.push(` comment="${t.comment}" \\`)
if (!t.enabled) lines.push(` disabled=yes \\`)
lines.push(``)
for (const vtep of t.remoteVteps) {
lines.push(`/interface/vxlan/vteps/add \\`)
lines.push(` interface=${t.name} \\`)
lines.push(` remote-ip=${vtep}`)
lines.push(``)
}
lines.push(`# Добавить в bridge:`)
lines.push(`/interface/bridge/port/add \\`)
lines.push(` bridge=bridge-overlay \\`)
lines.push(` interface=${t.name}`)
return lines.join("\n")
}
function ExportSheet({
open, tunnel, onClose, serverById, receivedAt, stale, mock,
}: {
+10
View File
@@ -54,3 +54,13 @@ const failed = await pollVxlanServers([server], async () => { throw new Error("a
assert.equal(failed.tunnels.length, 0)
assert.equal(failed.failures.length, 1)
console.log("vxlan polling: mixed/empty/all-failed ok")
for (const arp of ["disabled", "enabled", "local-proxy-arp", "proxy-arp", "reply-only"] as const) {
const mapped = mapVxlanRow(server, { ".id": "*4", name: "vx", arp, learning: "no" }, [], 0)
assert.equal(mapped.arpMode, arp)
assert.equal(mapped.arpProxy, arp === "proxy-arp" || arp === "local-proxy-arp")
assert.equal(mapped.macLearning, false)
}
console.log("vxlan: exact ARP modes and RouterOS no-learning passed")
assert.equal(mapVxlanRow(server, { arp: "enabled", "arp-proxy": "true" }, [], 0).arpProxy, false)
+5 -2
View File
@@ -40,6 +40,7 @@ export type VxlanTunnelLive = {
remoteVteps: string[]
l2mtu: number
arpProxy: boolean
arpMode: "disabled" | "enabled" | "local-proxy-arp" | "proxy-arp" | "reply-only"
macLearning: boolean
comment: string
enabled: boolean
@@ -66,6 +67,7 @@ export function mapVxlanRow(
const disabled = rosYes(vx.disabled)
const running = rosYes(vx.running)
const port = parseIntSafe(vx.port, 8472)
const arpMode: VxlanTunnelLive["arpMode"] = (["disabled", "enabled", "local-proxy-arp", "proxy-arp", "reply-only"] as const).find(mode => mode === vx.arp) ?? (rosYes(vx["arp-proxy"]) ? "proxy-arp" : "enabled")
const remoteVteps = vteps
.filter((v) => (v.interface ?? "").trim() === name)
.map((v) => (v["remote-ip"] ?? "").trim())
@@ -81,8 +83,9 @@ export function mapVxlanRow(
vtepIp: (vx["local-address"] ?? vx["vtep-address"] ?? "").trim(),
remoteVteps,
l2mtu: parseIntSafe(vx.l2mtu, 1500),
arpProxy: rosYes(vx["arp-proxy"]) || vx.arp === "proxy-arp" || vx.arp === "enabled",
macLearning: vx["mac-learning"] != null ? rosYes(vx["mac-learning"]) : vx.learning !== "false",
arpProxy: arpMode === "proxy-arp" || arpMode === "local-proxy-arp",
arpMode,
macLearning: vx["mac-learning"] != null ? rosYes(vx["mac-learning"]) : vx.learning != null ? rosYes(vx.learning) : true,
comment: vx.comment ?? "",
enabled: !disabled,
status: !disabled && running ? "up" : "down",
+9 -1
View File
@@ -25,12 +25,20 @@
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.53 — IPsec/GRE/VXLAN, Firewall и операции Filters |
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.54 — IPsec/GRE/VXLAN, Firewall и операции Filters |
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 9.54 — корректный фрагмент .rsc VXLAN (2026-09-30)
- Генератор вынесен в lib/vxlan-export.ts. Строки RouterOS заключены в кавычки; кавычка/обратный слеш/dollar и управляющие символы экранируются. Заголовки не содержат переводов строки из имени/metadata. Числовые параметры идут из валидированной модели.
- По документации MikroTik используются port, learning, arp и доступный local-address. Удалены неподдерживаемые vtep-mac-address/arp-proxy/mac-learning, read-only l2mtu и выдуманная команда привязки к bridge. Это фрагмент интерфейса/статических VTEP; bridge/FDB/прочие параметры, которых нет в API, не включены. Импорт на RouterOS ещё не проверен.
- Backend возвращает точный arpMode, обычный arp=enabled больше не означает proxy ARP. Обрабатывается learning=no. Точный arp приоритетен над legacy arp-proxy. Новое поле optional на frontend для совместимости прежних ответов; при его отсутствии старый boolean может описать только proxy-arp/обычный enabled.
- Unit генератора проверяет сложные строки и отсутствие посторонних команд/недоступных параметров; backend unit проверяет все ARP modes и частичный опрос. TypeScript frontend/backend, frontend lint, 2 E2E экспорта/свежести и backend build прошли. Последний frontend build — 9.52.
- Источники: [VXLAN](https://help.mikrotik.com/docs/spaces/ROS/pages/100007937/VXLAN), [строковые литералы RouterOS](https://help.mikrotik.com/docs/spaces/ROS/pages/47579229/Scripting). Далее — согласованность ответа VXLAN, экраны и общая проверка, затем GRE-каталог. Локальный коммит без push.
## Блок 9.53 — достоверность VXLAN и режим просмотра (2026-09-30)
- Backend VXLAN предоставляет GET; удалены активные кнопки без обработчиков (создание/редактирование/включение/удаление). Экран явно отмечен «Только просмотр»; доступны опрос и экспорт .rsc. Новые сетевые write-операции в этом блоке не вводились.
+8
View File
@@ -292,3 +292,11 @@ Live-копирование читает актуальный целевой rul
- Экспорт фиксирует строку, metadata сервера, дату и признак неизвестной актуальности на момент открытия; обновление таблицы не меняет открытый код. Mock-экспорт подписан отдельно. Смена backend/mode отделяет состояние, панели и даты; URL-контекст сохранён.
- После последних изменений прошли 17 обычных E2E VXLAN и 4 отдельных E2E смены источника. TypeScript и lint чистые. Backend не менялся; последний frontend build — 9.52, backend — 9.44.
- Далее — точность/экранирование .rsc VXLAN и валидация согласованности ответа, затем ошибки общего GRE-каталога и общая регрессия этапа 9. Реальная RouterOS-приёмка/полные права/DB/JWT не выполнены. Локальный коммит без push.
## Блок 9.54 — корректный фрагмент .rsc VXLAN (2026-09-30)
- Генератор вынесен в lib/vxlan-export.ts. Строки RouterOS заключены в кавычки; кавычка/обратный слеш/dollar и управляющие символы экранируются. Заголовки не содержат переводов строки из имени/metadata. Числовые параметры идут из валидированной модели.
- По документации MikroTik используются port, learning, arp и доступный local-address. Удалены неподдерживаемые vtep-mac-address/arp-proxy/mac-learning, read-only l2mtu и выдуманная команда привязки к bridge. Это фрагмент интерфейса/статических VTEP; bridge/FDB/прочие параметры, которых нет в API, не включены. Импорт на RouterOS ещё не проверен.
- Backend возвращает точный arpMode, обычный arp=enabled больше не означает proxy ARP. Обрабатывается learning=no. Точный arp приоритетен над legacy arp-proxy. Новое поле optional на frontend для совместимости прежних ответов; при его отсутствии старый boolean может описать только proxy-arp/обычный enabled.
- Unit генератора проверяет сложные строки и отсутствие посторонних команд/недоступных параметров; backend unit проверяет все ARP modes и частичный опрос. TypeScript frontend/backend, frontend lint, 2 E2E экспорта/свежести и backend build прошли. Последний frontend build — 9.52.
- Источники: [VXLAN](https://help.mikrotik.com/docs/spaces/ROS/pages/100007937/VXLAN), [строковые литералы RouterOS](https://help.mikrotik.com/docs/spaces/ROS/pages/47579229/Scripting). Далее — согласованность ответа VXLAN, экраны и общая проверка, затем GRE-каталог. Локальный коммит без push.
+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([]),