diff --git a/backend/package.json b/backend/package.json index 75ace35e..884f555b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -28,7 +28,7 @@ "test:ros-file": "tsx src/services/ros-file-contents.test.ts", "test:scheduler": "tsx src/services/scheduler-running.test.ts && tsx src/services/scheduler-recorded-run.test.ts && tsx src/routes/scheduler.test.ts", "test:backups": "tsx src/services/backup-scheduler-run.test.ts && tsx src/services/backup-restore.test.ts && tsx src/services/backup-deletion.test.ts && tsx src/services/backup-dto.test.ts && tsx src/services/backup-storage-update.test.ts && tsx src/services/backup-job-execution.test.ts && tsx src/routes/backups-operations.test.ts && tsx src/services/s3-backup-client.test.ts", - "test:ipsec": "tsx src/services/ipsec-config.test.ts && tsx src/services/ipsec-state.test.ts && tsx src/routes/ipsec-history.test.ts && tsx src/services/ipsec-restore.test.ts", + "test:ipsec": "tsx src/services/ipsec-config.test.ts && tsx src/services/ipsec-state.test.ts && tsx src/routes/ipsec-history.test.ts && tsx src/services/ipsec-restore.test.ts && tsx src/services/ipsec-operation.test.ts && tsx src/routes/ipsec-mutation.test.ts", "test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/routes/vxlan.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts && tsx src/services/gre-catalog-poll.test.ts && tsx src/routes/filters-gre-catalog.test.ts && tsx src/routes/bgp-catalog.test.ts && tsx src/services/bgp-catalog-parse.test.ts && tsx src/routes/ospf-catalog.test.ts && tsx src/services/ospf-observed-values.test.ts", "test": "npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:alerts-telegram && npm run test:auth && npm run test:wireguard && npm run test:ipsec && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:scheduler && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file && npm run test:containers && npm run test:certificates && npm run test:netflow-settings && npm run test:netflow-purge && npm run test:evolution && npm run test:system-database", "test:evolution": "tsx src/services/evobgp-catalog.test.ts && tsx src/routes/evobgp-catalog.test.ts && tsx src/routes/evobgp-settings.test.ts && tsx src/plugins/auth.evolution-settings.test.ts", diff --git a/backend/src/routes/ipsec-history.test.ts b/backend/src/routes/ipsec-history.test.ts index 27a3ab17..3836666e 100644 --- a/backend/src/routes/ipsec-history.test.ts +++ b/backend/src/routes/ipsec-history.test.ts @@ -2,12 +2,12 @@ import assert from "node:assert/strict" import Fastify from "fastify" import { validatorCompiler, serializerCompiler } from "@fastify/type-provider-zod" import { createIpsecRoutes } from "./ipsec.js" -import { MikrotikClient } from "../services/mikrotik.js" +import { MikrotikClient, MikrotikError } from "../services/mikrotik.js" import { fetchIpsecState } from "../services/ipsec-live.js" const original = MikrotikClient.fromServer const server = { id: 1, name: "test", host: "192.0.2.1", enabled: true } as Parameters[0] let patches = 0, recordings = 0, nativeDisabled = "false", rejectWrite = false -MikrotikClient.fromServer = () => ({ get: async (path: string) => path === "/ip/ipsec/peer" ? [{ ".id": "*P", name: "ike", "exchange-mode": "ike2", disabled: nativeDisabled }] : [], patch: async (_path: string, body: Record) => { if (rejectWrite) throw Error("write rejected"); patches++; nativeDisabled = body.disabled! } }) as unknown as MikrotikClient +MikrotikClient.fromServer = () => ({ get: async (path: string) => path === "/ip/ipsec/peer" ? [{ ".id": "*P", name: "ike", "exchange-mode": "ike2", disabled: nativeDisabled }] : [], put: async () => {}, post: async () => {}, delete: async () => {}, patch: async (_path: string, body: Record) => { if (rejectWrite) throw new MikrotikError(403, "/ip/ipsec/peer", "write rejected"); patches++; nativeDisabled = body.disabled! } }) as unknown as MikrotikClient try { for (const historySaved of [false, true]) { patches = recordings = 0; nativeDisabled = "false" diff --git a/backend/src/routes/ipsec-mutation.test.ts b/backend/src/routes/ipsec-mutation.test.ts new file mode 100644 index 00000000..91bd088d --- /dev/null +++ b/backend/src/routes/ipsec-mutation.test.ts @@ -0,0 +1,22 @@ +import assert from "node:assert/strict" +import Fastify from "fastify" +import { validatorCompiler, serializerCompiler } from "@fastify/type-provider-zod" +import { createIpsecRoutes } from "./ipsec.js" +import { MikrotikClient, MikrotikError } from "../services/mikrotik.js" +import { fetchIpsecState } from "../services/ipsec-live.js" +const server = { id: 1, name: "test", host: "192.0.2.1", enabled: true } as Parameters[0] +const original = MikrotikClient.fromServer +let writes = 0, records = 0, fault = "" +const identities = [{ ".id": "*I", peer: "ike", "auth-method": "pre-shared-key", "remote-id": "key-id:alice", "mode-config": "mc-ipsec-alice", comment: "MikrotikManager:ipsec user=Alice" }, { ".id": "*OTHER", peer: "ike", "auth-method": "pre-shared-key", "remote-id": "key-id:other", "mode-config": "mc-ipsec-other", comment: "MikrotikManager:ipsec user=Other" }] +MikrotikClient.fromServer = () => ({ get: async (p: string) => p === "/ip/ipsec/peer" ? [{ ".id": "*P", name: "ike", "exchange-mode": "ike2" }] : p === "/ip/ipsec/identity" ? identities : p === "/ip/ipsec/mode-config" ? [{ ".id": "*MC", name: "mc-ipsec-alice", address: "198.18.81.250" }, { ".id": "*OTHERMC", name: "mc-ipsec-other", address: "198.18.81.249" }] : [], put: async () => { writes++ }, post: async () => { writes++ }, delete: async () => { writes++ }, patch: async (path: string, body: Record) => { writes++; if (fault === "partial" && writes === 2) throw new MikrotikError(403, path, "rejected"); Object.assign(identities.find(i => path.endsWith(i[".id"]))!, body); if (fault === "lost-ack") throw Error("socket closed after application") } }) as unknown as MikrotikClient +const app = Fastify(); app.setValidatorCompiler(validatorCompiler); app.setSerializerCompiler(serializerCompiler) +await app.register(createIpsecRoutes({ findServer: async () => server, recordRevision: async () => { records++; return {created:false,revision:{} as never} } }), { prefix: "/api" }) +const patch = (payload: Record) => app.inject({ method: "PATCH", url: "/api/ipsec/users/1/identity:*I", payload }) +try { +for (const body of [{name:"New",staticIp:"198.18.81.249"},{name:"Other"},{remoteId:"key-id:other"}]) { writes=0; const r=await patch(body); assert.equal(r.statusCode,409); assert.equal(writes,0); assert.equal(identities[0]!.comment,"MikrotikManager:ipsec user=Alice") } +writes=records=0; fault="partial"; let r=await patch({name:"New",psk:"new-test-secret"}); assert.equal(r.statusCode,502); assert.equal(r.json().state,"partial"); assert.equal(r.json().confirmedWrites,1); assert.equal(records,0); assert.equal(identities[0]!.comment,"MikrotikManager:ipsec user=New") +writes=records=0; fault="lost-ack"; r=await patch({name:"Unknown"}); assert.equal(r.statusCode,502); assert.equal(r.json().state,"unknown"); assert.equal(r.json().confirmedWrites,0); assert.equal(identities[0]!.comment,"MikrotikManager:ipsec user=Unknown"); assert.equal(records,0) +writes=records=0; fault=""; r=await patch({remoteId:"fresh"}); assert.equal(r.statusCode,200); assert.equal(identities[0]!["remote-id"],"key-id:fresh"); assert.equal(writes,1); assert.equal(records,1) +} finally { await app.close(); MikrotikClient.fromServer=original } +console.log("IPsec HTTP mutations: no-write conflicts, acknowledged partial failure, applied-but-lost ack and remote ID normalization") + diff --git a/backend/src/routes/ipsec.ts b/backend/src/routes/ipsec.ts index 176af5b7..0dbbb94a 100644 --- a/backend/src/routes/ipsec.ts +++ b/backend/src/routes/ipsec.ts @@ -31,6 +31,7 @@ import { selectIke2Peers, selectSharedIke2Identity, userModeConfigName, + normalizeIpsecRemoteId, } from "../services/ipsec-config.js" import { ensureIpsecPeer, @@ -78,6 +79,7 @@ import { removeManagedIpsecServer } from "../services/ipsec-cleanup.js" import { parseDbServerId } from "../utils/server-id.js" import { isIP } from "node:net" import { randomBytes } from "node:crypto" +import { trackIpsecMutation, type IpsecMutationProgress } from "../services/ipsec-operation.js" import { z } from "zod" @@ -93,11 +95,14 @@ function rosIdParam(v: string): string { return decodeURIComponent(v) } -function errReply(reply: FastifyReply, e: unknown) { +function errReply(reply: FastifyReply, e: unknown, progress?: IpsecMutationProgress) { if (e instanceof IpsecRestoreConflict) return reply.status(409).send({ error: `${e.message}. Изменений не было` }) if (e instanceof IpsecHistoryError) return reply.status(502).send({ error: e.message, state: "applied", historyRecorded: false }) const msg = e instanceof MikrotikError ? e.message : e instanceof Error ? e.message : String(e) - return reply.status(502).send({ error: `RouterOS: ${msg}` }) + const receipt = progress?.receipt() + const guidance = receipt?.state === "applied" ? "Операция выполнена, но итоговый ответ не получен." + : receipt?.state === "partial" ? "Часть изменений применена." : receipt?.state === "unknown" ? "Результат операции не подтверждён." : "" + return reply.status(502).send({ error: `RouterOS: ${msg}${guidance ? `. ${guidance} Обновите данные перед повтором` : ""}`, ...receipt }) } /** @@ -206,7 +211,8 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs } const server = await getEnabledIpsecServerById(body.serverId) if (!server) return reply.status(404).send({ error: "Сервер не найден" }) - const client = MikrotikClient.fromServer(server) + const progress = trackIpsecMutation(MikrotikClient.fromServer(server)) + const client = progress.client try { const caCert = await ensureCaCertificate(client, body.caDaysValid) const serverCert = await ensureServerCertificate(client, { @@ -225,11 +231,12 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs if (body.createNatRule) { await ensureNatRule(client, body.poolCidr, ipsecManagedComment("интернет клиентам VPN")) } + progress.complete() await recordIpsec(server, "apply") const state = await fetchIpsecState(server) return reply.status(201).send(mapServerSummary(state, mapClients(state))) } catch (e) { - return errReply(reply, e) + return errReply(reply, e, progress) } }) @@ -239,13 +246,15 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs const removeCertificates = q.removeCertificates !== "false" const server = await getEnabledIpsecServerById(serverIdParam(serverId)) if (!server) return reply.status(404).send({ error: "Сервер не найден" }) - const client = MikrotikClient.fromServer(server) + const progress = trackIpsecMutation(MikrotikClient.fromServer(server)) + const client = progress.client try { await removeManagedIpsecServer(client, removeCertificates) + progress.complete() await recordIpsec(server, "apply") return reply.send({ ok: true }) } catch (e) { - return errReply(reply, e) + return errReply(reply, e, progress) } }) @@ -257,7 +266,8 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs const body = parsed.data const server = await getEnabledIpsecServerById(body.serverId) if (!server) return reply.status(404).send({ error: "Сервер не найден" }) - const client = MikrotikClient.fromServer(server) + const progress = trackIpsecMutation(MikrotikClient.fromServer(server)) + const client = progress.client try { const state = await fetchIpsecState(server) const ike2Peers = selectIke2Peers(state.peers) @@ -289,6 +299,7 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs if (body.peerName && (peer.name ?? "").trim() !== body.peerName.trim()) return reply.status(404).send({ error: "Выбранный peer не найден" }) if (mapClients(state).some(c => c.name === body.name.trim()) || (body.authMethod === "certificate" && state.certs.some(c => c.name === clientCertName(body.name)))) return reply.status(409).send({ error: "Клиент уже существует" }) const peerName = (peer.name ?? "").trim() + if (body.authMethod === "pre-shared-key" && state.identities.some(i => i.peer === peerName && i["auth-method"] === "pre-shared-key" && i["remote-id"] === normalizeIpsecRemoteId(body.remoteId ?? body.name))) return reply.status(409).send({ error: "Remote ID уже используется клиентом этого peer" }) const serverEndpoint = String(serverCertRow?.["common-name"] ?? "").trim() || server.host const dns = sharedMc?.["static-dns"]?.trim() || undefined @@ -319,7 +330,8 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs daysValid: body.daysValid ?? 1825, }) if (issued.existed) { - return reply.status(409).send({ error: `Клиент с сертификатом ${issued.certName} уже существует` }) + const receipt = progress.receipt() + return reply.status(409).send({ error: `Клиент с сертификатом ${issued.certName} уже существует${receipt ? ". Часть изменений применена; обновите данные перед повтором" : ""}`, ...receipt }) } certName = issued.certName } @@ -360,6 +372,7 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs }) } + progress.complete() await recordIpsec(server, "apply") const fresh = await fetchIpsecState(server) const clients = mapClients(fresh) @@ -368,7 +381,7 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs ) ?? clients.find((c) => c.certificateName === certName) return reply.status(201).send({ client: created ?? null, bundle }) } catch (e) { - return errReply(reply, e) + return errReply(reply, e, progress) } }) @@ -382,7 +395,8 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs if ("staticIp" in d && d.staticIp && isIP(d.staticIp.trim()) !== 4) return reply.status(400).send({ error: "Некорректный статический IPv4 адрес" }) const server = await getEnabledIpsecServerById(serverIdParam(serverId)) if (!server) return reply.status(404).send({ error: "Сервер не найден" }) - const client = MikrotikClient.fromServer(server) + const progress = trackIpsecMutation(MikrotikClient.fromServer(server)) + const client = progress.client try { const state = await fetchIpsecState(server) const targetRosId = rosIdParam(rosId) @@ -407,6 +421,16 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs const sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME) const sharedMcName = (sharedMc?.name ?? "").trim() const newName = d.name?.trim() || oldName + const normalizedRemoteId = d.remoteId === undefined ? undefined : normalizeIpsecRemoteId(d.remoteId) + + if (d.name && newName !== oldName && mapClients(state).some(c => c.name === newName && (identity ? c.rosId !== `identity:${identity[".id"]}` : c.certName !== certRow?.name))) return reply.status(409).send({ error: "Имя уже используется другим клиентом" }) + if (identity && normalizedRemoteId !== undefined && state.identities.some(i => i[".id"] !== identity[".id"] && i.peer === identity.peer && i["auth-method"] === "pre-shared-key" && i["remote-id"] === normalizedRemoteId)) return reply.status(409).send({ error: "Remote ID уже используется клиентом этого peer" }) + + // Validate conflicts before changing even the display name. + if (d.staticIp) { + const currentMc = (identity?.["mode-config"] ?? "").trim() + if (takenStaticIps(state.modeConfigs.filter(m => !currentMc || m.name !== currentMc)).includes(d.staticIp.trim())) return reply.status(409).send({ error: "IP уже назначен другому клиенту" }) + } if (d.name && d.name !== oldName) { if (identity) { @@ -440,6 +464,7 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs const name = userModeConfigName(newName) await putUserModeConfig(client, name, ip, ipsecManagedComment(`клиент ${newName.trim()}`)) await patchIdentity(client, identity[".id"]!, { "mode-config": name }) + if (currentMc && currentMc !== sharedMcName && currentMc !== name) await deleteUserModeConfig(client, currentMc) } } else if (!identity && certRow && d.staticIp) { // cert-клиент без identity: персональная identity (match-by=certificate) + статический mode-config @@ -466,16 +491,17 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs if (identity) { const patch: Record = {} if (d.psk) patch.secret = d.psk - if (d.remoteId !== undefined) patch["remote-id"] = d.remoteId + if (d.remoteId !== undefined) patch["remote-id"] = normalizedRemoteId! if (d.disabled === true) patch.disabled = "yes" if (d.disabled === false) patch.disabled = "no" if (Object.keys(patch).length) await patchIdentity(client, identity[".id"]!, patch) } + progress.complete() await recordIpsec(server, "apply") return reply.send({ ok: true }) } catch (e) { - return errReply(reply, e) + return errReply(reply, e, progress) } }) @@ -485,7 +511,8 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs const removeCertificate = q.removeCertificate !== "false" const server = await getEnabledIpsecServerById(serverIdParam(serverId)) if (!server) return reply.status(404).send({ error: "Сервер не найден" }) - const client = MikrotikClient.fromServer(server) + const progress = trackIpsecMutation(MikrotikClient.fromServer(server)) + const client = progress.client try { const state = await fetchIpsecState(server) const targetRosId = rosIdParam(rosId) @@ -518,10 +545,11 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs await client.removeCertificate(certName) } } + progress.complete() await recordIpsec(server, "apply") return reply.send({ ok: true }) } catch (e) { - return errReply(reply, e) + return errReply(reply, e, progress) } }) @@ -534,7 +562,8 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs const d = parsed.data const server = await getEnabledIpsecServerById(serverIdParam(serverId)) if (!server) return reply.status(404).send({ error: "Сервер не найден" }) - const client = MikrotikClient.fromServer(server) + const progress = trackIpsecMutation(MikrotikClient.fromServer(server)) + const client = progress.client try { const state = await fetchIpsecState(server) const peer = state.peers.find((p) => String(p[".id"] ?? "") === rosIdParam(rosId)) @@ -548,10 +577,11 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs if (d.profile !== undefined) body.profile = d.profile if (d.disabled !== undefined) body.disabled = d.disabled ? "yes" : "no" if (Object.keys(body).length) await patchPeer(client, rosIdParam(rosId), body) + progress.complete() await recordIpsec(server, "apply") return reply.send({ ok: true }) } catch (e) { - return errReply(reply, e) + return errReply(reply, e, progress) } }) @@ -560,7 +590,8 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs const q = req.query as { force?: string } const server = await getEnabledIpsecServerById(serverIdParam(serverId)) if (!server) return reply.status(404).send({ error: "Сервер не найден" }) - const client = MikrotikClient.fromServer(server) + const progress = trackIpsecMutation(MikrotikClient.fromServer(server)) + const client = progress.client try { const state = await fetchIpsecState(server) const peer = state.peers.find((p) => String(p[".id"] ?? "") === rosIdParam(rosId)) @@ -576,10 +607,11 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs }) } await deletePeer(client, rosIdParam(rosId)) + progress.complete() await recordIpsec(server, "apply") return reply.send({ ok: true }) } catch (e) { - return errReply(reply, e) + return errReply(reply, e, progress) } }) @@ -592,7 +624,8 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs const q = req.query as { force?: string } const server = await getEnabledIpsecServerById(serverIdParam(serverId)) if (!server) return reply.status(404).send({ error: "Сервер не найден" }) - const client = MikrotikClient.fromServer(server) + const progress = trackIpsecMutation(MikrotikClient.fromServer(server)) + const client = progress.client try { const state = await fetchIpsecState(server) const name = parsed.data.name @@ -611,10 +644,11 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs }) } await client.delete(`/certificate/${encodeURIComponent(cert[".id"])}`) + progress.complete() await recordIpsec(server, "apply") return reply.send({ ok: true }) } catch (e) { - return errReply(reply, e) + return errReply(reply, e, progress) } }) @@ -627,7 +661,8 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs const body = parsed.data const server = await getEnabledIpsecServerById(serverIdParam(serverId)) if (!server) return reply.status(404).send({ error: "Сервер не найден" }) - const client = MikrotikClient.fromServer(server) + const progress = trackIpsecMutation(MikrotikClient.fromServer(server)) + const client = progress.client try { const state = await fetchIpsecState(server) const target = resolveIpsecClientTarget(state, rosIdParam(rosId)) @@ -674,7 +709,7 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs } return reply.send(bundle) } catch (e) { - return errReply(reply, e) + return errReply(reply, e, progress) } }) @@ -688,7 +723,8 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs const { name, passphrase } = parsed.data const server = await getEnabledIpsecServerById(serverIdParam(serverId)) if (!server) return reply.status(404).send({ error: "Сервер не найден" }) - const client = MikrotikClient.fromServer(server) + const progress = trackIpsecMutation(MikrotikClient.fromServer(server)) + const client = progress.client try { const state = await fetchIpsecState(server) const cert = state.certs.find((c) => String(c.name ?? "").trim() === name) @@ -707,7 +743,7 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs }) return reply.send(bundle) } catch (e) { - return errReply(reply, e) + return errReply(reply, e, progress) } }) @@ -730,6 +766,7 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs requestedServerId: parseDbServerId(body?.serverId), }) if (!loaded.ok) return reply.status(loaded.status).send({ error: loaded.error }) + let progress: IpsecMutationProgress | undefined try { const desired = parseIpsecSnapshot(loaded.row.payload) const state = await fetchIpsecRestoreState(loaded.server) @@ -746,12 +783,14 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs nat: state.nat, policies: state.policies, }) - await executeRosOps(state.client, ops) + progress = trackIpsecMutation(state.client) + await executeRosOps(progress.client, ops) + progress.complete() await recordIpsec(loaded.server, "rollback") const result = await listIpsec({ serverId: String(loaded.server.id) }) return reply.send({ ok: true, ...result }) } catch (e) { - return errReply(reply, e) + return errReply(reply, e, progress) } }) } diff --git a/backend/src/services/ipsec-config.ts b/backend/src/services/ipsec-config.ts index 397baa9f..57eb554f 100644 --- a/backend/src/services/ipsec-config.ts +++ b/backend/src/services/ipsec-config.ts @@ -311,3 +311,8 @@ export function buildClientInstructions(args: { "Адрес выдаётся автоматически при подключении (mode-config).", ].join("\n") } + +export function normalizeIpsecRemoteId(value: string): string { + const id = value.trim() + return id && !id.includes(":") && !["auto", "ignore"].includes(id) ? `key-id:${id}` : id +} diff --git a/backend/src/services/ipsec-operation.test.ts b/backend/src/services/ipsec-operation.test.ts new file mode 100644 index 00000000..d6ecb452 --- /dev/null +++ b/backend/src/services/ipsec-operation.test.ts @@ -0,0 +1,20 @@ +import assert from "node:assert/strict" +import { trackIpsecMutation } from "./ipsec-operation.js" +import { MikrotikError, type MikrotikClient } from "./mikrotik.js" +const rejected = new MikrotikError(403, "/ip/ipsec/peer", "forbidden") +for (const steps of [0, 1]) for (const failure of [rejected, new Error("socket closed"), new MikrotikError(500, "/ip/ipsec/peer", "server error")]) { + let attempts = 0 + const client = { patch: async () => { attempts++; if (attempts > steps) throw failure }, put: async () => {}, delete: async () => {}, post: async () => {} } as unknown as MikrotikClient + const tracked = trackIpsecMutation(client) + if (steps) await tracked.client.patch("/own", {}) + await assert.rejects(tracked.client.patch("/own", {})) + assert.equal(tracked.receipt()?.state, failure === rejected ? steps ? "partial" : undefined : "unknown") + assert.equal(tracked.receipt()?.confirmedWrites ?? 0, steps) +} +const client = { patch: async () => {}, put: async () => {}, delete: async () => {}, post: async () => {}, removeCertificate: async function () { await this.delete("/certificate/own") } } as unknown as MikrotikClient +const tracked = trackIpsecMutation(client) +await tracked.client.removeCertificate("own") +assert.equal(tracked.receipt()?.confirmedWrites, 1) +tracked.complete(); assert.deepEqual(tracked.receipt(), { state: "applied", confirmedWrites: 1, needsReview: true }) +console.log("IPsec progress: native 4xx, partial writes, transport/5xx uncertainty, nested methods and completed operation") + diff --git a/backend/src/services/ipsec-operation.ts b/backend/src/services/ipsec-operation.ts new file mode 100644 index 00000000..a476ed4e --- /dev/null +++ b/backend/src/services/ipsec-operation.ts @@ -0,0 +1,27 @@ +import { MikrotikClient, MikrotikError } from "./mikrotik.js" + +/** Request-local evidence only: never claims that an unacknowledged request failed to apply. */ +export function trackIpsecMutation(client: MikrotikClient) { + let confirmedWrites = 0, uncertainWrite = false, completed = false + const methods = client as unknown as Record Promise> + for (const method of ["put", "patch", "delete", "post"]) { + const original = methods[method]!.bind(client) + methods[method] = async (...args) => { + try { const result = await original(...args); confirmedWrites++; return result } + catch (error) { + if (!(error instanceof MikrotikError && error.statusCode >= 400 && error.statusCode < 500)) uncertainWrite = true + throw error + } + } + } + return { + client, + complete: () => { completed = true }, + receipt: () => { + const state = completed ? "applied" : uncertainWrite ? "unknown" : confirmedWrites ? "partial" : undefined + return state ? { state, confirmedWrites, needsReview: true } : undefined + }, + } +} +export type IpsecMutationProgress = ReturnType + diff --git a/backend/src/services/ipsec-ros.ts b/backend/src/services/ipsec-ros.ts index b11e18a7..b6e7990c 100644 --- a/backend/src/services/ipsec-ros.ts +++ b/backend/src/services/ipsec-ros.ts @@ -1,5 +1,5 @@ import type { MikrotikClient } from "./mikrotik.js" -import { +import { normalizeIpsecRemoteId, ipsecManagedComment, isManagedIpsecModeConfig, selectIke2Peers, @@ -197,7 +197,7 @@ export function identityRosBody(f: IdentityFields): Record { "remote-certificate": f.remoteCertificate, "match-by": f.authMethod === "certificate" && f.remoteCertificate ? "certificate" : undefined, secret: f.secret, - "remote-id": f.remoteId && !f.remoteId.includes(":") && !["auto", "ignore"].includes(f.remoteId) ? `key-id:${f.remoteId}` : f.remoteId, + "remote-id": f.remoteId === undefined ? undefined : normalizeIpsecRemoteId(f.remoteId), "mode-config": f.modeConfig, "generate-policy": "port-strict", comment: f.comment, diff --git a/docs/ux-ui-external-acceptance.md b/docs/ux-ui-external-acceptance.md index 93b0a596..dfd278fa 100644 --- a/docs/ux-ui-external-acceptance.md +++ b/docs/ux-ui-external-acceptance.md @@ -1,6 +1,6 @@ # UX/UI: протокол внешней приёмки 12.5 -Статус на2026-10-09: backend ux-ipsec80/frontend ux-ipsec77, backend healthy; основной блок IPsec77 и VXLAN76 приняты в описанных границах. В12.5.67 исправленный Peer .conf принят как клиентский шаблон: API6/6, browser4/4, server endpoint и placeholders; настоящий RSC roundtrip через UI/native/handshake/Ping принят в12.5.68, .conf с явнымName/отказы400/409/сохранение формы приняты в70; поздний native rejection/подтверждённая компенсация/retry приняты в72; изолированный audit INSERT permission denial принят в74; остальные неизвестные исходы/DB faults открыты. Мобильная ширина WireGuard Sheets принята в12.5.64. Мобильная GRE форма и native сохранение на обоих CHR приняты в12.5.57. WireGuard handshake/короткий Ping/native API/read-only UI приняты в границах12.5.58; 12.5.59 воспроизвёл оставшиеся peers/IP; 12.5.60 развернул исправление и принял обычное удаление own WG интерфейса с peer/IPv4 адресом на обоих CHR. 12.5.62 принял полный WireGuard UI create/peer/disable/enable/Ping/delete на обоих CHR; устойчивое меню развёрнуто. Устойчивость меню GRE при live refresh на обоих CHR принята в12.5.55. GRE peer metadata и реальный lifecycle/API/UI/двусторонний Ping подтверждены в12.5.53. Browser retry/checkpoint на реальном HTTP/PG принят в границах12.5.43–45; Telegram sender responses подменены, реальная доставка не проверена. PG-1/2/3/4/5 приняты в описанных границах, PG-6 integrated для одного isolated instance, PG-7 real JWT/processcounts и browser restore passed. PG-8 real PG/native и browser unknown→history done приняты с fault injection после настоящего COMMIT. ROS-8/9 real API/SQL/native приняты в границах12.5.23; browser pair/duration change и delete приняты в12.5.24. ROS-2 authentication failure принят в12.5.25. PortalSSO, физический TCP disconnect, мультиэкземпляры и остальные ROS/Q/интеграции остаются открытыми. [Паспорт](ux-ui-test-server.md), [журнал](ux-ui-progress.md). VXLAN75: собственная native пара/Ping/catalog/URL/export приняты, create/delete API отсутствует; selected baseline восстановлен. +Статус на2026-10-09: backend ux-ipsec81/frontend ux-ipsec77, backend healthy; основной блок IPsec77 и VXLAN76 приняты в описанных границах. В12.5.67 исправленный Peer .conf принят как клиентский шаблон: API6/6, browser4/4, server endpoint и placeholders; настоящий RSC roundtrip через UI/native/handshake/Ping принят в12.5.68, .conf с явнымName/отказы400/409/сохранение формы приняты в70; поздний native rejection/подтверждённая компенсация/retry приняты в72; изолированный audit INSERT permission denial принят в74; остальные неизвестные исходы/DB faults открыты. Мобильная ширина WireGuard Sheets принята в12.5.64. Мобильная GRE форма и native сохранение на обоих CHR приняты в12.5.57. WireGuard handshake/короткий Ping/native API/read-only UI приняты в границах12.5.58; 12.5.59 воспроизвёл оставшиеся peers/IP; 12.5.60 развернул исправление и принял обычное удаление own WG интерфейса с peer/IPv4 адресом на обоих CHR. 12.5.62 принял полный WireGuard UI create/peer/disable/enable/Ping/delete на обоих CHR; устойчивое меню развёрнуто. Устойчивость меню GRE при live refresh на обоих CHR принята в12.5.55. GRE peer metadata и реальный lifecycle/API/UI/двусторонний Ping подтверждены в12.5.53. Browser retry/checkpoint на реальном HTTP/PG принят в границах12.5.43–45; Telegram sender responses подменены, реальная доставка не проверена. PG-1/2/3/4/5 приняты в описанных границах, PG-6 integrated для одного isolated instance, PG-7 real JWT/processcounts и browser restore passed. PG-8 real PG/native и browser unknown→history done приняты с fault injection после настоящего COMMIT. ROS-8/9 real API/SQL/native приняты в границах12.5.23; browser pair/duration change и delete приняты в12.5.24. ROS-2 authentication failure принят в12.5.25. PortalSSO, физический TCP disconnect, мультиэкземпляры и остальные ROS/Q/интеграции остаются открытыми. [Паспорт](ux-ui-test-server.md), [журнал](ux-ui-progress.md). VXLAN75: собственная native пара/Ping/catalog/URL/export приняты, create/delete API отсутствует; selected baseline восстановлен. IPsec78: реальный PSK responder established, mode-config198.18.78.10 и online=true подтверждены API; точные собственные объекты удалены. Сертификатный/DN online, late failure и history restore остаются открытыми. [Отчёт](ux-ui/acceptance/ipsec-mode-config-2026-10-09.json), [журнал](ux-ui-progress.md). @@ -8,6 +8,8 @@ IPsec79: сертификатный responder online=true/static mode-config198. IPsec80: real restore staticIp/disabled, повтор без дубликатов, foreign-reference409 и cleanup приняты. Первоначальный неполный образ откатан; исправленный runtime healthy. [Отчёт](ux-ui/acceptance/ipsec-restore-2026-10-09.json). +IPsec81: partial/unknown native fault injection, preflight конфликтов, typed Remote ID, удаление старого mode-config и isolated JWT/RBAC приняты. Рабочая авторизация не менялась, физический TCP disconnect не моделировался. [Отчёт](ux-ui/acceptance/ipsec-mutations-2026-10-09.json). + ## Паспорт стенда Перед внешним запуском зафиксировать отдельные PostgreSQL и RouterOS тестовые устройства, версии server/client PostgreSQL и RouterOS, HEAD backend/frontend, роли пользователей, число экземпляров backend/планировщика/отправителей и способ их остановки. Для Telegram использовать тестовый чат, для остальных интеграций — тестовые endpoints и учётные записи. Зафиксировать ожидаемые изменения и способ восстановления стенда. diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 4237b1c2..f52839be 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -1,6 +1,6 @@ # План реализации UX/UI по пятичасовым окнам -Актуальное продолжение 2026-10-09: 12.5.80 исправил восстановление IPsec mode-config и порядок зависимостей; реальный откат staticIp/disabled, повтор без дубликатов и запрет чужой ссылки приняты. Backend ux-ipsec80 healthy, frontend ux-ipsec77. Следующий12.5.81 — частичные ошибки операций и неопределённый результат при потере ответа RouterOS. Этап12.5 открыт. +Актуальное продолжение 2026-10-09: 12.5.81 внедрил исходы partial/unknown/applied, preflight конфликтов и очистку старого mode-config; native/API fault injection и isolated JWT/RBAC приняты. Backend ux-ipsec81 healthy, frontend ux-ipsec77; 7 профильных E2E прошли. Следующий12.5.82 — NAT/связность сертификатного клиента; остальные границы IPsec перечислены ниже. Этап12.5 открыт. Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-09. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 78e180df..edce8a5f 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -1,6 +1,6 @@ # UX/UI: журнал реализации -Актуальное продолжение 2026-10-09: 12.5.80 исправил восстановление IPsec mode-config и порядок зависимостей; реальный откат staticIp/disabled, повтор без дубликатов и запрет чужой ссылки приняты. Backend ux-ipsec80 healthy, frontend ux-ipsec77. Следующий12.5.81 — частичные ошибки операций и неопределённый результат при потере ответа RouterOS. Этап12.5 открыт. +Актуальное продолжение 2026-10-09: 12.5.81 внедрил исходы partial/unknown/applied, preflight конфликтов и очистку старого mode-config; native/API fault injection и isolated JWT/RBAC приняты. Backend ux-ipsec81 healthy, frontend ux-ipsec77; 7 профильных E2E прошли. Следующий12.5.82 — NAT/связность сертификатного клиента; остальные границы IPsec перечислены ниже. Этап12.5 открыт. Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md). @@ -36,7 +36,23 @@ | 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–80 и ограничения ниже. Runtime backend ux-ipsec80/frontend ux-ipsec77. | +| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка завершена в границах12.3.83; внешняя12.5 в работе, выполненные блоки1–81 и ограничения ниже. Runtime backend ux-ipsec81/frontend ux-ipsec77. | + +## 12.5.81 — IPsec: частичные операции, потерянное подтверждение и права (2026-10-09) + +**Результат.** Request-local tracker учитывает подтверждённые PUT/PATCH/DELETE/POST, включая вложенные методы клиента. Ответы отказа: partial — подтверждённые записи до явного4xx; unknown — неподтверждённая запись при сетевой/5xx ошибке; applied — весь mutation завершён, но финальный ответ не получен. Поля confirmedWrites/needsReview и текст требуют обновить данные перед повтором. Tracker не делает автоматический откат и не является durable/idempotency receipt: потеря соединения клиент↔backend и повтор после рестарта требуют отдельного разбора. Отказ до первой записи не называется applied. История после apply/rollback продолжает явно сообщать о null capture/record согласно79. + +**Preflight и консистентность.** Конфликты имени/staticIP/Remote ID проверяются до переименования. Create и PATCH нормализуют обычный PSK Remote ID в key-id: одинаково; duplicate Remote ID одного peer отклоняется. После rename + смены staticIP identity переключается на новый mode-config, старый owned и неиспользуемый удаляется по существующему guarded helper. Поздний отказ такого удаления теперь возвращает корректный частичный результат. + +**Проверки.** npm test:ipsec: config +80 state checks, HTTP history/restore, progress evidence (4xx/partial/transport/5xx/nested/completed) и HTTP mutations. Backend TypeScript/scoped ESLint/diff check прошли. Playwright7/7: три новых320px сценария partial/unknown/applied сохраняют ввод, показывают ошибку и не повторяют запрос автоматически; существующие формы peer/cert/PSK/edit также прошли. UI-код не изменён, frontend не пересобирался. + +**Реальная приёмка.** Собственная PSK конфигурация наCHR1. Production409 при занятом IP сохранил native comment. Изолированный compiled route: первый native patch подтверждён, следующий искусственно403 → partial/confirmedWrites1; настоящий native patch применён, затем injected exception вместо ACK → unknown/confirmedWrites0. Независимый GET подтвердил оба изменения имени. Это управляемая инъекция после реального применения, не физический TCP disconnect. Production PATCH обычного Remote ID подтвердил typed key-id; rename/staticIP подтвердили новый адрес и отсутствие старого mode-config. + +**JWT/RBAC.** В изолированном worker env AUTH_REQUIRED=true и случайный JWT secret только в памяти; тот же auth plugin/requireAuth + compiled IPsec routes. No token/invalid token401, reader PATCH/DELETE/restore/export403, reader GET200, writer native peer disable/enable200. Native объекты при запрете сохранены. Рабочий backend остаётся AUTH_REQUIRED=false, Portal SSO и live browser authenticated flow не заявляются. [Отчёт](ux-ui/acceptance/ipsec-mutations-2026-10-09.json). + +**Очистка и runtime.** До удаления сверены точные ID и ожидаемые имена/comments; свои objects удалены, baseline IDs четырёх коллекций совпали. Один backend build/deploy: mmapp-test/backend:ux-ipsec81/version1.0.0-test.ipsec81, image sha256:2a7d8ab317867f2df4826b7c477659eeba689b7e9d8806676ca030fc4f099abac, healthy; frontend ux-ipsec77. Entry file/node --check/pg_restore18.6 и compose --wait прошли. Runner apt layer #18 CACHED: оптимизация80 подтверждена на изменённом коде/версии. Context ipsec80 + восемь backend-файлов до коммита, archive SHA111eb30a5621eb85e2f7e421a25c22ab70f8073093a83cf0a0f67663163004ec; label ipsec81, не Git SHA. .env backup приватный; миграций нет. Один локальный коммит без push. + +Следующий12.5.82: реальная NAT/связность сертификатного клиента. Открыты физические transport disconnect/durable retry, multi-instance, Portal SSO/browser auth, IPv6/клиентские ОС/throughput, сложные DN и полный PKI/policy/profile/proposal restore. Не расширять эти границы по результатам fault injection. ## 12.5.80 — IPsec: восстановление истории и зависимости (2026-10-09) diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index ed1990b0..3b19317c 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -1926,3 +1926,19 @@ flowchart LR **Runtime.** mmapp-test/backend:ux-ipsec80/version1.0.0-test.ipsec80, image sha256:c28600a85fb609c910a030594ff4ea2fea91d87415744935c5e49e24b013f578, healthy; frontend ux-ipsec77. Context ipsec79 + шесть backend-файлов рабочего состояния до коммита; corrected archive SHA680f90b1f88119702aa5761a5d71e77bb6c512b4a67aea4f606993606f1e586b; label revision=ipsec80, не Git SHA. Приватный .env backup; миграций нет. Один локальный коммит без push. **Границы.** Принят откат управляемых peer/identity/mode-config/pool/nat в проверенном срезе. Политики и profile/proposal/CA/key материал не пересоздаются; удалённый сертификат/profile требует ручного восстановления перед restore. Частичные write failures и транспортная неопределённость остаются12.5.81; NAT/интернет/IPv6/клиентские ОС/RBAC/throughput и сложные DN открыты. + +## 12.5.81 — IPsec: частичные операции, потерянное подтверждение и права (2026-10-09) + +**Результат.** Request-local tracker учитывает подтверждённые PUT/PATCH/DELETE/POST, включая вложенные методы клиента. Ответы отказа: partial — подтверждённые записи до явного4xx; unknown — неподтверждённая запись при сетевой/5xx ошибке; applied — весь mutation завершён, но финальный ответ не получен. Поля confirmedWrites/needsReview и текст требуют обновить данные перед повтором. Tracker не делает автоматический откат и не является durable/idempotency receipt: потеря соединения клиент↔backend и повтор после рестарта требуют отдельного разбора. Отказ до первой записи не называется applied. История после apply/rollback продолжает явно сообщать о null capture/record согласно79. + +**Preflight и консистентность.** Конфликты имени/staticIP/Remote ID проверяются до переименования. Create и PATCH нормализуют обычный PSK Remote ID в key-id: одинаково; duplicate Remote ID одного peer отклоняется. После rename + смены staticIP identity переключается на новый mode-config, старый owned и неиспользуемый удаляется по существующему guarded helper. Поздний отказ такого удаления теперь возвращает корректный частичный результат. + +**Проверки.** npm test:ipsec: config +80 state checks, HTTP history/restore, progress evidence (4xx/partial/transport/5xx/nested/completed) и HTTP mutations. Backend TypeScript/scoped ESLint/diff check прошли. Playwright7/7: три новых320px сценария partial/unknown/applied сохраняют ввод, показывают ошибку и не повторяют запрос автоматически; существующие формы peer/cert/PSK/edit также прошли. UI-код не изменён, frontend не пересобирался. + +**Реальная приёмка.** Собственная PSK конфигурация наCHR1. Production409 при занятом IP сохранил native comment. Изолированный compiled route: первый native patch подтверждён, следующий искусственно403 → partial/confirmedWrites1; настоящий native patch применён, затем injected exception вместо ACK → unknown/confirmedWrites0. Независимый GET подтвердил оба изменения имени. Это управляемая инъекция после реального применения, не физический TCP disconnect. Production PATCH обычного Remote ID подтвердил typed key-id; rename/staticIP подтвердили новый адрес и отсутствие старого mode-config. + +**JWT/RBAC.** В изолированном worker env AUTH_REQUIRED=true и случайный JWT secret только в памяти; тот же auth plugin/requireAuth + compiled IPsec routes. No token/invalid token401, reader PATCH/DELETE/restore/export403, reader GET200, writer native peer disable/enable200. Native объекты при запрете сохранены. Рабочий backend остаётся AUTH_REQUIRED=false, Portal SSO и live browser authenticated flow не заявляются. [Отчёт](ux-ui/acceptance/ipsec-mutations-2026-10-09.json). + +**Очистка и runtime.** До удаления сверены точные ID и ожидаемые имена/comments; свои objects удалены, baseline IDs четырёх коллекций совпали. Один backend build/deploy: mmapp-test/backend:ux-ipsec81/version1.0.0-test.ipsec81, image sha256:2a7d8ab317867f2df4826b7c477659eeba689b7e9d8806676ca030fc4f099abac, healthy; frontend ux-ipsec77. Entry file/node --check/pg_restore18.6 и compose --wait прошли. Runner apt layer #18 CACHED: оптимизация80 подтверждена на изменённом коде/версии. Context ipsec80 + восемь backend-файлов до коммита, archive SHA111eb30a5621eb85e2f7e421a25c22ab70f8073093a83cf0a0f67663163004ec; label ipsec81, не Git SHA. .env backup приватный; миграций нет. Один локальный коммит без push. + +Следующий12.5.82: реальная NAT/связность сертификатного клиента. Открыты физические transport disconnect/durable retry, multi-instance, Portal SSO/browser auth, IPv6/клиентские ОС/throughput, сложные DN и полный PKI/policy/profile/proposal restore. Не расширять эти границы по результатам fault injection. diff --git a/docs/ux-ui-test-server.md b/docs/ux-ui-test-server.md index d971478c..7513987c 100644 --- a/docs/ux-ui-test-server.md +++ b/docs/ux-ui-test-server.md @@ -1,6 +1,6 @@ # Тестовый сервер UX/UI — 2026-10-09 -Сервер161.104.46.222 предоставлен для временного тестирования. Backend ux-ipsec80/version1.0.0-test.ipsec80, frontend ux-ipsec77/version1.0.0-test.ipsec77; backend/PG healthy, frontend HTTP/browser passed. Основной блок IPsec77 принят в границах журнала, own fixtures/fake DB rows удалены; каталог flash CHR2 сохранён из-за неподтверждённого ownership. SSH/оба CHR доступны, пароли прежние. +Сервер161.104.46.222 предоставлен для временного тестирования. Backend ux-ipsec81/version1.0.0-test.ipsec81, frontend ux-ipsec77/version1.0.0-test.ipsec77; backend/PG healthy, frontend HTTP/browser passed. Основной блок IPsec77 принят в границах журнала, own fixtures/fake DB rows удалены; каталог flash CHR2 сохранён из-за неподтверждённого ownership. SSH/оба CHR доступны, пароли прежние. | Параметр | Проверенное состояние | | --- | --- | @@ -14,7 +14,7 @@ | PostgreSQL | postgres:18-alpine,18.6;mmapp-test-postgres healthy;SELECT version() passed | | Данные | Отдельный Compose project mmapp-test,том mmapp-test_mmapp-pgdata и каталог ./data | | Доступ | PostgreSQL не публикуется;frontend3000/backend8000 привязаны к127.0.0.1 после запуска | -| Образы приложения | mmapp-test/backend:ux-ipsec80 и mmapp-test/frontend:ux-ipsec77; запущены | +| Образы приложения | mmapp-test/backend:ux-ipsec81 и mmapp-test/frontend:ux-ipsec77; запущены | Compose SHA256:3052bd6ab5d9865f97605cebfb5ff27a4410cd3357b81153a65a87fb7c92582f. OptionalBACKEND_IMAGE_TAG позволяетобновлятьbackendотдельно. Локальный и удалённый файлы совпали. Шаблоны: [Compose](../deploy/docker-compose.test.yml), [env без секретов](../deploy/env.test.example). @@ -24,6 +24,8 @@ IPsec79: сертификатный responder online=true/static mode-config198. IPsec80: real restore staticIp/disabled, повтор без дубликатов, foreign-reference409 и cleanup приняты. Первоначальный неполный образ откатан; исправленный runtime healthy. [Отчёт](ux-ui/acceptance/ipsec-restore-2026-10-09.json). +IPsec81: partial/unknown native fault injection, preflight конфликтов, typed Remote ID, удаление старого mode-config и isolated JWT/RBAC приняты. Рабочая авторизация не менялась, физический TCP disconnect не моделировался. [Отчёт](ux-ui/acceptance/ipsec-mutations-2026-10-09.json). + ## Развёртывание приложения Чистые frontend/backend build contexts подготовлены по .gitea/workflows/docker.yml из закреплённого Git HEAD;образы собраны на сервере без push. Локально docker CLI сейчас отсутствует, на сервере доступен Docker. В корневом .dockerignore исключён backend, поэтому обычный backend build из корня не повторяет CI: использовать отдельный backend context, как main workflow. Не передавать .env.local,backend/.env,ключи и локальные node_modules/.next. diff --git a/docs/ux-ui/acceptance/ipsec-mutations-2026-10-09.json b/docs/ux-ui/acceptance/ipsec-mutations-2026-10-09.json new file mode 100644 index 00000000..38bd729d --- /dev/null +++ b/docs/ux-ui/acceptance/ipsec-mutations-2026-10-09.json @@ -0,0 +1,54 @@ +{ + "date": "2026-10-09", + "name": "mm-ipsec81-1791536576686", + "serverId": 1, + "checks": [ + { + "scenario": "conflicting IP does not rename client", + "httpStatus": 409, + "nativeCommentPreserved": true + }, + { + "scenario": "partial", + "httpStatus": 502, + "confirmedWrites": 1, + "nativeCommentApplied": true, + "faultInjection": "reject second patch before transmission" + }, + { + "scenario": "unknown", + "httpStatus": 502, + "confirmedWrites": 0, + "nativeCommentApplied": true, + "faultInjection": "throw after successful native patch; not physical TCP disconnect" + }, + { + "scenario": "plain remote ID normalized on PATCH", + "httpStatus": 200, + "nativeTypedId": true + }, + { + "scenario": "rename and static IP change releases old mode config", + "httpStatus": 200, + "oldRemoved": true + }, + { + "scenario": "isolated AUTH_REQUIRED=true on compiled IPsec routes", + "noToken": 401, + "invalidToken": 401, + "readerWrite": 403, + "readerRead": 200, + "readerDeleteRestoreExport": 403, + "writerPatch": 200, + "nativeDeniedPreserved": true, + "nativeAuthorizedToggleConfirmed": true, + "productionAuthChanged": false + } + ], + "cleanup": { + "/ip/ipsec/identity": true, + "/ip/ipsec/peer": true, + "/ip/ipsec/mode-config": true, + "/ip/ipsec/profile": true + } +} \ No newline at end of file diff --git a/tests/e2e/ipsec-mutation-result.spec.ts b/tests/e2e/ipsec-mutation-result.spec.ts new file mode 100644 index 00000000..f75d2c16 --- /dev/null +++ b/tests/e2e/ipsec-mutation-result.spec.ts @@ -0,0 +1,23 @@ +import { test, expect } from "@playwright/test" +for (const state of ["partial", "unknown", "applied"] as const) { + test(`IPsec: ${state} сохраняет ввод и предупреждает перед повтором`, async ({ page }) => { + await page.setViewportSize({ width: 320, height: 740 }) + let attempts = 0 + const client = {id:"1:identity:*I",rosId:"identity:*I",serverId:"1",serverName:"CHR",name:"Alice",authMethod:"pre-shared-key",kind:"psk",online:false,disabled:false,managed:true} + await page.route("**/health", route => route.fulfill({json:{ok:true}})) + await page.route("**/api/**", route => { + const path = new URL(route.request().url()).pathname + if (route.request().method()==="PATCH" && path.startsWith("/api/ipsec/users")) { attempts++; return route.fulfill({status:502,json:{state,confirmedWrites:state==="unknown"?0:1,error:"Результат операции требует проверки. Обновите данные перед повтором"}}) } + return route.fulfill({json:path==="/api/auth/config"?{required:false}:path==="/api/ipsec"?{servers:[],clients:[client]}:path==="/api/servers"?[{id:1,name:"CHR",host:"192.0.2.1",enabled:true,type:"exit-node",country:"RU",status:"online"}]:{}}) + }) + await page.goto("/ipsec") + await page.getByRole("button",{name:"Alice",exact:true}).click() + const sheet=page.getByRole("dialog") + await sheet.getByLabel("Имя клиента").fill("Changed") + await sheet.getByRole("button",{name:"Сохранить",exact:true}).click() + await expect(sheet.getByRole("alert")).toContainText("Обновите данные перед повтором") + await expect(sheet.getByLabel("Имя клиента")).toHaveValue("Changed") + expect(attempts).toBe(1) + expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true) + }) +}