fix(wireguard): очищать подтверждённые объекты при отказе импорта
This commit is contained in:
1 parent
f9980d7d7e
commit
e01454b720
10 files changed
+331
-58
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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<Array<{ name?: string }>>("/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 })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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<string, string>
|
||||
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<string, Row[]> = {
|
||||
[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<T>(path: string): Promise<T> { return structuredClone(rows[path]) as T },
|
||||
async put<T>(path: string, value: Record<string, string>): Promise<T> {
|
||||
putCount++
|
||||
const row = { ".id": "*OWN/" + (++next), ...value }
|
||||
rows[path].push(row)
|
||||
return structuredClone(row) as T
|
||||
},
|
||||
async delete(path: string): Promise<void> {
|
||||
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"))
|
||||
})
|
||||
}
|
||||
@@ -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<MikrotikClient, "get" | "put" | "delete">
|
||||
type Row = Record<string, string>
|
||||
const paths = ["/interface/wireguard", "/ip/address", "/interface/wireguard/peers"] as const
|
||||
type Created = { path: typeof paths[number]; id: string; identity: Record<string, string> }
|
||||
|
||||
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<string, string>,
|
||||
): Promise<{ interfaceName: string; peersCreated: number }> {
|
||||
const name = parsed.interface.name
|
||||
const read = async () => Object.fromEntries(await Promise.all(paths.map(async path => [path, asRosArray<Row>(await client.get(path))]))) as Record<typeof paths[number], Row[]>
|
||||
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<string, string>, identity: Record<string, string>) => {
|
||||
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<Row>(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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
+1
-1
@@ -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). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат.
|
||||
|
||||
|
||||
+16
-2
@@ -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)
|
||||
|
||||
|
||||
+15
-1
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user