diff --git a/app/(main)/vxlan/page.tsx b/app/(main)/vxlan/page.tsx index 32f68738..60467b34 100644 --- a/app/(main)/vxlan/page.tsx +++ b/app/(main)/vxlan/page.tsx @@ -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 { - 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, }: { diff --git a/backend/src/services/vxlan-live.test.ts b/backend/src/services/vxlan-live.test.ts index 4edca89e..ce5b8132 100644 --- a/backend/src/services/vxlan-live.test.ts +++ b/backend/src/services/vxlan-live.test.ts @@ -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) diff --git a/backend/src/services/vxlan-live.ts b/backend/src/services/vxlan-live.ts index b50ab823..f14deb40 100644 --- a/backend/src/services/vxlan-live.ts +++ b/backend/src/services/vxlan-live.ts @@ -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", diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 5bf6f3f2..96049bf6 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -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-операции в этом блоке не вводились. diff --git a/docs/ux-ui-stage-9.md b/docs/ux-ui-stage-9.md index c4db18d7..5295aa7c 100644 --- a/docs/ux-ui-stage-9.md +++ b/docs/ux-ui-stage-9.md @@ -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. diff --git a/lib/data.ts b/lib/data.ts index ffc646bc..dfa9bdf4 100644 --- a/lib/data.ts +++ b/lib/data.ts @@ -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 diff --git a/lib/vxlan-export.test.ts b/lib/vxlan-export.test.ts new file mode 100644 index 00000000..8dcf07eb --- /dev/null +++ b/lib/vxlan-export.test.ts @@ -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") diff --git a/lib/vxlan-export.ts b/lib/vxlan-export.ts new file mode 100644 index 00000000..da7a0c9f --- /dev/null +++ b/lib/vxlan-export.ts @@ -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 { + 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" +} diff --git a/lib/vxlan-response.ts b/lib/vxlan-response.ts index 5215af59..52890b12 100644 --- a/lib/vxlan-response.ts +++ b/lib/vxlan-response.ts @@ -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([]),