fix(api): отклонять несогласованные объекты IPsec и VXLAN

This commit is contained in:
Denozordec committed 2026-09-30 15:25:29 +07:00
1 parent 0e0f98e0f4
commit d112935c73
6 files changed
+31 -7

No files matched your search

+8 -1
View File
@@ -25,12 +25,19 @@
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.54 — IPsec/GRE/VXLAN, Firewall и операции Filters |
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.55 — IPsec/GRE/VXLAN, Firewall и операции Filters |
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 9.55 — согласованность ответов IPsec/VXLAN (2026-09-30)
- IPsec дополнительно отклоняет чужой serverId вложенных peer/pool/shared-mode-config, повторные peer/сертификаты и отказ с пустым serverId. VXLAN отклоняет повторные failures, одновременные tunnel/failure одного устройства, несогласованные arpMode/arpProxy и повторные ID inventory. Прежние схемы payload и контекст выборного IPsec ответа сохранены.
- Противоречивый ответ целиком считается ошибкой чтения и не заменяет последние подтверждённые строки; вложенный объект не переносится под другой сервер. Старые ответы без arpMode совместимы.
- 24 E2E прошли: расширенные повреждённые/чужие payload IPsec и регрессия ссылок/сбоев/гонок VXLAN. TypeScript и lint четырёх файлов чистые. Backend в этом блоке не менялся; последний backend build — 9.54, frontend — 9.52.
- Далее — ошибки общего GRE-каталога и его потребители, затем экраны VXLAN/сводная регрессия этапа 9. Реальные RouterOS/JWT/БД не проверены. Локальный коммит без push.
## Блок 9.54 — корректный фрагмент .rsc VXLAN (2026-09-30)
- Генератор вынесен в lib/vxlan-export.ts. Строки RouterOS заключены в кавычки; кавычка/обратный слеш/dollar и управляющие символы экранируются. Заголовки не содержат переводов строки из имени/metadata. Числовые параметры идут из валидированной модели.
+7
View File
@@ -300,3 +300,10 @@ Live-копирование читает актуальный целевой rul
- 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.55 — согласованность ответов IPsec/VXLAN (2026-09-30)
- IPsec дополнительно отклоняет чужой serverId вложенных peer/pool/shared-mode-config, повторные peer/сертификаты и отказ с пустым serverId. VXLAN отклоняет повторные failures, одновременные tunnel/failure одного устройства, несогласованные arpMode/arpProxy и повторные ID inventory. Прежние схемы payload и контекст выборного IPsec ответа сохранены.
- Противоречивый ответ целиком считается ошибкой чтения и не заменяет последние подтверждённые строки; вложенный объект не переносится под другой сервер. Старые ответы без arpMode совместимы.
- 24 E2E прошли: расширенные повреждённые/чужие payload IPsec и регрессия ссылок/сбоев/гонок VXLAN. TypeScript и lint четырёх файлов чистые. Backend в этом блоке не менялся; последний backend build — 9.54, frontend — 9.52.
- Далее — ошибки общего GRE-каталога и его потребители, затем экраны VXLAN/сводная регрессия этапа 9. Реальные RouterOS/JWT/БД не проверены. Локальный коммит без push.
+4 -4
View File
@@ -8,13 +8,13 @@ export const vxlanResponseSchema = z.object({
vtepIp: z.string(), remoteVteps: z.array(z.string()), l2mtu: z.number().int().nonnegative(),
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([]),
})
})).refine(rows => new Set(rows.map(row => JSON.stringify([row.serverId, row.id]))).size === rows.length && rows.every(row => row.arpMode === undefined || row.arpProxy === (row.arpMode === "proxy-arp" || row.arpMode === "local-proxy-arp"))),
failures: z.array(z.object({ serverId: z.string().trim().min(1), serverName: z.string().optional(), error: z.string() })).default([]),
}).refine(data => new Set(data.failures.map(f => f.serverId)).size === data.failures.length && !data.tunnels.some(t => data.failures.some(f => f.serverId === t.serverId)))
export const vxlanServersSchema = z.array(z.object({
id: z.number().int().positive(), name: z.string(), host: z.string(), country: z.string(), enabled: z.boolean(),
type: z.enum(["jump-host", "exit-node", "home-router"]).optional(), site: z.string().optional(),
asn: z.string().optional(), status: z.enum(["online", "offline", "degraded"]).optional(),
latency: z.number().finite().nullable().optional(),
}))
})).refine(rows => new Set(rows.map(row => row.id)).size === rows.length)
+5 -1
View File
@@ -26,7 +26,11 @@ export async function listIpsec(
const clientIds = data.clients.map(c => c.id)
const failedIds = (data.failures ?? []).map(f => f.serverId)
if (new Set(serverIds).size !== serverIds.length || new Set(clientIds).size !== clientIds.length ||
new Set(failedIds).size !== failedIds.length || serverIds.some(id => failedIds.includes(id)) ||
new Set(failedIds).size !== failedIds.length || failedIds.some(id => !id.trim()) ||
data.servers.some(s => s.peers.some(p => p.serverId !== s.serverId) ||
new Set(s.peers.map(p => p.id)).size !== s.peers.length ||
[s.peer, s.pool, s.sharedModeConfig].some(child => child && child.serverId !== s.serverId) ||
new Set((s.certs ?? []).map(c => c.name)).size !== (s.certs ?? []).length) || serverIds.some(id => failedIds.includes(id)) ||
data.clients.some(c => failedIds.includes(c.serverId)) ||
(opts?.serverId && [...serverIds, ...failedIds, ...data.clients.map(c => c.serverId)].some(id => id !== opts.serverId))) {
throw new Error("Несогласованный ответ загрузки IPsec")
+4 -1
View File
@@ -5,7 +5,7 @@ const client = (id: string, name = "Клиент " + id) => ({ id: id + "-*1", r
const servers = [1, 2].map(id => ({ id, name: "Роутер " + id, host: "192.0.2." + id, enabled: true, country: "RU", type: "exit-node", status: "online" }))
const full = { servers: [summary("1"), summary("2")], clients: [client("1"), client("2")] }
for (const broken of ["http", "payload", "duplicate", "conflict"]) {
for (const broken of ["http", "payload", "duplicate", "conflict", "peer-context", "pool-context", "blank-failure"]) {
test(`IPsec: неизвестное начало и сохранение данных при ${broken}`, async ({ page }) => {
let phase = 0
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
@@ -17,6 +17,9 @@ for (const broken of ["http", "payload", "duplicate", "conflict"]) {
return broken === "http" ? r.fulfill({ status: 503, json: { error: "Повторный отказ IPsec" } })
: r.fulfill({ json: broken === "payload" ? { servers: [], clients: [{}] }
: broken === "duplicate" ? { ...full, clients: [client("1"), client("1")] }
: broken === "peer-context" ? { ...full, servers: [{ ...summary("1"), peers: [{ id: "1-*p1", rosId: "*p1", serverId: "2", serverName: "Чужой роутер", name: "peer", managed: true, disabled: false }] }, summary("2")] }
: broken === "pool-context" ? { ...full, servers: [{ ...summary("1"), pool: { id: "1-*p1", rosId: "*p1", serverId: "2", name: "pool", ranges: "10.0.0.1-10.0.0.20", managed: true } }, summary("2")] }
: broken === "blank-failure" ? { servers: [], clients: [], failures: [{ serverId: "", error: "Неизвестный отказ" }] }
: { ...full, failures: [{ serverId: "1", error: "Сбой устройства" }] } })
}
return r.fulfill({ json: path === "/api/servers" ? servers : path === "/api/auth/config" ? { required: false } : {} })
+3
View File
@@ -171,6 +171,9 @@ test("VXLAN: повреждённые записи не заменяют пос
{ tunnels: [{ ...valid, status: "unknown" }] },
{ tunnels: [valid, valid] },
{ tunnels: [], failures: [{ serverId: "1", error: null }] },
{ tunnels: [valid], failures: [{ serverId: "1", error: "Конфликт" }] },
{ tunnels: [], failures: [{ serverId: "1", error: "Сбой" }, { serverId: "1", error: "Повтор" }] },
{ tunnels: [{ ...valid, arpMode: "enabled", arpProxy: true }] },
]) {
control.payload = payload
await page.getByRole("button", { name: "Обновить", exact: true }).click()