fix(ipsec): различать частичный и неподтверждённый результат операций

This commit is contained in:
Denozordec committed 2026-10-09 16:06:33 +07:00
1 parent a83e4734df
commit 43b00e0740
15 files changed
+263 -37

No files matched your search

+1 -1
View File
@@ -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",
+2 -2
View File
@@ -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<typeof fetchIpsecState>[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<string, string>) => { 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<string, string>) => { 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"
+22
View File
@@ -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<typeof fetchIpsecState>[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<string,string>) => { 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<string, unknown>) => 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")
+65 -26
View File
@@ -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<string, string> = {}
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)
}
})
}
+5
View File
@@ -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
}
@@ -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")
+27
View File
@@ -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<string, (...args: unknown[]) => Promise<unknown>>
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<typeof trackIpsecMutation>
+2 -2
View File
@@ -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<string, string> {
"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,
+3 -1
View File
@@ -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 и учётные записи. Зафиксировать ожидаемые изменения и способ восстановления стенда.
+1 -1
View File
@@ -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). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат.
+18 -2
View File
@@ -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)
+16
View File
@@ -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.
+4 -2
View File
@@ -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.
@@ -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
}
}
+23
View File
@@ -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)
})
}