fix(ipsec): различать частичный и неподтверждённый результат операций
This commit is contained in:
1 parent
a83e4734df
commit
43b00e0740
15 files changed
+263
-37
No files matched your search
@@ -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,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"
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user