From e01454b720f92ae8adc1636f7a2092132232dc87 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 9 Oct 2026 09:04:05 +0700 Subject: [PATCH] =?UTF-8?q?fix(wireguard):=20=D0=BE=D1=87=D0=B8=D1=89?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=B6=D0=B4=D1=91=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=BE=D0=B1=D1=8A?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D1=8B=20=D0=BF=D1=80=D0=B8=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=D0=B7=D0=B5=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package.json | 2 +- backend/src/routes/wireguard.ts | 64 ++------ .../services/wireguard-import-apply.test.ts | 143 ++++++++++++++++++ .../src/services/wireguard-import-apply.ts | 86 +++++++++++ docs/ux-ui-external-acceptance.md | 14 ++ docs/ux-ui-plan.md | 2 +- docs/ux-ui-progress.md | 18 ++- docs/ux-ui-stage-12.md | 16 +- docs/ux-ui-test-server.md | 14 ++ tests/e2e/wireguard-links.spec.ts | 30 ++++ 10 files changed, 331 insertions(+), 58 deletions(-) create mode 100644 backend/src/services/wireguard-import-apply.test.ts create mode 100644 backend/src/services/wireguard-import-apply.ts diff --git a/backend/package.json b/backend/package.json index 5a7e5da5..706a89d4 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 && npx tsx --test src/services/wireguard-import.test.ts && npx tsx src/routes/wireguard-import.test.ts", + "test:wireguard": "npx tsx src/services/wireguard-config.test.ts && npx tsx --test src/services/wireguard-import.test.ts src/services/wireguard-import-apply.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.ts b/backend/src/routes/wireguard.ts index 813072af..624ca867 100644 --- a/backend/src/routes/wireguard.ts +++ b/backend/src/routes/wireguard.ts @@ -12,6 +12,7 @@ import { } from "@mmapp/contracts/wireguard" import { MikrotikClient, MikrotikError } from "../services/mikrotik.js" import { deleteWireguardInterface } from "../services/wireguard-delete.js" +import { applyWireguardImport, WireguardImportApplyError } from "../services/wireguard-import-apply.js" import { parseWireguardImport } from "../services/wireguard-import.js" import { generateMikrotikRsc, @@ -98,51 +99,6 @@ function previewFromParsed(parsed: WgParsedConfig) { } } -async function applyParsedConfig( - client: MikrotikClient, - parsed: WgParsedConfig, -): Promise<{ interfaceName: string; peersCreated: number }> { - const name = parsed.interface.name - const ifaceBody = toRosBody({ - name, - "listen-port": String(parsed.interface.listenPort ?? 13231), - mtu: String(parsed.interface.mtu ?? 1420), - "private-key": parsed.interface.privateKey, - comment: parsed.interface.comment, - disabled: parsed.interface.disabled ? "yes" : undefined, - }) - await putWireguardInterface(client, ifaceBody) - - if (parsed.interface.address) { - await putIpAddress(client, parsed.interface.address, name) - } - - let peersCreated = 0 - for (const p of parsed.peers) { - if (!p.publicKey) continue - await putWireguardPeer( - client, - peerToRosBody({ - interfaceName: name, - publicKey: p.publicKey, - allowedAddresses: p.allowedAddresses, - endpointAddress: p.endpointAddress, - endpointPort: p.endpointPort, - persistentKeepalive: p.persistentKeepalive, - comment: p.comment, - name: p.name, - privateKey: p.privateKey, - clientAddress: p.clientAddress, - clientDns: p.clientDns, - clientEndpoint: p.clientEndpoint, - disabled: p.disabled, - }), - ) - peersCreated += 1 - } - return { interfaceName: name, peersCreated } -} - function findIface( list: WgIfaceDto[], serverId: string, @@ -358,16 +314,18 @@ 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 applyWireguardImport(client, config, (peer, interfaceName) => peerToRosBody({ ...peer, interfaceName })) + try { + await recordWireguard(server, "copy") + } catch { + return reply.status(502).send({ error: "Конфигурация применена, но ревизия не сохранена. Обновите данные перед повтором", state: "applied", preview }) } - const applied = await applyParsedConfig(client, config) - await recordWireguard(server, "copy") return reply.send({ dryRun: false, preview, applied }) - } catch (e) { - const msg = e instanceof Error ? e.message : String(e) - return reply.status(502).send({ error: `RouterOS: ${msg}`, preview }) + } catch (error) { + if (error instanceof WireguardImportApplyError) { + return reply.status(error.state === "conflict" ? 409 : 502).send({ error: error.message, state: error.state, preview }) + } + return reply.status(502).send({ error: "Не удалось проверить состояние WireGuard перед импортом; применение не начато", preview }) } }) diff --git a/backend/src/services/wireguard-import-apply.test.ts b/backend/src/services/wireguard-import-apply.test.ts new file mode 100644 index 00000000..03c012a1 --- /dev/null +++ b/backend/src/services/wireguard-import-apply.test.ts @@ -0,0 +1,143 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { MikrotikError } from "./mikrotik.js" +import { applyWireguardImport, WireguardImportApplyError } from "./wireguard-import-apply.js" +import type { WgParsedConfig } from "./wireguard-config.js" + +const iface = "/interface/wireguard", addresses = "/ip/address", peers = "/interface/wireguard/peers" +type Row = Record +const parsed: WgParsedConfig = { format: "conf", interface: { name: "own-wg", address: "198.18.255.241/30" }, peers: [{ publicKey: "peer-one", allowedAddresses: ["198.18.255.242/32"] }, { publicKey: "peer-two", allowedAddresses: ["198.18.255.243/32"] }] } +const body = (p: WgParsedConfig["peers"][number], name: string) => ({ interface: name, "public-key": p.publicKey, "allowed-address": p.allowedAddresses.join(",") }) + +function fixture() { + const rows: Record = { + [iface]: [{ ".id": "*F", name: "foreign-wg" }], + [addresses]: [{ ".id": "*A", interface: "foreign-wg", address: "192.0.2.1/24" }], + [peers]: [{ ".id": "*P", interface: "foreign-wg", "public-key": "foreign-peer" }], + } + const baseline = structuredClone(rows), deletes: string[] = [] + let next = 0, putCount = 0 + const client = { + async get(path: string): Promise { return structuredClone(rows[path]) as T }, + async put(path: string, value: Record): Promise { + putCount++ + const row = { ".id": "*OWN/" + (++next), ...value } + rows[path].push(row) + return structuredClone(row) as T + }, + async delete(path: string): Promise { + deletes.push(path) + const split = path.lastIndexOf("/"), base = path.slice(0, split), id = decodeURIComponent(path.slice(split + 1)) + rows[base] = rows[base].filter(row => row[".id"] !== id) + }, + } + return { client, rows, baseline, deletes, puts: () => putCount } +} +const state = (expected: WireguardImportApplyError["state"]) => (error: unknown) => error instanceof WireguardImportApplyError && error.state === expected + +test("baseline read failure prevents every mutation", async () => { + const f = fixture() + f.client.get = async () => { throw new Error("Read unavailable") } + await assert.rejects(applyWireguardImport(f.client, parsed, body), /Read unavailable/) + assert.equal(f.puts(), 0); assert.deepEqual(f.deletes, []) +}) + +test("success cannot be reported when receipt identities do not match native state", async () => { + const f = fixture(), put = f.client.put + f.client.put = async (path, value) => { + const result = await put(path, value) + if (path === iface) f.rows[iface].find(row => row.name === "own-wg")!.name = "different-native-name" + return result + } + await assert.rejects(applyWireguardImport(f.client, parsed, body), state("needs-review")) + assert.deepEqual(f.deletes, []) +}) + +test("success requires native receipt identities and repeat conflicts without mutations", async () => { + const f = fixture() + assert.deepEqual(await applyWireguardImport(f.client, parsed, body), { interfaceName: "own-wg", peersCreated: 2 }) + const before = structuredClone(f.rows), puts = f.puts() + await assert.rejects(applyWireguardImport(f.client, parsed, body), state("conflict")) + assert.deepEqual(f.rows, before); assert.equal(f.puts(), puts); assert.deepEqual(f.deletes, []) +}) + +test("second peer rejection removes only receipt-owned objects, then retry succeeds", async () => { + const f = fixture(), put = f.client.put + f.client.put = async (path, value) => { + if (value["public-key"] === "peer-two") throw new MikrotikError(400, peers, "Rejected secret payload must not be exposed") + return put(path, value) + } + await assert.rejects(applyWireguardImport(f.client, parsed, body), error => state("rolled-back")(error) && !(error as Error).message.includes("secret payload")) + assert.deepEqual(f.rows, f.baseline) + assert.deepEqual(f.deletes, [peers + "/*OWN%2F3", addresses + "/*OWN%2F2", iface + "/*OWN%2F1"]) + f.client.put = put + assert.equal((await applyWireguardImport(f.client, parsed, body)).peersCreated, 2) +}) + +test("address rejection removes the interface, preserves foreign objects", async () => { + const f = fixture(), put = f.client.put + f.client.put = async (path, value) => { if (path === addresses) throw new MikrotikError(400, path, "rejected"); return put(path, value) } + await assert.rejects(applyWireguardImport(f.client, parsed, body), state("rolled-back")) + assert.deepEqual(f.rows, f.baseline) +}) + +for (const path of [iface, addresses, peers]) { + test(`lost response after committed ${path} preserves unknown outcome for review`, async () => { + const f = fixture(), put = f.client.put + f.client.put = async (target, value) => { const receipt = await put(target, value); if (target === path) throw new Error("Connection reset after commit"); return receipt } + await assert.rejects(applyWireguardImport(f.client, parsed, body), state("needs-review")) + assert.deepEqual(f.deletes, []) + }) +} + +test("missing create receipt never permits cleanup by interface name", async () => { + const f = fixture(), put = f.client.put + f.client.put = async (path, value) => { await put(path, value); return undefined as never } + await assert.rejects(applyWireguardImport(f.client, parsed, body), state("needs-review")) + assert.deepEqual(f.deletes, []) +}) + +for (const status of [408, 500]) { + test(`HTTP ${status} after commit does not imply a rejected mutation`, async () => { + const f = fixture(), put = f.client.put + f.client.put = async (path, value) => { await put(path, value); throw new MikrotikError(status, path, "Uncertain upstream response") } + await assert.rejects(applyWireguardImport(f.client, parsed, body), state("needs-review")) + assert.deepEqual(f.deletes, []) + }) +} + +test("receipt referring to baseline identity never permits foreign deletion", async () => { + const f = fixture() + f.client.put = async () => ({ ".id": "*F" }) as never + await assert.rejects(applyWireguardImport(f.client, parsed, body), state("needs-review")) + assert.deepEqual(f.rows, f.baseline); assert.deepEqual(f.deletes, []) +}) + +test("concurrent foreign child stops compensation before any deletion", async () => { + const f = fixture(), put = f.client.put + f.client.put = async (path, value) => { + if (path === peers) { f.rows[peers].push({ ".id": "*CONCURRENT", interface: "own-wg", "public-key": "foreign" }); throw new MikrotikError(400, path, "rejected") } + return put(path, value) + } + await assert.rejects(applyWireguardImport(f.client, parsed, body), state("needs-review")) + assert.deepEqual(f.deletes, []) +}) + +test("changed native identity stops compensation before deletion", async () => { + const f = fixture(), put = f.client.put + f.client.put = async (path, value) => { + if (path === peers) { f.rows[iface].find(row => row.name === "own-wg")!.name = "changed-elsewhere"; throw new MikrotikError(400, path, "rejected") } + return put(path, value) + } + await assert.rejects(applyWireguardImport(f.client, parsed, body), state("needs-review")) + assert.deepEqual(f.deletes, []) +}) + +for (const retain of [false, true]) { + test(`cleanup ${retain ? "unconfirmed" : "rejected"} is never reported as rolled back`, async () => { + const f = fixture(), put = f.client.put + f.client.put = async (path, value) => { if (path === peers) throw new MikrotikError(400, path, "rejected"); return put(path, value) } + f.client.delete = async () => { if (!retain) throw new Error("Delete rejected") } + await assert.rejects(applyWireguardImport(f.client, parsed, body), state("needs-review")) + }) +} diff --git a/backend/src/services/wireguard-import-apply.ts b/backend/src/services/wireguard-import-apply.ts new file mode 100644 index 00000000..476b6057 --- /dev/null +++ b/backend/src/services/wireguard-import-apply.ts @@ -0,0 +1,86 @@ +import { MikrotikError, type MikrotikClient } from "./mikrotik.js" +import type { WgParsedConfig, WgParsedPeer } from "./wireguard-config.js" +import { asRosArray, toRosBody } from "./wireguard-ros.js" + +type Client = Pick +type Row = Record +const paths = ["/interface/wireguard", "/ip/address", "/interface/wireguard/peers"] as const +type Created = { path: typeof paths[number]; id: string; identity: Record } + +export class WireguardImportApplyError extends Error { + constructor(public readonly state: "conflict" | "rolled-back" | "needs-review") { + super(state === "conflict" + ? "Интерфейс с таким именем уже существует. Укажите другое имя в конфигурации" + : state === "rolled-back" + ? "RouterOS отклонил импорт. Созданные этим запросом объекты удалены; исправьте конфигурацию и повторите" + : "Не удалось подтвердить результат импорта или очистки. Проверьте интерфейс на роутере перед повтором") + } +} + +/** Receipts establish ownership; a lost response never authorizes deletion by name alone. */ +export async function applyWireguardImport( + client: Client, + parsed: WgParsedConfig, + peerBody: (peer: WgParsedPeer, interfaceName: string) => Record, +): Promise<{ interfaceName: string; peersCreated: number }> { + const name = parsed.interface.name + const read = async () => Object.fromEntries(await Promise.all(paths.map(async path => [path, asRosArray(await client.get(path))]))) as Record + const baseline = await read() + if (baseline[paths[0]].some(row => row.name === name)) throw new WireguardImportApplyError("conflict") + const created: Created[] = [] + let unconfirmed = false + const owns = (row: Row | undefined, item: Created) => !!row && Object.entries(item.identity).every(([key, value]) => row[key] === value) + const put = async (path: typeof paths[number], body: Record, identity: Record) => { + let receipt: unknown + try { + receipt = await client.put(path, body) + } catch (error) { + // Only an explicit RouterOS rejection permits immediate compensation. + unconfirmed = !(error instanceof MikrotikError && [400, 401, 403, 404, 405, 409, 422].includes(error.statusCode)) + throw error + } + const rows = asRosArray(receipt) + const id = rows.length === 1 ? rows[0]?.[".id"] : undefined + if (typeof id !== "string" || !id || baseline[path].some(row => row[".id"] === id)) { + unconfirmed = true + throw new Error("Unconfirmed create receipt") + } + created.push({ path, id, identity }) + } + try { + await put(paths[0], toRosBody({ name, "listen-port": String(parsed.interface.listenPort ?? 13231), mtu: String(parsed.interface.mtu ?? 1420), "private-key": parsed.interface.privateKey, comment: parsed.interface.comment, disabled: parsed.interface.disabled ? "yes" : undefined }), { name }) + if (parsed.interface.address) { + await put(paths[1], { address: parsed.interface.address, interface: name }, { address: parsed.interface.address, interface: name }) + } + for (const peer of parsed.peers) { + await put(paths[2], peerBody(peer, name), { interface: name, "public-key": peer.publicKey }) + } + const current = await read() + if (created.some(item => !owns(current[item.path].find(row => row[".id"] === item.id), item))) { + unconfirmed = true + throw new Error("Native identities not confirmed") + } + return { interfaceName: name, peersCreated: parsed.peers.length } + } catch { + if (unconfirmed) throw new WireguardImportApplyError("needs-review") + try { + const current = await read() + const ifaceId = created.find(item => item.path === paths[0])?.id + // A concurrent foreign child must never be removed with the interface. + if (paths.slice(1).some(path => current[path].some(row => (row.interface === name || row.interface === ifaceId) && !created.some(item => item.path === path && item.id === row[".id"]))) + || created.some(item => !owns(current[item.path].find(row => row[".id"] === item.id), item))) { + throw new Error("Ownership not confirmed") + } + for (const item of [...created].reverse()) await client.delete(`${item.path}/${encodeURIComponent(item.id)}`) + const after = await read() + if (created.some(item => after[item.path].some(row => row[".id"] === item.id)) + || after[paths[0]].some(row => row.name === name) + || paths.slice(1).some(path => after[path].some(row => row.interface === name || row.interface === ifaceId))) { + throw new Error("Cleanup not confirmed") + } + } catch { + throw new WireguardImportApplyError("needs-review") + } + throw new WireguardImportApplyError("rolled-back") + } +} diff --git a/docs/ux-ui-external-acceptance.md b/docs/ux-ui-external-acceptance.md index 3bbe52b8..2be3a474 100644 --- a/docs/ux-ui-external-acceptance.md +++ b/docs/ux-ui-external-acceptance.md @@ -583,3 +583,17 @@ Native checks подтверждают name,ключевую идентично Все собственные объекты удалены; выбранные WG/peers/IP/routes/IPv4 filter/nat/mangle совпали с baseline на обоих CHR. Для imported conf без comment ownership подтверждалась уникальным fixture name и ожидаемой native public identity, используемой только в памяти. Остались3 основные службы, backend healthy. DB controls до/после:servers2,speedHistory9,retentionDays14,migrations9. Audit revisions намеренно сохранены. Production smoke /dashboard,/servers,/uptime?tab=speed,/settings,/filters,/wireguard →HTTP200,320px fits,0 page/API errors. Публичные JSON проверены на отсутствие ключей/конфигов/паролей. Дополнительная общая локальная регрессия не повторялась после уже проверенного69; production build/typecheck и реальные scoped сценарии выполнены. Следующий12.5.71 — partial apply failures/компенсация и повторный import. Concurrent collision races, rollback/interrupted mutation, неизвестные/повторные поляconf,PSK/multi-address поддержка, ready Peer client template import,private-key UI download,IPv6/throughput/RBAC/multi-instance/Telegram/Portal и остальные внешние протоколы/интеграции остаются открытыми. Локальный коммит без push. + +## 12.5.71 — подтверждённая очистка частичного импорта WireGuard (2026-10-09) + +Локально обнаружено по code: прежний последовательный apply оставлял интерфейс/IP, если поздний peer PUT отклонялся. Добавлен backend/src/services/wireguard-import-apply.ts; route использует сервис вместо встроенного applyParsedConfig. До mutations читаются interface/IP/peer baseline; занятое имя возвращает409. Каждый успешный create должен дать один строковый ROS .id, отсутствовавший в baseline; identity записывается в памяти. Native GET после всех PUT подтверждает созданные ID и name/interface/public-key/address association перед success receipt. Полное сравнение всех настроек/связности остаётся внешней проверкой, не подменяется этим identity check. + +При подтверждённом RouterOS rejection (явные400/401/403/404/405/409/422) сервис читает текущее состояние, проверяет принадлежность всех receipt-owned объектов и отсутствие чужих связанных объектов. Только затем удаляет собственные peers →IP →interface в обратном порядке и перечитывает ресурсы. Подтверждённое отсутствие объектов →502,state rolled-back с понятным сообщением и возможностью исправить/повторить. Удаление существующих baseline IDs, чужих child rows и cleanup только по имени запрещены. Это компенсация текущего запроса, не RouterOS транзакция. + +Потеря ответа после commit,HTTP408/500,неполный create receipt или несовпадение native identity →needs-review; автоматическое удаление не выполняется при неподтверждённом create outcome. Ошибка cleanup/read/изменённая identity/чужой concurrent child →needs-review, успешный rollback не обещается. Если DELETE оборвался после части удалений, состояние надо проверить вручную. Ответы не пересказывают native body с потенциальными ключами. Между ownership GET и DELETE нет распределённой блокировки; конкурентные изменения и потеря процесса требуют отдельной приёмки. + +Route отдельно обрабатывает audit revision failure после успешного apply:502,state applied и сообщение, что конфигурация уже применена, но ревизия не сохранена; сеть не объявляется неприменённой и автоматический rollback уже подтверждённых изменений не запускается. Этот backend audit branch реализован, но отказ реальной БД после apply в71 не воспроизведён. Success HTTP shape прежний; error state добавлен как уточнение, текущий UI показывает message и сохраняет форму. + +Проверки apply unit16/16: native receipt успех, baseline read failure0writes, несовпавшая native identity, повтор после успеха409 без mutations, отказ второго peer/адреса с восстановлением baseline, успешный повтор после rollback, lost reply после iface/IP/peer commit,HTTP408/500,missing/foreign receipt,concurrent чужой peer,изменённая identity,отказ/неподтверждённый DELETE. Исходные preflight22/22 и codec/HTTP4 invalid cases passed; новые apply tests включены в backend test:wireguard. Browser6/6: прежние import сценарии и три новых502 состояния rolled-back/needs-review/applied сохраняют текст/server/error; автоматического повторного POST нет; ручной retry после rolled-back принят успешным receipt. HTTP ответы browser подменены, это не real RouterOS cleanup. Backend и scoped frontend TypeScript, ESLint и diff check passed. UI primitives/ReUI composition не менялись. + +Исправление локальное: runtime backend/frontend479f2ad из70 не обновлялся; production frontend source не менялся. Следующий12.5.72 — production build/deploy только backend, реальная проверка ROS PUT receipts, поздний native rejection на собственных объектах,cleanup/baseline и исправленный повтор с связностью. Прерванный HTTP/процесс,подлинный audit DB failure,конкуренция/multi-instance,IPv6/throughput/RBAC,PSK/multi-address/unknown-conf поля,интеграции и остальные внешние протоколы остаются открытыми. Пароли/RouterOS конфигурация в этом локальном блоке не менялись. Локальный коммит без push. diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 34fdec41..ffa961aa 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -1,6 +1,6 @@ # План реализации UX/UI по пятичасовым окнам -Актуальное продолжение 2026-10-09: 12.5.70 развернул backend/frontend479f2ad и принял реальные отказы400/409 без native изменений, .conf roundtrip с явным Name на собственной WG паре обоих CHR и browser сохранение формы. Handshake/Ping3/3 в обе стороны, cleanup/baseline, DB controls и smoke6 страниц passed. Все три сервера доступны с прежними доступами. Следующий12.5.71 — ошибки частичного применения и повторный импорт WireGuard: сначала профильные проверки компенсации/receipt, затем собственный real fixture. Этап12.5 открыт. +Актуальное продолжение 2026-10-09: 12.5.71 локально вынес WireGuard apply в сервис с receipt ownership, native подтверждением и компенсацией подтверждённых отказов. Потерянный ответ/unknown receipt/чужие объекты/неподтверждённая очистка →needs-review без удаления по имени; успешный повтор после rollback и409 повтор после успеха проверены. Apply unit16/16, preflight22/22, HTTP invalid cases, browser6/6, TypeScript/ESLint passed. Runtime остаётся backend/frontend479f2ad. Следующий12.5.72 — backend Docker build/deploy, реальный receipt/partial rejection/cleanup/retry на собственной WG паре; frontend479f2ad сохранить. Этап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 3d1c6baf..32687eaf 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -1,6 +1,6 @@ # UX/UI: журнал реализации -Актуальное продолжение 2026-10-09: 12.5.70 развернул backend/frontend479f2ad и принял реальные отказы400/409 без native изменений, .conf roundtrip с явным Name на собственной WG паре обоих CHR и browser сохранение формы. Handshake/Ping3/3 в обе стороны, cleanup/baseline, DB controls и smoke6 страниц passed. Все три сервера доступны с прежними доступами. Следующий12.5.71 — ошибки частичного применения и повторный импорт WireGuard: сначала профильные проверки компенсации/receipt, затем собственный real fixture. Этап12.5 открыт. +Актуальное продолжение 2026-10-09: 12.5.71 локально вынес WireGuard apply в сервис с receipt ownership, native подтверждением и компенсацией подтверждённых отказов. Потерянный ответ/unknown receipt/чужие объекты/неподтверждённая очистка →needs-review без удаления по имени; успешный повтор после rollback и409 повтор после успеха проверены. Apply unit16/16, preflight22/22, HTTP invalid cases, browser6/6, TypeScript/ESLint passed. Runtime остаётся backend/frontend479f2ad. Следующий12.5.72 — backend Docker build/deploy, реальный receipt/partial rejection/cleanup/retry на собственной WG паре; frontend479f2ad сохранить. Этап12.5 открыт. Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md). @@ -34,7 +34,21 @@ | 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–70 и ограничения ниже. Runtime backend/frontend479f2ad. | +| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка завершена в границах12.3.83; внешняя12.5 в работе, выполненные блоки1–71 и ограничения ниже. Runtime backend/frontend479f2ad. | + +## 12.5.71 — подтверждённая очистка частичного импорта WireGuard (2026-10-09) + +Локально обнаружено по code: прежний последовательный apply оставлял интерфейс/IP, если поздний peer PUT отклонялся. Добавлен backend/src/services/wireguard-import-apply.ts; route использует сервис вместо встроенного applyParsedConfig. До mutations читаются interface/IP/peer baseline; занятое имя возвращает409. Каждый успешный create должен дать один строковый ROS .id, отсутствовавший в baseline; identity записывается в памяти. Native GET после всех PUT подтверждает созданные ID и name/interface/public-key/address association перед success receipt. Полное сравнение всех настроек/связности остаётся внешней проверкой, не подменяется этим identity check. + +При подтверждённом RouterOS rejection (явные400/401/403/404/405/409/422) сервис читает текущее состояние, проверяет принадлежность всех receipt-owned объектов и отсутствие чужих связанных объектов. Только затем удаляет собственные peers →IP →interface в обратном порядке и перечитывает ресурсы. Подтверждённое отсутствие объектов →502,state rolled-back с понятным сообщением и возможностью исправить/повторить. Удаление существующих baseline IDs, чужих child rows и cleanup только по имени запрещены. Это компенсация текущего запроса, не RouterOS транзакция. + +Потеря ответа после commit,HTTP408/500,неполный create receipt или несовпадение native identity →needs-review; автоматическое удаление не выполняется при неподтверждённом create outcome. Ошибка cleanup/read/изменённая identity/чужой concurrent child →needs-review, успешный rollback не обещается. Если DELETE оборвался после части удалений, состояние надо проверить вручную. Ответы не пересказывают native body с потенциальными ключами. Между ownership GET и DELETE нет распределённой блокировки; конкурентные изменения и потеря процесса требуют отдельной приёмки. + +Route отдельно обрабатывает audit revision failure после успешного apply:502,state applied и сообщение, что конфигурация уже применена, но ревизия не сохранена; сеть не объявляется неприменённой и автоматический rollback уже подтверждённых изменений не запускается. Этот backend audit branch реализован, но отказ реальной БД после apply в71 не воспроизведён. Success HTTP shape прежний; error state добавлен как уточнение, текущий UI показывает message и сохраняет форму. + +Проверки apply unit16/16: native receipt успех, baseline read failure0writes, несовпавшая native identity, повтор после успеха409 без mutations, отказ второго peer/адреса с восстановлением baseline, успешный повтор после rollback, lost reply после iface/IP/peer commit,HTTP408/500,missing/foreign receipt,concurrent чужой peer,изменённая identity,отказ/неподтверждённый DELETE. Исходные preflight22/22 и codec/HTTP4 invalid cases passed; новые apply tests включены в backend test:wireguard. Browser6/6: прежние import сценарии и три новых502 состояния rolled-back/needs-review/applied сохраняют текст/server/error; автоматического повторного POST нет; ручной retry после rolled-back принят успешным receipt. HTTP ответы browser подменены, это не real RouterOS cleanup. Backend и scoped frontend TypeScript, ESLint и diff check passed. UI primitives/ReUI composition не менялись. + +Исправление локальное: runtime backend/frontend479f2ad из70 не обновлялся; production frontend source не менялся. Следующий12.5.72 — production build/deploy только backend, реальная проверка ROS PUT receipts, поздний native rejection на собственных объектах,cleanup/baseline и исправленный повтор с связностью. Прерванный HTTP/процесс,подлинный audit DB failure,конкуренция/multi-instance,IPv6/throughput/RBAC,PSK/multi-address/unknown-conf поля,интеграции и остальные внешние протоколы остаются открытыми. Пароли/RouterOS конфигурация в этом локальном блоке не менялись. Локальный коммит без push. ## 12.5.70 — deploy preflight и собственный .conf roundtrip WireGuard (2026-10-09) diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index 93f6a70a..c7993f1d 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.70 развернул backend/frontend479f2ad и принял реальные отказы400/409 без native изменений, .conf roundtrip с явным Name на собственной WG паре обоих CHR и browser сохранение формы. Handshake/Ping3/3 в обе стороны, cleanup/baseline, DB controls и smoke6 страниц passed. Все три сервера доступны с прежними доступами. Следующий12.5.71 — ошибки частичного применения и повторный импорт WireGuard: сначала профильные проверки компенсации/receipt, затем собственный real fixture. Этап12.5 открыт. +Актуальное продолжение 2026-10-09: 12.5.71 локально вынес WireGuard apply в сервис с receipt ownership, native подтверждением и компенсацией подтверждённых отказов. Потерянный ответ/unknown receipt/чужие объекты/неподтверждённая очистка →needs-review без удаления по имени; успешный повтор после rollback и409 повтор после успеха проверены. Apply unit16/16, preflight22/22, HTTP invalid cases, browser6/6, TypeScript/ESLint passed. Runtime остаётся backend/frontend479f2ad. Следующий12.5.72 — backend Docker build/deploy, реальный receipt/partial rejection/cleanup/retry на собственной WG паре; frontend479f2ad сохранить. Этап12.5 открыт. План: [ux-ui-plan.md](ux-ui-plan.md). Фактические результаты: [ux-ui-progress.md](ux-ui-progress.md). Подготовлен после сводных проверок 11Б; завершение каждого блока требует локального коммита без push. @@ -1748,3 +1748,17 @@ Native checks подтверждают name,ключевую идентично Все собственные объекты удалены; выбранные WG/peers/IP/routes/IPv4 filter/nat/mangle совпали с baseline на обоих CHR. Для imported conf без comment ownership подтверждалась уникальным fixture name и ожидаемой native public identity, используемой только в памяти. Остались3 основные службы, backend healthy. DB controls до/после:servers2,speedHistory9,retentionDays14,migrations9. Audit revisions намеренно сохранены. Production smoke /dashboard,/servers,/uptime?tab=speed,/settings,/filters,/wireguard →HTTP200,320px fits,0 page/API errors. Публичные JSON проверены на отсутствие ключей/конфигов/паролей. Дополнительная общая локальная регрессия не повторялась после уже проверенного69; production build/typecheck и реальные scoped сценарии выполнены. Следующий12.5.71 — partial apply failures/компенсация и повторный import. Concurrent collision races, rollback/interrupted mutation, неизвестные/повторные поляconf,PSK/multi-address поддержка, ready Peer client template import,private-key UI download,IPv6/throughput/RBAC/multi-instance/Telegram/Portal и остальные внешние протоколы/интеграции остаются открытыми. Локальный коммит без push. + +## 12.5.71 — подтверждённая очистка частичного импорта WireGuard (2026-10-09) + +Локально обнаружено по code: прежний последовательный apply оставлял интерфейс/IP, если поздний peer PUT отклонялся. Добавлен backend/src/services/wireguard-import-apply.ts; route использует сервис вместо встроенного applyParsedConfig. До mutations читаются interface/IP/peer baseline; занятое имя возвращает409. Каждый успешный create должен дать один строковый ROS .id, отсутствовавший в baseline; identity записывается в памяти. Native GET после всех PUT подтверждает созданные ID и name/interface/public-key/address association перед success receipt. Полное сравнение всех настроек/связности остаётся внешней проверкой, не подменяется этим identity check. + +При подтверждённом RouterOS rejection (явные400/401/403/404/405/409/422) сервис читает текущее состояние, проверяет принадлежность всех receipt-owned объектов и отсутствие чужих связанных объектов. Только затем удаляет собственные peers →IP →interface в обратном порядке и перечитывает ресурсы. Подтверждённое отсутствие объектов →502,state rolled-back с понятным сообщением и возможностью исправить/повторить. Удаление существующих baseline IDs, чужих child rows и cleanup только по имени запрещены. Это компенсация текущего запроса, не RouterOS транзакция. + +Потеря ответа после commit,HTTP408/500,неполный create receipt или несовпадение native identity →needs-review; автоматическое удаление не выполняется при неподтверждённом create outcome. Ошибка cleanup/read/изменённая identity/чужой concurrent child →needs-review, успешный rollback не обещается. Если DELETE оборвался после части удалений, состояние надо проверить вручную. Ответы не пересказывают native body с потенциальными ключами. Между ownership GET и DELETE нет распределённой блокировки; конкурентные изменения и потеря процесса требуют отдельной приёмки. + +Route отдельно обрабатывает audit revision failure после успешного apply:502,state applied и сообщение, что конфигурация уже применена, но ревизия не сохранена; сеть не объявляется неприменённой и автоматический rollback уже подтверждённых изменений не запускается. Этот backend audit branch реализован, но отказ реальной БД после apply в71 не воспроизведён. Success HTTP shape прежний; error state добавлен как уточнение, текущий UI показывает message и сохраняет форму. + +Проверки apply unit16/16: native receipt успех, baseline read failure0writes, несовпавшая native identity, повтор после успеха409 без mutations, отказ второго peer/адреса с восстановлением baseline, успешный повтор после rollback, lost reply после iface/IP/peer commit,HTTP408/500,missing/foreign receipt,concurrent чужой peer,изменённая identity,отказ/неподтверждённый DELETE. Исходные preflight22/22 и codec/HTTP4 invalid cases passed; новые apply tests включены в backend test:wireguard. Browser6/6: прежние import сценарии и три новых502 состояния rolled-back/needs-review/applied сохраняют текст/server/error; автоматического повторного POST нет; ручной retry после rolled-back принят успешным receipt. HTTP ответы browser подменены, это не real RouterOS cleanup. Backend и scoped frontend TypeScript, ESLint и diff check passed. UI primitives/ReUI composition не менялись. + +Исправление локальное: runtime backend/frontend479f2ad из70 не обновлялся; production frontend source не менялся. Следующий12.5.72 — production build/deploy только backend, реальная проверка ROS PUT receipts, поздний native rejection на собственных объектах,cleanup/baseline и исправленный повтор с связностью. Прерванный HTTP/процесс,подлинный audit DB failure,конкуренция/multi-instance,IPv6/throughput/RBAC,PSK/multi-address/unknown-conf поля,интеграции и остальные внешние протоколы остаются открытыми. Пароли/RouterOS конфигурация в этом локальном блоке не менялись. Локальный коммит без push. diff --git a/docs/ux-ui-test-server.md b/docs/ux-ui-test-server.md index 6b95d087..60147312 100644 --- a/docs/ux-ui-test-server.md +++ b/docs/ux-ui-test-server.md @@ -591,3 +591,17 @@ Native checks подтверждают name,ключевую идентично Все собственные объекты удалены; выбранные WG/peers/IP/routes/IPv4 filter/nat/mangle совпали с baseline на обоих CHR. Для imported conf без comment ownership подтверждалась уникальным fixture name и ожидаемой native public identity, используемой только в памяти. Остались3 основные службы, backend healthy. DB controls до/после:servers2,speedHistory9,retentionDays14,migrations9. Audit revisions намеренно сохранены. Production smoke /dashboard,/servers,/uptime?tab=speed,/settings,/filters,/wireguard →HTTP200,320px fits,0 page/API errors. Публичные JSON проверены на отсутствие ключей/конфигов/паролей. Дополнительная общая локальная регрессия не повторялась после уже проверенного69; production build/typecheck и реальные scoped сценарии выполнены. Следующий12.5.71 — partial apply failures/компенсация и повторный import. Concurrent collision races, rollback/interrupted mutation, неизвестные/повторные поляconf,PSK/multi-address поддержка, ready Peer client template import,private-key UI download,IPv6/throughput/RBAC/multi-instance/Telegram/Portal и остальные внешние протоколы/интеграции остаются открытыми. Локальный коммит без push. + +## 12.5.71 — подтверждённая очистка частичного импорта WireGuard (2026-10-09) + +Локально обнаружено по code: прежний последовательный apply оставлял интерфейс/IP, если поздний peer PUT отклонялся. Добавлен backend/src/services/wireguard-import-apply.ts; route использует сервис вместо встроенного applyParsedConfig. До mutations читаются interface/IP/peer baseline; занятое имя возвращает409. Каждый успешный create должен дать один строковый ROS .id, отсутствовавший в baseline; identity записывается в памяти. Native GET после всех PUT подтверждает созданные ID и name/interface/public-key/address association перед success receipt. Полное сравнение всех настроек/связности остаётся внешней проверкой, не подменяется этим identity check. + +При подтверждённом RouterOS rejection (явные400/401/403/404/405/409/422) сервис читает текущее состояние, проверяет принадлежность всех receipt-owned объектов и отсутствие чужих связанных объектов. Только затем удаляет собственные peers →IP →interface в обратном порядке и перечитывает ресурсы. Подтверждённое отсутствие объектов →502,state rolled-back с понятным сообщением и возможностью исправить/повторить. Удаление существующих baseline IDs, чужих child rows и cleanup только по имени запрещены. Это компенсация текущего запроса, не RouterOS транзакция. + +Потеря ответа после commit,HTTP408/500,неполный create receipt или несовпадение native identity →needs-review; автоматическое удаление не выполняется при неподтверждённом create outcome. Ошибка cleanup/read/изменённая identity/чужой concurrent child →needs-review, успешный rollback не обещается. Если DELETE оборвался после части удалений, состояние надо проверить вручную. Ответы не пересказывают native body с потенциальными ключами. Между ownership GET и DELETE нет распределённой блокировки; конкурентные изменения и потеря процесса требуют отдельной приёмки. + +Route отдельно обрабатывает audit revision failure после успешного apply:502,state applied и сообщение, что конфигурация уже применена, но ревизия не сохранена; сеть не объявляется неприменённой и автоматический rollback уже подтверждённых изменений не запускается. Этот backend audit branch реализован, но отказ реальной БД после apply в71 не воспроизведён. Success HTTP shape прежний; error state добавлен как уточнение, текущий UI показывает message и сохраняет форму. + +Проверки apply unit16/16: native receipt успех, baseline read failure0writes, несовпавшая native identity, повтор после успеха409 без mutations, отказ второго peer/адреса с восстановлением baseline, успешный повтор после rollback, lost reply после iface/IP/peer commit,HTTP408/500,missing/foreign receipt,concurrent чужой peer,изменённая identity,отказ/неподтверждённый DELETE. Исходные preflight22/22 и codec/HTTP4 invalid cases passed; новые apply tests включены в backend test:wireguard. Browser6/6: прежние import сценарии и три новых502 состояния rolled-back/needs-review/applied сохраняют текст/server/error; автоматического повторного POST нет; ручной retry после rolled-back принят успешным receipt. HTTP ответы browser подменены, это не real RouterOS cleanup. Backend и scoped frontend TypeScript, ESLint и diff check passed. UI primitives/ReUI composition не менялись. + +Исправление локальное: runtime backend/frontend479f2ad из70 не обновлялся; production frontend source не менялся. Следующий12.5.72 — production build/deploy только backend, реальная проверка ROS PUT receipts, поздний native rejection на собственных объектах,cleanup/baseline и исправленный повтор с связностью. Прерванный HTTP/процесс,подлинный audit DB failure,конкуренция/multi-instance,IPv6/throughput/RBAC,PSK/multi-address/unknown-conf поля,интеграции и остальные внешние протоколы остаются открытыми. Пароли/RouterOS конфигурация в этом локальном блоке не менялись. Локальный коммит без push. diff --git a/tests/e2e/wireguard-links.spec.ts b/tests/e2e/wireguard-links.spec.ts index 72c2b81e..d8108058 100644 --- a/tests/e2e/wireguard-links.spec.ts +++ b/tests/e2e/wireguard-links.spec.ts @@ -229,6 +229,36 @@ test("WireGuard: ошибка проверки .conf сохраняет клие expect(attempts).toBe(1) }) +for (const [state, message] of [ + ["rolled-back", "RouterOS отклонил импорт. Созданные этим запросом объекты удалены; исправьте конфигурацию и повторите"], + ["needs-review", "Не удалось подтвердить результат импорта или очистки. Проверьте интерфейс на роутере перед повтором"], + ["applied", "Конфигурация применена, но ревизия не сохранена. Обновите данные перед повтором"], +] as const) { + test(`WireGuard: импорт ${state} сохраняет форму и не повторяет запрос автоматически`, async ({ page }) => { + await setup(page, "/wireguard?server=1") + const content = "/interface wireguard add name=wg-retry" + let attempts = 0 + await page.route("**/api/wireguard/import", route => { + attempts++ + if (attempts === 1) return route.fulfill({ status: 502, json: { error: message, state } }) + return route.fulfill({ json: { dryRun: false, preview: { format: "rsc", interface: { name: "wg-retry" }, peers: [] }, applied: { interfaceName: "wg-retry", peersCreated: 0 } } }) + }) + await page.getByRole("button", { name: "Импорт", exact: true }).click() + const dialog = page.getByRole("dialog", { name: "Импорт конфига WireGuard" }) + await dialog.getByLabel("Конфигурация WireGuard", { exact: true }).fill(content) + await dialog.getByRole("button", { name: "Применить на роутер" }).click() + await expect(dialog.getByRole("alert")).toContainText(message) + await expect(dialog.getByLabel("Конфигурация WireGuard", { exact: true })).toHaveValue(content) + await expect(dialog.getByLabel("Сервер", { exact: true })).toHaveValue("1") + expect(attempts).toBe(1) + if (state === "rolled-back") { + await dialog.getByRole("button", { name: "Применить на роутер" }).click() + await expect(dialog).toHaveCount(0) + expect(attempts).toBe(2) + } + }) +} + test("WireGuard: импорт удерживает форму до ответа и разрешает повтор", async ({ page }) => { await setup(page, "/wireguard?server=1") let attempts = 0