diff --git a/backend/package.json b/backend/package.json index 8c00e540..5a7e5da5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,7 +18,7 @@ "test:probes": "tsx src/routes/probes.test.ts && tsx src/routes/probes.cancel.test.ts", "test:exec": "tsx src/routes/exec.test.ts && tsx src/plugins/auth.exec.test.ts", "test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts && tsx src/plugins/auth.ospf-optimizer.test.ts", - "test:wireguard": "npx tsx src/services/wireguard-config.test.ts", + "test:wireguard": "npx tsx src/services/wireguard-config.test.ts && npx tsx --test src/services/wireguard-import.test.ts && npx tsx src/routes/wireguard-import.test.ts", "test:traffic-rate": "tsx src/services/traffic-rate.test.ts", "test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-ifindex.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-dest.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/services/traffic-flow-facts.test.ts && tsx src/services/traffic-flow-facts-filter.test.ts && tsx src/services/traffic-flow-facts-rebuild.test.ts && tsx src/services/statistics-aggregate.test.ts", "test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts && tsx src/modules/users/interface-inventory.test.ts && tsx src/routes/users-catalog.test.ts", diff --git a/backend/src/routes/wireguard-import.test.ts b/backend/src/routes/wireguard-import.test.ts new file mode 100644 index 00000000..6dde2c2e --- /dev/null +++ b/backend/src/routes/wireguard-import.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict" +import Fastify from "fastify" +import { validatorCompiler, serializerCompiler } from "@fastify/type-provider-zod" +import wireguardRoutes from "./wireguard.js" +import { MikrotikClient } from "../services/mikrotik.js" + +const app = Fastify() +app.setValidatorCompiler(validatorCompiler) +app.setSerializerCompiler(serializerCompiler) +await app.register(wireguardRoutes, { prefix: "/api" }) +let mutations = 0 +const originalPut = MikrotikClient.prototype.put +MikrotikClient.prototype.put = async () => { mutations++; throw new Error("Unexpected RouterOS mutation") } +try { + for (const dryRun of [false, true]) { + for (const content of ["[Interface]\n# PrivateKey = placeholder", "[Peer]\nPublicKey = placeholder"]) { + const response = await app.inject({ method: "POST", url: "/api/wireguard/import", payload: { serverId: "1", content, format: "conf", dryRun } }) + assert.equal(response.statusCode, 400) + assert.match(response.json().error, /PrivateKey|Interface/) + } + } + assert.equal(mutations, 0) +} finally { + MikrotikClient.prototype.put = originalPut + await app.close() +} +console.log("WireGuard import HTTP: invalid template/section rejected for apply and dry-run, zero mutations") diff --git a/backend/src/routes/wireguard.ts b/backend/src/routes/wireguard.ts index ca869436..813072af 100644 --- a/backend/src/routes/wireguard.ts +++ b/backend/src/routes/wireguard.ts @@ -12,11 +12,11 @@ import { } from "@mmapp/contracts/wireguard" import { MikrotikClient, MikrotikError } from "../services/mikrotik.js" import { deleteWireguardInterface } from "../services/wireguard-delete.js" +import { parseWireguardImport } from "../services/wireguard-import.js" import { generateMikrotikRsc, generateNativeConf, generatePeerClientConf, - parseWgConfig, type WgParsedConfig, } from "../services/wireguard-config.js" import { @@ -125,7 +125,7 @@ async function applyParsedConfig( peerToRosBody({ interfaceName: name, publicKey: p.publicKey, - allowedAddresses: p.allowedAddresses.length ? p.allowedAddresses : ["0.0.0.0/0"], + allowedAddresses: p.allowedAddresses, endpointAddress: p.endpointAddress, endpointPort: p.endpointPort, persistentKeepalive: p.persistentKeepalive, @@ -345,7 +345,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => { const body = parsed.data let config: WgParsedConfig try { - config = parseWgConfig(body.content, body.format) + config = parseWireguardImport(body.content, body.format) } catch (e) { return reply.status(400).send({ error: e instanceof Error ? e.message : "Ошибка разбора конфига" }) } @@ -358,6 +358,10 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => { if (!server) return reply.status(404).send({ error: "Сервер не найден" }) const client = MikrotikClient.fromServer(server) try { + const existing = await client.get>("/interface/wireguard") + if (existing.some(iface => iface.name === config.interface.name)) { + return reply.status(409).send({ error: "Интерфейс с таким именем уже существует. Укажите другое имя в конфигурации", preview }) + } const applied = await applyParsedConfig(client, config) await recordWireguard(server, "copy") return reply.send({ dryRun: false, preview, applied }) diff --git a/backend/src/services/wireguard-config.ts b/backend/src/services/wireguard-config.ts index 19569e3c..c69df533 100644 --- a/backend/src/services/wireguard-config.ts +++ b/backend/src/services/wireguard-config.ts @@ -136,8 +136,8 @@ export function parseNativeConf(content: string): WgParsedConfig { if (section === "interface") { if (key === "privatekey") iface.privateKey = value else if (key === "address") iface.address = value.split(",")[0]?.trim() - else if (key === "listenport") iface.listenPort = Number.parseInt(value, 10) || undefined - else if (key === "mtu") iface.mtu = Number.parseInt(value, 10) || undefined + else if (key === "listenport") iface.listenPort = Number(value) + else if (key === "mtu") iface.mtu = Number(value) else if (key === "name") iface.name = value || iface.name } else if (section === "peer" && currentPeer) { if (key === "publickey") currentPeer.publicKey = value @@ -150,16 +150,16 @@ export function parseNativeConf(content: string): WgParsedConfig { const lastColon = value.lastIndexOf(":") if (lastColon > 0 && !value.includes("]:")) { currentPeer.endpointAddress = value.slice(0, lastColon) - currentPeer.endpointPort = Number.parseInt(value.slice(lastColon + 1), 10) || undefined + currentPeer.endpointPort = Number(value.slice(lastColon + 1)) } else if (value.startsWith("[") && value.includes("]:")) { const idx = value.indexOf("]:") currentPeer.endpointAddress = value.slice(1, idx) - currentPeer.endpointPort = Number.parseInt(value.slice(idx + 2), 10) || undefined + currentPeer.endpointPort = Number(value.slice(idx + 2)) } else { currentPeer.endpointAddress = value } } else if (key === "persistentkeepalive") { - currentPeer.persistentKeepalive = Number.parseInt(value, 10) || undefined + currentPeer.persistentKeepalive = Number(value) } else if (key === "presharedkey") { // ignore PSK for ROS import for now } @@ -187,8 +187,8 @@ export function parseMikrotikRsc(content: string): WgParsedConfig { ) { const kv = parseKvLine(line) if (kv.name) iface.name = kv.name - if (kv["listen-port"]) iface.listenPort = Number.parseInt(kv["listen-port"], 10) || undefined - if (kv.mtu) iface.mtu = Number.parseInt(kv.mtu, 10) || undefined + if (kv["listen-port"]) iface.listenPort = Number(kv["listen-port"]) + if (kv.mtu) iface.mtu = Number(kv.mtu) if (kv["private-key"]) iface.privateKey = kv["private-key"] if (kv.comment) iface.comment = kv.comment if (kv.disabled === "yes") iface.disabled = true @@ -207,13 +207,13 @@ export function parseMikrotikRsc(content: string): WgParsedConfig { .filter(Boolean) peers.push({ publicKey: kv["public-key"] ?? "", - allowedAddresses: allowed.length ? allowed : ["0.0.0.0/0"], + allowedAddresses: allowed, endpointAddress: kv["endpoint-address"], endpointPort: kv["endpoint-port"] - ? Number.parseInt(kv["endpoint-port"], 10) || undefined + ? Number(kv["endpoint-port"]) : undefined, persistentKeepalive: kv["persistent-keepalive"] - ? Number.parseInt(kv["persistent-keepalive"], 10) || undefined + ? Number(kv["persistent-keepalive"]) : undefined, comment: kv.comment, name: kv.name, @@ -232,7 +232,7 @@ export function parseMikrotikRsc(content: string): WgParsedConfig { } } - if (!foundIface && peers.length === 0) { + if (!foundIface) { throw new Error("Не удалось распознать RouterOS WireGuard .rsc") } return { format: "rsc", interface: iface, peers } diff --git a/backend/src/services/wireguard-import.test.ts b/backend/src/services/wireguard-import.test.ts new file mode 100644 index 00000000..5a6c24bf --- /dev/null +++ b/backend/src/services/wireguard-import.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { parseWireguardImport } from "./wireguard-import.js" +import { generatePeerClientConf } from "./wireguard-config.js" + +const key = Buffer.alloc(32, 7).toString("base64") +const conf = `[Interface]\nPrivateKey = ${key}\nAddress = 198.18.255.241/30\nListenPort = 51879\n[Peer]\nPublicKey = ${key}\nAllowedIPs = 198.18.255.242/32\nEndpoint = 192.0.2.1:51879\n` + +test("complete conf accepts the documented wg0 name and explicit routes", () => { + const parsed = parseWireguardImport(conf, "auto") + assert.equal(parsed.interface.name, "wg0") + assert.deepEqual(parsed.peers[0]?.allowedAddresses, ["198.18.255.242/32"]) + assert.equal(parseWireguardImport(conf.replace("198.18.255.242/32", "0.0.0.0/0"), "conf").peers[0]?.allowedAddresses[0], "0.0.0.0/0") +}) + +test("generated client template cannot be imported before it is completed", () => { + const template = generatePeerClientConf({ serverPublicKey: key, endpoint: "192.0.2.1:51879" }) + assert.throws(() => parseWireguardImport(template, "conf"), /PrivateKey/) +}) + +for (const [label, content, message] of [ + ["absent interface", "[Peer]\nPublicKey = " + key, /Interface/], + ["duplicate interface", conf + "[Interface]\n", /Interface/], + ["missing peer key", conf.replace(`PublicKey = ${key}`, "# PublicKey absent"), /PublicKey/], + ["invalid private key", conf.replace(`PrivateKey = ${key}`, "PrivateKey = placeholder"), /PrivateKey/], + ["invalid peer key", conf.replace(`PublicKey = ${key}`, "PublicKey = placeholder"), /PublicKey/], + ["absent routes", conf.replace("AllowedIPs = 198.18.255.242/32", "# AllowedIPs absent"), /AllowedIPs/], + ["invalid prefix", conf.replace("198.18.255.242/32", "198.18.255.242/33"), /AllowedIPs/], + ["extra prefix separator", conf.replace("198.18.255.242/32", "198.18.255.242/32/"), /AllowedIPs/], + ["invalid address", conf.replace("198.18.255.241/30", "not-an-address"), /Address/], + ["unsupported multiple addresses", conf.replace("198.18.255.241/30", "198.18.255.241/30, 198.18.255.242/30"), /нескольких/], + ["unsupported PSK", conf + `PresharedKey = ${key}\n`, /PresharedKey/], + ["invalid listen port", conf.replace("ListenPort = 51879", "ListenPort = 65536"), /ListenPort/], + ["zero listen port", conf.replace("ListenPort = 51879", "ListenPort = 0"), /ListenPort/], + ["non-numeric listen port", conf.replace("ListenPort = 51879", "ListenPort = invalid"), /ListenPort/], + ["invalid MTU", conf.replace("[Interface]", "[Interface]\nMTU = invalid"), /MTU/], + ["zero MTU", conf.replace("[Interface]", "[Interface]\nMTU = 0"), /MTU/], + ["zero endpoint port", conf.replace("192.0.2.1:51879", "192.0.2.1:0"), /Endpoint/], + ["invalid endpoint port", conf.replace("192.0.2.1:51879", "192.0.2.1:invalid"), /Endpoint/], + ["negative keepalive", conf + "PersistentKeepalive = -1", /PersistentKeepalive/], +] as const) { + test(label, () => assert.throws(() => parseWireguardImport(content, "conf"), message)) +} + +test("RSC peers without routes or interface are rejected without default routes", () => { + const peer = `/interface wireguard peers add public-key="${key}"` + assert.throws(() => parseWireguardImport(peer, "rsc"), /распознать/) + assert.throws(() => parseWireguardImport(`/interface wireguard add name=own-wg\n${peer}`, "rsc"), /allowed-address/) + assert.throws(() => parseWireguardImport("/interface wireguard add name=own-wg mtu=0", "rsc"), /MTU/) + assert.deepEqual(parseWireguardImport(`/interface wireguard add name=own-wg\n${peer} allowed-address=192.0.2.0/24`, "rsc").peers[0]?.allowedAddresses, ["192.0.2.0/24"]) +}) diff --git a/backend/src/services/wireguard-import.ts b/backend/src/services/wireguard-import.ts new file mode 100644 index 00000000..7e488186 --- /dev/null +++ b/backend/src/services/wireguard-import.ts @@ -0,0 +1,64 @@ +import { isIP } from "node:net" +import { parseWgConfig, type WgParsedConfig } from "./wireguard-config.js" + +function validKey(value: string): boolean { + return /^[A-Za-z0-9+/]{43}=$/.test(value) && Buffer.from(value, "base64").length === 32 +} + +function validNetwork(value: string): boolean { + const parts = value.split("/") + const [address, prefix] = parts + const version = isIP(address ?? "") + return parts.length === 2 && version !== 0 && /^\d+$/.test(prefix ?? "") && Number(prefix) <= (version === 4 ? 32 : 128) +} + +/** Validate the complete import before the first RouterOS mutation. Never echo keys. */ +export function parseWireguardImport(content: string, format: "auto" | "rsc" | "conf"): WgParsedConfig { + const parsed = parseWgConfig(content, format) + if (parsed.format === "conf") { + if ((content.match(/^\s*\[Interface\]\s*$/gim) ?? []).length !== 1) { + throw new Error("Конфигурация .conf должна содержать одну секцию [Interface]") + } + if (!parsed.interface.privateKey) { + throw new Error("Заполните PrivateKey: клиентский шаблон нельзя применить без приватного ключа") + } + if ((content.match(/^\s*\[Peer\]\s*$/gim) ?? []).length !== parsed.peers.length) { + throw new Error("У каждого пира должен быть PublicKey") + } + if (/^\s*Address\s*=.*,/im.test(content)) { + throw new Error("Импорт нескольких адресов Address пока не поддерживается; укажите один адрес") + } + } + if (/^\s*PresharedKey\s*=\s*\S/im.test(content) || /\bpreshared-key\s*=/i.test(content)) { + throw new Error("Импорт PresharedKey пока не поддерживается; конфигурация не применена") + } + if (!parsed.interface.name.trim()) throw new Error("Укажите имя интерфейса") + if (parsed.interface.privateKey && !validKey(parsed.interface.privateKey)) { + throw new Error("Некорректный PrivateKey: нужен ключ WireGuard длиной 32 байта в base64") + } + if (parsed.interface.address && !validNetwork(parsed.interface.address)) { + throw new Error("Некорректный Address: укажите IPv4 или IPv6 адрес с префиксом") + } + if (parsed.interface.listenPort !== undefined && (!Number.isInteger(parsed.interface.listenPort) || parsed.interface.listenPort < 1 || parsed.interface.listenPort > 65535)) { + throw new Error("ListenPort должен быть от 1 до 65535") + } + if (parsed.interface.mtu !== undefined && (!Number.isInteger(parsed.interface.mtu) || parsed.interface.mtu <= 0)) { + throw new Error("MTU должен быть положительным целым числом") + } + for (const peer of parsed.peers) { + if (!validKey(peer.publicKey)) throw new Error("Некорректный PublicKey пира") + if (!peer.allowedAddresses.length || peer.allowedAddresses.some(value => !validNetwork(value))) { + throw new Error("Заполните AllowedIPs / allowed-address каждого пира корректными сетями с префиксом") + } + if (peer.endpointPort !== undefined && (!Number.isInteger(peer.endpointPort) || peer.endpointPort < 1 || peer.endpointPort > 65535)) { + throw new Error("Порт Endpoint должен быть от 1 до 65535") + } + if (parsed.format === "conf" && peer.endpointAddress && peer.endpointPort === undefined) { + throw new Error("Укажите порт Endpoint после адреса") + } + if (peer.persistentKeepalive !== undefined && (!Number.isInteger(peer.persistentKeepalive) || peer.persistentKeepalive < 0)) { + throw new Error("PersistentKeepalive должен быть неотрицательным целым числом") + } + } + return parsed +} diff --git a/components/wireguard/wg-import-sheet.tsx b/components/wireguard/wg-import-sheet.tsx index 2a5a4968..2e389f53 100644 --- a/components/wireguard/wg-import-sheet.tsx +++ b/components/wireguard/wg-import-sheet.tsx @@ -92,7 +92,9 @@ function WgImportSheet({ Импорт конфига WireGuard - Native .conf или MikroTik .rsc → применить на выбранный роутер + Native .conf или MikroTik .rsc → применить на выбранный роутер. + В .conf без Name используется имя wg0. Заполните ключ и сети пиров; + совпадение имени остановит импорт. diff --git a/docs/ux-ui-external-acceptance.md b/docs/ux-ui-external-acceptance.md index bef4bb1b..5fe675a4 100644 --- a/docs/ux-ui-external-acceptance.md +++ b/docs/ux-ui-external-acceptance.md @@ -553,3 +553,15 @@ Native проверка после UI восстановила name/comment,list Own interfaces/peers/IP/connected routes удалены; WG/peers/IP/routes/IPv4 filter/nat/mangle совпали с исходным baseline на обоих CHR. Остались три основные службы, backend healthy. Business DB controls совпадают с контрольной точкой67:servers2,speedHistory9,retentionDays14,migrations9; audit revisions намеренно сохранены. Публичные JSON проверены на отсутствие ключей/конфигураций/паролей. Общие ReUI компоненты и product source не менялись; общая регрессия не повторялась для внешней приёмки. Граница результата: правильный RSC собственного туннеля, preview и успешное применение с независимой связностью. Импорт .conf не принят: exporter не сохраняет имя/комментарии, parser использует wg0 по умолчанию; incomplete Peer .conf template требует отдельной проверки до применения. Partial failure/rollback, повторный import/коллизии, private-key UI download,IPv6/throughput/RBAC/внешние failures/multi-instance/Telegram/Portal и остальные протоколы/интеграции остаются открытыми. Следующий12.5.69 — preflight/ошибки .conf и затем безопасный собственный roundtrip. Локальный коммит без push. + +## 12.5.69 — preflight импорта WireGuard без выдуманных маршрутов (2026-10-09) + +Обнаружено локально: parse/apply могли принять клиентский шаблон без PrivateKey и подставить0.0.0.0/0 вместо отсутствующих allowed-address. Добавлен backend/src/services/wireguard-import.ts; route использует его до dry-run, поиска сервера и любых RouterOS mutations. .conf должен содержать ровно одну Interface секцию и PrivateKey; каждый объявленный Peer должен иметь PublicKey и явные корректные AllowedIPs. Проверяются32байтные base64 ключи, IPv4/IPv6 CIDR, порты, положительный MTU и неотрицательный keepalive. Неподдерживаемые несколько Address и PresharedKey отклоняются явно вместо потери данных. Ошибки ключей не выводят их значения. + +Backend/frontend RSC parsers больше не подставляют default route и требуют interface command; applyParsedConfig использует только проверенные явные сети. Явно заданный0.0.0.0/0 остаётся допустимым. Числа сохраняются для проверки вместо parseInt/undefined defaults, включая0 иNaN. Перед обычным apply route читает список интерфейсов и возвращает409 при занятом имени; это предварительная проверка, не блокировка конкурентных запросов. Dry-run проверяет формат, но не обещает доступность сервера или отсутствие конфликтов на момент применения. + +В существующем WgImportSheet добавлено короткое пояснение: .conf безName использует wg0; ключ/сети надо заполнить, совпадение имени остановит import. Структура Sheet/base-nova, формы, aria labels/status/alert и управление busy не менялись. Локальный preview остаётся разбором формата; обязательная preflight проверка выполняется backend. Новых API полей/зависимостей нет. Полный протокол .conf или поддержку PSK этот блок не добавляет. + +Проверки: wireguard-import unit22/22 (готовый conf и explicit default route, template, Interface/Peer/key errors, CIDR, отсутствие маршрутов, несколькоAddress/PSK, числовые ошибки и RSC); настоящий Fastify inject4 invalid apply/dryRun случая →400, PUT mutations0. Backend test:wireguard теперь включает новые service/route tests; исходный codec passed. Client-template cross-layer5/5 passed. Browser3/3: доступ/неподтверждённый receipt, ожидание/повтор,400 incomplete conf с сохранением текста и выбранного сервера2. Последний test не попал в первый grep импорт; отдельно запущен и прошёл. Backend/scoped frontend TypeScript, ESLint и diff check passed. ReUI checklist checked; installed primitives не менялись. + +Исправление локальное, стенд по-прежнему c16fc0c. Реальные preflight400/409, отсутствие native изменений при отказе, production build и новый .conf roundtrip ещё не приняты. Проверка коллизии409 добавлена в code, отдельно не доказана RouterOS fixture в этом блоке. Следующий12.5.70 — собрать/развернуть, проверить отказы и собственную пару на обоих CHR, native параметры/идентичность/связность, затем cleanup/baseline. Конкуренция, повторный import/partial failure/rollback, неизвестные/повторные поля .conf и остальные внешние ограничения остаются открытыми. Пароли и RouterOS настройки в этом блоке не менялись. Локальный коммит без push. diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 81893b5c..87c4ec25 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -1,6 +1,6 @@ # План реализации UX/UI по пятичасовым окнам -Актуальное продолжение 2026-10-09: 12.5.68 подтвердил настоящий .rsc export → dry-run → delete → UI import на обоих CHR. Native параметры, публичная идентичность и эквивалентность private scalar сохранены; handshake/Ping3/3 в обе стороны, cleanup/baseline и DB controls passed. Runtime backend/frontend c16fc0c healthy. Следующий12.5.69 — проверить безопасный импорт .conf: имя устройства/интерфейса, неполный клиентский шаблон и ошибки до применения; затем собственный native roundtrip. Этап12.5 открыт. +Актуальное продолжение 2026-10-09: 12.5.69 локально добавил проверки импорта WireGuard до применения: неполный .conf/неверные ключи и сети/неподдерживаемый PSK отклоняются, отсутствие AllowedIPs больше не создаёт default route; совпадение имени даёт409. Unit22/22, HTTP4 invalid cases, client-template5/5, import browser3/3, backend/frontend TypeScript и ESLint passed. Runtime остаётся backend/frontend c16fc0c. Следующий12.5.70 — Docker build/deploy, реальные400/409 без native изменений и собственный .conf roundtrip на обоих CHR с очисткой. Этап12.5 открыт. Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-08. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 1f0fe7eb..31a249c8 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -1,6 +1,6 @@ # UX/UI: журнал реализации -Актуальное продолжение 2026-10-09: 12.5.68 подтвердил настоящий .rsc export → dry-run → delete → UI import на обоих CHR. Native параметры, публичная идентичность и эквивалентность private scalar сохранены; handshake/Ping3/3 в обе стороны, cleanup/baseline и DB controls passed. Runtime backend/frontend c16fc0c healthy. Следующий12.5.69 — проверить безопасный импорт .conf: имя устройства/интерфейса, неполный клиентский шаблон и ошибки до применения; затем собственный native roundtrip. Этап12.5 открыт. +Актуальное продолжение 2026-10-09: 12.5.69 локально добавил проверки импорта WireGuard до применения: неполный .conf/неверные ключи и сети/неподдерживаемый PSK отклоняются, отсутствие AllowedIPs больше не создаёт default route; совпадение имени даёт409. Unit22/22, HTTP4 invalid cases, client-template5/5, import browser3/3, backend/frontend TypeScript и ESLint passed. Runtime остаётся backend/frontend c16fc0c. Следующий12.5.70 — Docker build/deploy, реальные400/409 без native изменений и собственный .conf roundtrip на обоих CHR с очисткой. Этап12.5 открыт. Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md). @@ -34,7 +34,19 @@ | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 | -| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка завершена в границах12.3.83; внешняя12.5 в работе, выполненные блоки1–68 и ограничения ниже. Runtime backend/frontend c16fc0c. | +| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка завершена в границах12.3.83; внешняя12.5 в работе, выполненные блоки1–69 и ограничения ниже. Runtime backend/frontend c16fc0c. | + +## 12.5.69 — preflight импорта WireGuard без выдуманных маршрутов (2026-10-09) + +Обнаружено локально: parse/apply могли принять клиентский шаблон без PrivateKey и подставить0.0.0.0/0 вместо отсутствующих allowed-address. Добавлен backend/src/services/wireguard-import.ts; route использует его до dry-run, поиска сервера и любых RouterOS mutations. .conf должен содержать ровно одну Interface секцию и PrivateKey; каждый объявленный Peer должен иметь PublicKey и явные корректные AllowedIPs. Проверяются32байтные base64 ключи, IPv4/IPv6 CIDR, порты, положительный MTU и неотрицательный keepalive. Неподдерживаемые несколько Address и PresharedKey отклоняются явно вместо потери данных. Ошибки ключей не выводят их значения. + +Backend/frontend RSC parsers больше не подставляют default route и требуют interface command; applyParsedConfig использует только проверенные явные сети. Явно заданный0.0.0.0/0 остаётся допустимым. Числа сохраняются для проверки вместо parseInt/undefined defaults, включая0 иNaN. Перед обычным apply route читает список интерфейсов и возвращает409 при занятом имени; это предварительная проверка, не блокировка конкурентных запросов. Dry-run проверяет формат, но не обещает доступность сервера или отсутствие конфликтов на момент применения. + +В существующем WgImportSheet добавлено короткое пояснение: .conf безName использует wg0; ключ/сети надо заполнить, совпадение имени остановит import. Структура Sheet/base-nova, формы, aria labels/status/alert и управление busy не менялись. Локальный preview остаётся разбором формата; обязательная preflight проверка выполняется backend. Новых API полей/зависимостей нет. Полный протокол .conf или поддержку PSK этот блок не добавляет. + +Проверки: wireguard-import unit22/22 (готовый conf и explicit default route, template, Interface/Peer/key errors, CIDR, отсутствие маршрутов, несколькоAddress/PSK, числовые ошибки и RSC); настоящий Fastify inject4 invalid apply/dryRun случая →400, PUT mutations0. Backend test:wireguard теперь включает новые service/route tests; исходный codec passed. Client-template cross-layer5/5 passed. Browser3/3: доступ/неподтверждённый receipt, ожидание/повтор,400 incomplete conf с сохранением текста и выбранного сервера2. Последний test не попал в первый grep импорт; отдельно запущен и прошёл. Backend/scoped frontend TypeScript, ESLint и diff check passed. ReUI checklist checked; installed primitives не менялись. + +Исправление локальное, стенд по-прежнему c16fc0c. Реальные preflight400/409, отсутствие native изменений при отказе, production build и новый .conf roundtrip ещё не приняты. Проверка коллизии409 добавлена в code, отдельно не доказана RouterOS fixture в этом блоке. Следующий12.5.70 — собрать/развернуть, проверить отказы и собственную пару на обоих CHR, native параметры/идентичность/связность, затем cleanup/baseline. Конкуренция, повторный import/partial failure/rollback, неизвестные/повторные поля .conf и остальные внешние ограничения остаются открытыми. Пароли и RouterOS настройки в этом блоке не менялись. Локальный коммит без push. ## 12.5.68 — реальный .rsc roundtrip WireGuard через UI (2026-10-09) diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index 0b8f8b1f..ecaefc76 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -1,6 +1,6 @@ # Этап 12: итоговая приёмка UX/UI -Актуальное продолжение 2026-10-09: 12.5.68 подтвердил настоящий .rsc export → dry-run → delete → UI import на обоих CHR. Native параметры, публичная идентичность и эквивалентность private scalar сохранены; handshake/Ping3/3 в обе стороны, cleanup/baseline и DB controls passed. Runtime backend/frontend c16fc0c healthy. Следующий12.5.69 — проверить безопасный импорт .conf: имя устройства/интерфейса, неполный клиентский шаблон и ошибки до применения; затем собственный native roundtrip. Этап12.5 открыт. +Актуальное продолжение 2026-10-09: 12.5.69 локально добавил проверки импорта WireGuard до применения: неполный .conf/неверные ключи и сети/неподдерживаемый PSK отклоняются, отсутствие AllowedIPs больше не создаёт default route; совпадение имени даёт409. Unit22/22, HTTP4 invalid cases, client-template5/5, import browser3/3, backend/frontend TypeScript и ESLint passed. Runtime остаётся backend/frontend c16fc0c. Следующий12.5.70 — Docker build/deploy, реальные400/409 без native изменений и собственный .conf roundtrip на обоих CHR с очисткой. Этап12.5 открыт. План: [ux-ui-plan.md](ux-ui-plan.md). Фактические результаты: [ux-ui-progress.md](ux-ui-progress.md). Подготовлен после сводных проверок 11Б; завершение каждого блока требует локального коммита без push. @@ -1718,3 +1718,15 @@ Native проверка после UI восстановила name/comment,list Own interfaces/peers/IP/connected routes удалены; WG/peers/IP/routes/IPv4 filter/nat/mangle совпали с исходным baseline на обоих CHR. Остались три основные службы, backend healthy. Business DB controls совпадают с контрольной точкой67:servers2,speedHistory9,retentionDays14,migrations9; audit revisions намеренно сохранены. Публичные JSON проверены на отсутствие ключей/конфигураций/паролей. Общие ReUI компоненты и product source не менялись; общая регрессия не повторялась для внешней приёмки. Граница результата: правильный RSC собственного туннеля, preview и успешное применение с независимой связностью. Импорт .conf не принят: exporter не сохраняет имя/комментарии, parser использует wg0 по умолчанию; incomplete Peer .conf template требует отдельной проверки до применения. Partial failure/rollback, повторный import/коллизии, private-key UI download,IPv6/throughput/RBAC/внешние failures/multi-instance/Telegram/Portal и остальные протоколы/интеграции остаются открытыми. Следующий12.5.69 — preflight/ошибки .conf и затем безопасный собственный roundtrip. Локальный коммит без push. + +## 12.5.69 — preflight импорта WireGuard без выдуманных маршрутов (2026-10-09) + +Обнаружено локально: parse/apply могли принять клиентский шаблон без PrivateKey и подставить0.0.0.0/0 вместо отсутствующих allowed-address. Добавлен backend/src/services/wireguard-import.ts; route использует его до dry-run, поиска сервера и любых RouterOS mutations. .conf должен содержать ровно одну Interface секцию и PrivateKey; каждый объявленный Peer должен иметь PublicKey и явные корректные AllowedIPs. Проверяются32байтные base64 ключи, IPv4/IPv6 CIDR, порты, положительный MTU и неотрицательный keepalive. Неподдерживаемые несколько Address и PresharedKey отклоняются явно вместо потери данных. Ошибки ключей не выводят их значения. + +Backend/frontend RSC parsers больше не подставляют default route и требуют interface command; applyParsedConfig использует только проверенные явные сети. Явно заданный0.0.0.0/0 остаётся допустимым. Числа сохраняются для проверки вместо parseInt/undefined defaults, включая0 иNaN. Перед обычным apply route читает список интерфейсов и возвращает409 при занятом имени; это предварительная проверка, не блокировка конкурентных запросов. Dry-run проверяет формат, но не обещает доступность сервера или отсутствие конфликтов на момент применения. + +В существующем WgImportSheet добавлено короткое пояснение: .conf безName использует wg0; ключ/сети надо заполнить, совпадение имени остановит import. Структура Sheet/base-nova, формы, aria labels/status/alert и управление busy не менялись. Локальный preview остаётся разбором формата; обязательная preflight проверка выполняется backend. Новых API полей/зависимостей нет. Полный протокол .conf или поддержку PSK этот блок не добавляет. + +Проверки: wireguard-import unit22/22 (готовый conf и explicit default route, template, Interface/Peer/key errors, CIDR, отсутствие маршрутов, несколькоAddress/PSK, числовые ошибки и RSC); настоящий Fastify inject4 invalid apply/dryRun случая →400, PUT mutations0. Backend test:wireguard теперь включает новые service/route tests; исходный codec passed. Client-template cross-layer5/5 passed. Browser3/3: доступ/неподтверждённый receipt, ожидание/повтор,400 incomplete conf с сохранением текста и выбранного сервера2. Последний test не попал в первый grep импорт; отдельно запущен и прошёл. Backend/scoped frontend TypeScript, ESLint и diff check passed. ReUI checklist checked; installed primitives не менялись. + +Исправление локальное, стенд по-прежнему c16fc0c. Реальные preflight400/409, отсутствие native изменений при отказе, production build и новый .conf roundtrip ещё не приняты. Проверка коллизии409 добавлена в code, отдельно не доказана RouterOS fixture в этом блоке. Следующий12.5.70 — собрать/развернуть, проверить отказы и собственную пару на обоих CHR, native параметры/идентичность/связность, затем cleanup/baseline. Конкуренция, повторный import/partial failure/rollback, неизвестные/повторные поля .conf и остальные внешние ограничения остаются открытыми. Пароли и RouterOS настройки в этом блоке не менялись. Локальный коммит без push. diff --git a/docs/ux-ui-test-server.md b/docs/ux-ui-test-server.md index 8b89f5b9..c3dfc8f5 100644 --- a/docs/ux-ui-test-server.md +++ b/docs/ux-ui-test-server.md @@ -561,3 +561,15 @@ Native проверка после UI восстановила name/comment,list Own interfaces/peers/IP/connected routes удалены; WG/peers/IP/routes/IPv4 filter/nat/mangle совпали с исходным baseline на обоих CHR. Остались три основные службы, backend healthy. Business DB controls совпадают с контрольной точкой67:servers2,speedHistory9,retentionDays14,migrations9; audit revisions намеренно сохранены. Публичные JSON проверены на отсутствие ключей/конфигураций/паролей. Общие ReUI компоненты и product source не менялись; общая регрессия не повторялась для внешней приёмки. Граница результата: правильный RSC собственного туннеля, preview и успешное применение с независимой связностью. Импорт .conf не принят: exporter не сохраняет имя/комментарии, parser использует wg0 по умолчанию; incomplete Peer .conf template требует отдельной проверки до применения. Partial failure/rollback, повторный import/коллизии, private-key UI download,IPv6/throughput/RBAC/внешние failures/multi-instance/Telegram/Portal и остальные протоколы/интеграции остаются открытыми. Следующий12.5.69 — preflight/ошибки .conf и затем безопасный собственный roundtrip. Локальный коммит без push. + +## 12.5.69 — preflight импорта WireGuard без выдуманных маршрутов (2026-10-09) + +Обнаружено локально: parse/apply могли принять клиентский шаблон без PrivateKey и подставить0.0.0.0/0 вместо отсутствующих allowed-address. Добавлен backend/src/services/wireguard-import.ts; route использует его до dry-run, поиска сервера и любых RouterOS mutations. .conf должен содержать ровно одну Interface секцию и PrivateKey; каждый объявленный Peer должен иметь PublicKey и явные корректные AllowedIPs. Проверяются32байтные base64 ключи, IPv4/IPv6 CIDR, порты, положительный MTU и неотрицательный keepalive. Неподдерживаемые несколько Address и PresharedKey отклоняются явно вместо потери данных. Ошибки ключей не выводят их значения. + +Backend/frontend RSC parsers больше не подставляют default route и требуют interface command; applyParsedConfig использует только проверенные явные сети. Явно заданный0.0.0.0/0 остаётся допустимым. Числа сохраняются для проверки вместо parseInt/undefined defaults, включая0 иNaN. Перед обычным apply route читает список интерфейсов и возвращает409 при занятом имени; это предварительная проверка, не блокировка конкурентных запросов. Dry-run проверяет формат, но не обещает доступность сервера или отсутствие конфликтов на момент применения. + +В существующем WgImportSheet добавлено короткое пояснение: .conf безName использует wg0; ключ/сети надо заполнить, совпадение имени остановит import. Структура Sheet/base-nova, формы, aria labels/status/alert и управление busy не менялись. Локальный preview остаётся разбором формата; обязательная preflight проверка выполняется backend. Новых API полей/зависимостей нет. Полный протокол .conf или поддержку PSK этот блок не добавляет. + +Проверки: wireguard-import unit22/22 (готовый conf и explicit default route, template, Interface/Peer/key errors, CIDR, отсутствие маршрутов, несколькоAddress/PSK, числовые ошибки и RSC); настоящий Fastify inject4 invalid apply/dryRun случая →400, PUT mutations0. Backend test:wireguard теперь включает новые service/route tests; исходный codec passed. Client-template cross-layer5/5 passed. Browser3/3: доступ/неподтверждённый receipt, ожидание/повтор,400 incomplete conf с сохранением текста и выбранного сервера2. Последний test не попал в первый grep импорт; отдельно запущен и прошёл. Backend/scoped frontend TypeScript, ESLint и diff check passed. ReUI checklist checked; installed primitives не менялись. + +Исправление локальное, стенд по-прежнему c16fc0c. Реальные preflight400/409, отсутствие native изменений при отказе, production build и новый .conf roundtrip ещё не приняты. Проверка коллизии409 добавлена в code, отдельно не доказана RouterOS fixture в этом блоке. Следующий12.5.70 — собрать/развернуть, проверить отказы и собственную пару на обоих CHR, native параметры/идентичность/связность, затем cleanup/baseline. Конкуренция, повторный import/partial failure/rollback, неизвестные/повторные поля .conf и остальные внешние ограничения остаются открытыми. Пароли и RouterOS настройки в этом блоке не менялись. Локальный коммит без push. diff --git a/lib/wg-config.ts b/lib/wg-config.ts index 12fb5d8c..e829a08a 100644 --- a/lib/wg-config.ts +++ b/lib/wg-config.ts @@ -136,8 +136,8 @@ export function parseNativeConf(content: string): WgParsedConfig { if (section === "interface") { if (key === "privatekey") iface.privateKey = value else if (key === "address") iface.address = value.split(",")[0]?.trim() - else if (key === "listenport") iface.listenPort = Number.parseInt(value, 10) || undefined - else if (key === "mtu") iface.mtu = Number.parseInt(value, 10) || undefined + else if (key === "listenport") iface.listenPort = Number(value) + else if (key === "mtu") iface.mtu = Number(value) else if (key === "name") iface.name = value || iface.name } else if (section === "peer" && currentPeer) { if (key === "publickey") currentPeer.publicKey = value @@ -150,12 +150,12 @@ export function parseNativeConf(content: string): WgParsedConfig { const lastColon = value.lastIndexOf(":") if (lastColon > 0 && !value.includes("]:")) { currentPeer.endpointAddress = value.slice(0, lastColon) - currentPeer.endpointPort = Number.parseInt(value.slice(lastColon + 1), 10) || undefined + currentPeer.endpointPort = Number(value.slice(lastColon + 1)) } else { currentPeer.endpointAddress = value } } else if (key === "persistentkeepalive") { - currentPeer.persistentKeepalive = Number.parseInt(value, 10) || undefined + currentPeer.persistentKeepalive = Number(value) } } } @@ -179,8 +179,8 @@ export function parseMikrotikRsc(content: string): WgParsedConfig { ) { const kv = parseKvLine(line) if (kv.name) iface.name = kv.name - if (kv["listen-port"]) iface.listenPort = Number.parseInt(kv["listen-port"], 10) || undefined - if (kv.mtu) iface.mtu = Number.parseInt(kv.mtu, 10) || undefined + if (kv["listen-port"]) iface.listenPort = Number(kv["listen-port"]) + if (kv.mtu) iface.mtu = Number(kv.mtu) if (kv["private-key"]) iface.privateKey = kv["private-key"] if (kv.comment) iface.comment = kv.comment if (kv.disabled === "yes") iface.disabled = true @@ -199,13 +199,13 @@ export function parseMikrotikRsc(content: string): WgParsedConfig { .filter(Boolean) peers.push({ publicKey: kv["public-key"] ?? "", - allowedAddresses: allowed.length ? allowed : ["0.0.0.0/0"], + allowedAddresses: allowed, endpointAddress: kv["endpoint-address"], endpointPort: kv["endpoint-port"] - ? Number.parseInt(kv["endpoint-port"], 10) || undefined + ? Number(kv["endpoint-port"]) : undefined, persistentKeepalive: kv["persistent-keepalive"] - ? Number.parseInt(kv["persistent-keepalive"], 10) || undefined + ? Number(kv["persistent-keepalive"]) : undefined, comment: kv.comment, name: kv.name, @@ -223,7 +223,7 @@ export function parseMikrotikRsc(content: string): WgParsedConfig { } } - if (!foundIface && peers.length === 0) { + if (!foundIface) { throw new Error("Не удалось распознать RouterOS WireGuard .rsc") } return { format: "rsc", interface: iface, peers } diff --git a/tests/e2e/wireguard-links.spec.ts b/tests/e2e/wireguard-links.spec.ts index 0996725b..72c2b81e 100644 --- a/tests/e2e/wireguard-links.spec.ts +++ b/tests/e2e/wireguard-links.spec.ts @@ -208,6 +208,27 @@ test("WireGuard: импорт сохраняет форму без подтве expect(attempts).toBe(5) }) +test("WireGuard: ошибка проверки .conf сохраняет клиентский шаблон и выбранный сервер", async ({ page }) => { + await setup(page, "/wireguard?server=2") + const content = "[Interface]\n# PrivateKey = <заполните ключ>\n[Peer]\n# AllowedIPs = <укажите сети>" + let attempts = 0 + await page.route("**/api/wireguard/import", route => { + expect(route.request().postDataJSON()).toMatchObject({ serverId: "2", content, dryRun: false }) + attempts++ + return route.fulfill({ status: 400, json: { error: "Заполните PrivateKey: клиентский шаблон нельзя применить без приватного ключа" } }) + }) + await page.getByRole("button", { name: "Импорт", exact: true }).click() + const dialog = page.getByRole("dialog", { name: "Импорт конфига WireGuard" }) + await expect(dialog).toContainText("без Name используется имя wg0") + await dialog.getByLabel("Конфигурация WireGuard", { exact: true }).fill(content) + await dialog.getByRole("button", { name: "Применить на роутер" }).click() + await expect(dialog.getByRole("alert")).toContainText("Заполните PrivateKey") + await expect(dialog.getByLabel("Конфигурация WireGuard", { exact: true })).toHaveValue(content) + await expect(dialog.getByLabel("Сервер", { exact: true })).toHaveValue("2") + await expect(dialog.getByRole("button", { name: "Применить на роутер" })).toBeEnabled() + expect(attempts).toBe(1) +}) + test("WireGuard: импорт удерживает форму до ответа и разрешает повтор", async ({ page }) => { await setup(page, "/wireguard?server=1") let attempts = 0