diff --git a/backend/Dockerfile b/backend/Dockerfile index b5fa9f63..9ed93083 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -37,6 +37,15 @@ RUN npm run build -w @mmapp/contracts \ FROM node:22-bookworm-slim AS runner WORKDIR /app +# Keep system packages independent of application changes and version tags. +RUN apt-get update \ + && apt-get install -y --no-install-recommends gnupg wget ca-certificates \ + && wget -qO- https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/pgdg.gpg \ + && echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" > /etc/apt/sources.list.d/pgdg.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends postgresql-client-18 \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /app/data ARG APP_VERSION=dev ENV NODE_ENV=production ENV PORT=8000 @@ -50,13 +59,5 @@ COPY --from=build /app/backend/package.json ./backend/package.json COPY --from=build /app/backend/drizzle ./backend/drizzle # Nested install from lockfile (dotenv etc.) — ESM resolves from /app/backend/dist → ../node_modules COPY --from=build /app/backend/node_modules ./backend/node_modules -RUN apt-get update \ - && apt-get install -y --no-install-recommends gnupg wget ca-certificates \ - && wget -qO- https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/pgdg.gpg \ - && echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" > /etc/apt/sources.list.d/pgdg.list \ - && apt-get update \ - && apt-get install -y --no-install-recommends postgresql-client-18 \ - && rm -rf /var/lib/apt/lists/* \ - && mkdir -p /app/data EXPOSE 8000 CMD ["node", "backend/dist/index.js"] diff --git a/backend/package.json b/backend/package.json index 24399dfe..75ace35e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -28,7 +28,7 @@ "test:ros-file": "tsx src/services/ros-file-contents.test.ts", "test:scheduler": "tsx src/services/scheduler-running.test.ts && tsx src/services/scheduler-recorded-run.test.ts && tsx src/routes/scheduler.test.ts", "test:backups": "tsx src/services/backup-scheduler-run.test.ts && tsx src/services/backup-restore.test.ts && tsx src/services/backup-deletion.test.ts && tsx src/services/backup-dto.test.ts && tsx src/services/backup-storage-update.test.ts && tsx src/services/backup-job-execution.test.ts && tsx src/routes/backups-operations.test.ts && tsx src/services/s3-backup-client.test.ts", - "test:ipsec": "tsx src/services/ipsec-config.test.ts && tsx src/services/ipsec-state.test.ts && tsx src/routes/ipsec-history.test.ts", + "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:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/routes/vxlan.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts && tsx src/services/gre-catalog-poll.test.ts && tsx src/routes/filters-gre-catalog.test.ts && tsx src/routes/bgp-catalog.test.ts && tsx src/services/bgp-catalog-parse.test.ts && tsx src/routes/ospf-catalog.test.ts && tsx src/services/ospf-observed-values.test.ts", "test": "npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:alerts-telegram && npm run test:auth && npm run test:wireguard && npm run test:ipsec && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:scheduler && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file && npm run test:containers && npm run test:certificates && npm run test:netflow-settings && npm run test:netflow-purge && npm run test:evolution && npm run test:system-database", "test:evolution": "tsx src/services/evobgp-catalog.test.ts && tsx src/routes/evobgp-catalog.test.ts && tsx src/routes/evobgp-settings.test.ts && tsx src/plugins/auth.evolution-settings.test.ts", diff --git a/backend/src/routes/ipsec.ts b/backend/src/routes/ipsec.ts index ce519f92..176af5b7 100644 --- a/backend/src/routes/ipsec.ts +++ b/backend/src/routes/ipsec.ts @@ -72,7 +72,7 @@ import { loadRevisionForRestore, type ConfigRevisionSource, } from "../services/config-revisions.js" -import { parseIpsecSnapshot, planIpsecRestore } from "../services/entity-snapshots.js" +import { IpsecRestoreConflict, parseIpsecSnapshot, planIpsecRestore } from "../services/entity-snapshots.js" import { executeRosOps } from "../services/ros-ops.js" import { removeManagedIpsecServer } from "../services/ipsec-cleanup.js" import { parseDbServerId } from "../utils/server-id.js" @@ -94,6 +94,7 @@ function rosIdParam(v: string): string { } function errReply(reply: FastifyReply, e: unknown) { + 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}` }) @@ -732,12 +733,18 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs try { const desired = parseIpsecSnapshot(loaded.row.payload) const state = await fetchIpsecRestoreState(loaded.server) + const missingCertificates = desired.identities.flatMap(i => [i.certificate, i.remoteCertificate]).filter(n => n && !state.certificateNames.includes(n)) + if (missingCertificates.length) return reply.status(409).send({ error: "На устройстве отсутствуют сертификаты ревизии. Откат не восстанавливает ключи PKI; изменений не было" }) + const profiles = await state.client.get("/ip/ipsec/profile") + if (!Array.isArray(profiles) || profiles.some(p => !p || typeof p.name !== "string")) throw new Error("Некорректный список IPsec profiles; откат не начат") + if (desired.peers.some(p => p.profile && !profiles.some(live => live.name === p.profile))) return reply.status(409).send({ error: "На устройстве отсутствует profile ревизии. Восстановите profile перед откатом; изменений не было" }) const ops = planIpsecRestore(desired, { peers: state.peers, identities: state.identities, modeConfigs: state.modeConfigs, pools: state.pools, nat: state.nat, + policies: state.policies, }) await executeRosOps(state.client, ops) await recordIpsec(loaded.server, "rollback") diff --git a/backend/src/services/entity-snapshots.ts b/backend/src/services/entity-snapshots.ts index 485572ba..e520967b 100644 --- a/backend/src/services/entity-snapshots.ts +++ b/backend/src/services/entity-snapshots.ts @@ -1,10 +1,12 @@ /** Канонические снапшоты и планы restore для firewall / WireGuard / GRE / IPsec. */ -import { isIpsecManagedComment } from "./ipsec-config.js" +import { isIpsecManagedComment, isManagedIpsecModeConfig } from "./ipsec-config.js" import { readGreComment, writeGreComment } from "./gre-comment.js" export type FirewallFamily = "ip" | "ip6" export type FirewallTable = "filter" | "nat" | "mangle" | "raw" +export class IpsecRestoreConflict extends Error {} + export type RosWriteOp = | { op: "put"; path: string; body: Record } | { op: "post"; path: string; body: Record } @@ -718,6 +720,8 @@ function ipsecIdentityBody(i: IpsecSnapshotIdentity): Record { function ipsecModeConfigBody(m: IpsecSnapshotModeConfig): Record { return compactBody({ name: m.name, + responder: "yes", + "address-prefix-length": m.address ? "32" : undefined, "address-pool": m.addressPool, address: m.address, "static-dns": m.staticDns, @@ -747,16 +751,35 @@ export function planIpsecRestore( modeConfigs: IpsecLiveModeConfig[] pools: IpsecLivePool[] nat: IpsecLiveNat[] + policies?: Array<{ peer?: string; dynamic?: string; comment?: string }> }, ): RosWriteOp[] { const desired = canonicalIpsecSnapshot(desiredInput) const ops: RosWriteOp[] = [] + const removeDependents: RosWriteOp[] = [] const managedPeers = current.peers.filter((p) => isIpsecManagedComment(p.comment)) const managedIdentities = current.identities.filter((i) => isIpsecManagedComment(i.comment)) - const managedModeConfigs = current.modeConfigs.filter((m) => isIpsecManagedComment(m.comment)) + const managedModeConfigs = current.modeConfigs.filter(isManagedIpsecModeConfig) const managedPools = current.pools.filter((p) => isIpsecManagedComment(p.comment)) const managedNat = current.nat.filter((n) => isIpsecManagedComment(n.comment)) + const removedPeers = managedPeers.filter(p => !desired.peers.some(w => w.name === p.name)) + const removedConfigs = managedModeConfigs.filter(m => !desired.modeConfigs.some(w => w.name === m.name)) + const removedPools = managedPools.filter(p => !desired.pools.some(w => w.name === p.name)) + for (const identity of current.identities.filter(i => !isIpsecManagedComment(i.comment))) { + if (removedPeers.some(p => p.name === identity.peerName) || removedConfigs.some(m => m.name === identity.modeConfig)) throw new IpsecRestoreConflict("Откат затрагивает ссылку чужой IPsec identity") + } + for (const config of current.modeConfigs.filter(m => !isManagedIpsecModeConfig(m))) { + if (removedPools.some(p => p.name === config.addressPool)) throw new IpsecRestoreConflict("Откат затрагивает пул чужого mode-config") + } + for (const policy of current.policies ?? []) { + if (policy.dynamic === "true" || policy.dynamic === "yes" || isIpsecManagedComment(policy.comment)) continue + if (removedPeers.some(p => (policy.peer ?? "").split(",").map(n => n.trim()).includes(p.name))) throw new IpsecRestoreConflict("Откат затрагивает peer чужой IPsec policy") + } + for (const [wanted, live, managed] of [[desired.peers, current.peers, managedPeers], [desired.pools, current.pools, managedPools]] as const) { + if (wanted.some(w => live.some(l => l.name === w.name && !managed.some(m => m.rosId === l.rosId)))) throw new IpsecRestoreConflict("Имя объекта ревизии занято чужой конфигурацией") + } + for (const identity of managedIdentities) { if (!desired.identities.some((i) => i.comment === identity.comment)) { ops.push({ op: "delete", path: `/ip/ipsec/identity/${identity.rosId}` }) @@ -764,17 +787,17 @@ export function planIpsecRestore( } for (const mc of managedModeConfigs) { if (!desired.modeConfigs.some((m) => m.name === mc.name)) { - ops.push({ op: "delete", path: `/ip/ipsec/mode-config/${mc.rosId}` }) + removeDependents.push({ op: "delete", path: `/ip/ipsec/mode-config/${mc.rosId}` }) } } for (const peer of managedPeers) { if (!desired.peers.some((p) => p.name === peer.name)) { - ops.push({ op: "delete", path: `/ip/ipsec/peer/${peer.rosId}` }) + removeDependents.push({ op: "delete", path: `/ip/ipsec/peer/${peer.rosId}` }) } } for (const pool of managedPools) { if (!desired.pools.some((p) => p.name === pool.name)) { - ops.push({ op: "delete", path: `/ip/pool/${pool.rosId}` }) + removeDependents.push({ op: "delete", path: `/ip/pool/${pool.rosId}` }) } } for (const rule of managedNat) { @@ -789,18 +812,18 @@ export function planIpsecRestore( if (!live) ops.push({ op: "put", path: "/ip/ipsec/peer", body }) else ops.push({ op: "patch", path: `/ip/ipsec/peer/${live.rosId}`, body }) } - for (const want of desired.modeConfigs) { - const live = managedModeConfigs.find((m) => m.name === want.name) - const body = ipsecModeConfigBody(want) - if (!live) ops.push({ op: "put", path: "/ip/ipsec/mode-config", body }) - else ops.push({ op: "patch", path: `/ip/ipsec/mode-config/${live.rosId}`, body }) - } for (const want of desired.pools) { const live = managedPools.find((p) => p.name === want.name) const body = ipsecPoolBody(want) if (!live) ops.push({ op: "put", path: "/ip/pool", body }) else ops.push({ op: "patch", path: `/ip/pool/${live.rosId}`, body }) } + for (const want of desired.modeConfigs) { + const live = managedModeConfigs.find((m) => m.name === want.name) + const body = ipsecModeConfigBody(want) + if (!live) ops.push({ op: "put", path: "/ip/ipsec/mode-config", body }) + else ops.push({ op: "patch", path: `/ip/ipsec/mode-config/${live.rosId}`, body }) + } for (const want of desired.identities) { const live = managedIdentities.find((i) => i.comment === want.comment) const body = ipsecIdentityBody(want) @@ -814,7 +837,7 @@ export function planIpsecRestore( else ops.push({ op: "patch", path: `/ip/firewall/nat/${live.rosId}`, body }) } - return ops + return [...ops, ...removeDependents] } export function canonicalGreSnapshot(input: { diff --git a/backend/src/services/ipsec-live.ts b/backend/src/services/ipsec-live.ts index 15641919..d790a921 100644 --- a/backend/src/services/ipsec-live.ts +++ b/backend/src/services/ipsec-live.ts @@ -92,6 +92,8 @@ export interface RosIpsecPolicy { proposal?: string template?: string comment?: string + peer?: string + dynamic?: string } export interface RosFirewallNat { @@ -174,7 +176,7 @@ function mapModeConfig(server: ServerRow, m: RosIpsecModeConfig): IpsecModeConfi splitDns: m["split-dns"] || undefined, staticDns: m["static-dns"] || undefined, comment: m.comment || undefined, - managed: isIpsecManagedComment(m.comment), + managed: isManagedIpsecModeConfig(m), } } @@ -499,13 +501,16 @@ export async function fetchIpsecRestoreState(server: ServerRow): Promise<{ modeConfigs: IpsecLiveModeConfig[] pools: IpsecLivePool[] nat: IpsecLiveNat[] + policies: RosIpsecPolicy[] + certificateNames: string[] snapshot: IpsecSnapshot }> { const state = await fetchIpsecState(server) return { client: state.client, + policies: state.policies, + certificateNames: state.certs.map(c => c.name ?? "").filter(Boolean), peers: state.peers - .filter((p) => isIpsecManagedComment(p.comment)) .map((p) => ({ rosId: String(p[".id"] ?? ""), name: (p.name ?? "").trim(), @@ -518,7 +523,6 @@ export async function fetchIpsecRestoreState(server: ServerRow): Promise<{ disabled: asBool(p.disabled), })), identities: state.identities - .filter((i) => isIpsecManagedComment(i.comment)) .map((i) => ({ rosId: String(i[".id"] ?? ""), peerName: (i.peer ?? "").trim(), @@ -534,7 +538,6 @@ export async function fetchIpsecRestoreState(server: ServerRow): Promise<{ disabled: asBool(i.disabled), })), modeConfigs: state.modeConfigs - .filter(isManagedIpsecModeConfig) .map((m) => ({ rosId: String(m[".id"] ?? ""), name: (m.name ?? "").trim(), @@ -544,7 +547,6 @@ export async function fetchIpsecRestoreState(server: ServerRow): Promise<{ comment: (m.comment ?? "").trim(), })), pools: state.pools - .filter((p) => isIpsecManagedComment(p.comment)) .map((p) => ({ rosId: String(p[".id"] ?? ""), name: (p.name ?? "").trim(), @@ -552,7 +554,6 @@ export async function fetchIpsecRestoreState(server: ServerRow): Promise<{ comment: (p.comment ?? "").trim(), })), nat: state.nat - .filter((n) => isIpsecManagedComment(n.comment)) .map((n) => ({ rosId: String(n[".id"] ?? ""), chain: (n.chain ?? "").trim(), diff --git a/backend/src/services/ipsec-restore.test.ts b/backend/src/services/ipsec-restore.test.ts new file mode 100644 index 00000000..deb4a51b --- /dev/null +++ b/backend/src/services/ipsec-restore.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict" +import { canonicalIpsecSnapshot, planIpsecRestore } from "./entity-snapshots.js" +const snapshot = canonicalIpsecSnapshot({ + peers: [{ name: "ipsec-vpn", comment: "MikrotikManager:ipsec", exchangeMode: "ike2", profile: "ipsec-vpn" }], + pools: [{ name: "ipsec-vpn", ranges: "198.18.80.2-198.18.80.254", comment: "MikrotikManager:ipsec" }], + modeConfigs: [{ name: "ipsec-vpn", addressPool: "ipsec-vpn" }, { name: "mc-ipsec-alice", address: "198.18.80.250" }], + identities: [{ peerName: "ipsec-vpn", modeConfig: "mc-ipsec-alice", authMethod: "pre-shared-key", secret: "(hidden)", remoteId: "key-id:alice", comment: "MikrotikManager:ipsec user=alice" }], +}) +const current = { peers: snapshot.peers.map(p => ({ ...p, rosId: "*P" })), pools: snapshot.pools.map(p => ({ ...p, rosId: "*POOL" })), modeConfigs: snapshot.modeConfigs.map((m, i) => ({ ...m, comment: "", rosId: "*M" + i })), identities: snapshot.identities.map(i => ({ ...i, rosId: "*I" })), nat: [] } +const existing = planIpsecRestore(snapshot, current) +assert.equal(existing.filter(o => o.op === "put" && o.path === "/ip/ipsec/mode-config").length, 0, "Reserved mode configs without unsupported comment must be updated") +const fresh = planIpsecRestore(snapshot, { peers: [], pools: [], modeConfigs: [], identities: [], nat: [] }) +assert(fresh.findIndex(o => o.path === "/ip/pool") < fresh.findIndex(o => o.path === "/ip/ipsec/mode-config"), "Create pool before its consumer") +const configs = fresh.filter(o => o.op === "put" && o.path === "/ip/ipsec/mode-config") +assert(configs.every(o => "body" in o && o.body.responder === "yes"), "Restored server configs must assign addresses") +assert(configs.some(o => "body" in o && o.body.address === "198.18.80.250" && o.body["address-prefix-length"] === "32")) +const ghost = { ...snapshot.modeConfigs[1]!, name: "mc-ipsec-old", rosId: "*OLD", comment: "" } +const switching = planIpsecRestore(snapshot, { ...current, identities: current.identities.map(i => ({ ...i, modeConfig: ghost.name })), modeConfigs: [...current.modeConfigs, ghost] }) +assert(switching.findIndex(o => o.op === "patch" && o.path === "/ip/ipsec/identity/*I") < switching.findIndex(o => o.op === "delete" && o.path === "/ip/ipsec/mode-config/*OLD"), "Move identity before removing its old mode config") +console.log("IPsec restore: reserved configs, dependency order, responder, /32 and reference switch") +const peer = canonicalIpsecSnapshot({ peers: [{ name: "ipsec-vpn", comment: "MikrotikManager:ipsec" }] }).peers[0]! +const config = canonicalIpsecSnapshot({ modeConfigs: [{ name: "mc-ipsec-old" }] }).modeConfigs[0]! +const base = { peers: [{ ...peer, rosId: "*P" }], modeConfigs: [{ ...config, rosId: "*MC" }], pools: [], nat: [], identities: [] } +const foreignIdentity = { ...canonicalIpsecSnapshot({ identities: [{ peerName: peer.name, modeConfig: config.name, comment: "foreign" }] }).identities[0]!, rosId: "*FOREIGN" } +assert.throws(() => planIpsecRestore(canonicalIpsecSnapshot({}), { ...base, identities: [foreignIdentity] }), /чужой IPsec identity/) +assert.throws(() => planIpsecRestore(canonicalIpsecSnapshot({}), { ...base, policies: [{ peer: peer.name, comment: "foreign", dynamic: "false" }] }), /чужой IPsec policy/) +const foreignPool = { ...canonicalIpsecSnapshot({ pools: [{ name: peer.name, comment: "foreign" }] }).pools[0]!, rosId: "*FOREIGNPOOL" } +assert.throws(() => planIpsecRestore(canonicalIpsecSnapshot({ pools: [{ name: peer.name, comment: "MikrotikManager:ipsec" }] }), { ...base, pools: [foreignPool] }), /чужой конфигурацией/) +console.log("IPsec restore safety: foreign identity/policy references and name conflict fail before operations") diff --git a/docs/ux-ui-external-acceptance.md b/docs/ux-ui-external-acceptance.md index 987d1782..93b0a596 100644 --- a/docs/ux-ui-external-acceptance.md +++ b/docs/ux-ui-external-acceptance.md @@ -1,11 +1,13 @@ # UX/UI: протокол внешней приёмки 12.5 -Статус на2026-10-09: backend ux-ipsec79/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-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 восстановлен. 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). IPsec79: сертификатный responder online=true/static mode-config198.18.79.250 принят; recordRevision=null после native patch даёт502/state=applied. Собственные объекты и private files удалены. [Отчёт](ux-ui/acceptance/ipsec-certificate-2026-10-09.json). +IPsec80: real restore staticIp/disabled, повтор без дубликатов, foreign-reference409 и cleanup приняты. Первоначальный неполный образ откатан; исправленный runtime healthy. [Отчёт](ux-ui/acceptance/ipsec-restore-2026-10-09.json). + ## Паспорт стенда Перед внешним запуском зафиксировать отдельные PostgreSQL и RouterOS тестовые устройства, версии server/client PostgreSQL и RouterOS, HEAD backend/frontend, роли пользователей, число экземпляров backend/планировщика/отправителей и способ их остановки. Для Telegram использовать тестовый чат, для остальных интеграций — тестовые endpoints и учётные записи. Зафиксировать ожидаемые изменения и способ восстановления стенда. diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index ecc2da71..4237b1c2 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -1,6 +1,6 @@ # План реализации UX/UI по пятичасовым окнам -Актуальное продолжение 2026-10-09: 12.5.79 подтвердил сертификатный IKEv2 со статическим mode-config198.18.79.250 и online=true; поздний сбой сохранения ревизии сообщает state=applied/historyRecorded=false. Backend ux-ipsec79, frontend ux-ipsec77. Следующий12.5.80 — исправление и реальная приёмка восстановления истории IPsec; поздние частичные/транспортные ошибки остаются открытыми. Этап12.5 открыт. +Актуальное продолжение 2026-10-09: 12.5.80 исправил восстановление IPsec mode-config и порядок зависимостей; реальный откат staticIp/disabled, повтор без дубликатов и запрет чужой ссылки приняты. Backend ux-ipsec80 healthy, frontend ux-ipsec77. Следующий12.5.81 — частичные ошибки операций и неопределённый результат при потере ответа RouterOS. Этап12.5 открыт. Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-09. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index dde962fe..78e180df 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -1,6 +1,6 @@ # UX/UI: журнал реализации -Актуальное продолжение 2026-10-09: 12.5.79 подтвердил сертификатный IKEv2 со статическим mode-config198.18.79.250 и online=true; поздний сбой сохранения ревизии сообщает state=applied/historyRecorded=false. Backend ux-ipsec79, frontend ux-ipsec77. Следующий12.5.80 — исправление и реальная приёмка восстановления истории IPsec; поздние частичные/транспортные ошибки остаются открытыми. Этап12.5 открыт. +Актуальное продолжение 2026-10-09: 12.5.80 исправил восстановление IPsec mode-config и порядок зависимостей; реальный откат staticIp/disabled, повтор без дубликатов и запрет чужой ссылки приняты. Backend ux-ipsec80 healthy, frontend ux-ipsec77. Следующий12.5.81 — частичные ошибки операций и неопределённый результат при потере ответа RouterOS. Этап12.5 открыт. Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md). @@ -36,7 +36,21 @@ | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 | -| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка завершена в границах12.3.83; внешняя12.5 в работе, выполненные блоки1–79 и ограничения ниже. Runtime backend ux-ipsec79/frontend ux-ipsec77. | +| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка завершена в границах12.3.83; внешняя12.5 в работе, выполненные блоки1–80 и ограничения ниже. Runtime backend ux-ipsec80/frontend ux-ipsec77. | + +## 12.5.80 — IPsec: восстановление истории и зависимости (2026-10-09) + +**Исправления.** Restore распознаёт reserved mode-config по имени без неподдерживаемого RouterOS comment; существующий объект обновляется вместо повторного PUT. Пул создаётся до mode-config; restored responder=yes и персональный address-prefix-length=32. Лишние mode-config/peer/pool удаляются после переключения identities. Для проверки ссылок restore state содержит полный live набор, а план изменяет managed объекты. Чужие identity/static policy/пул mode-config и конфликт имени блокируют разрушительный план до записей; HTTP409 с объяснением отсутствия изменений. Отсутствующие сертификаты/profile ревизии также проверяются до исполнения; история не содержит ключи PKI и не может создать их. + +**Проверки.** config +80 state checks, IPsec HTTP history test, новый ipsec-restore test (reserved objects, pool dependency, responder/32, переключение перед удалением, foreign identity/policy/name conflict), entity-snapshots tests, backend TypeScript/ESLint/diff check. Сохраняется исходное предупреждение entity-snapshots.ts unused bool; новых ESLint ошибок нет. + +**Реальная приёмка.** Собственные profile/peer/pool/mode-config/PSK identity наCHR1. Production API сохранил observed ревизию, PATCH изменил198.18.80.250→249 и disabled=true; restore вернул250/disabled=false, native responder=true. Повторный restore200 не создал дубликат. Собственная дополнительная identity без managed marker, ссылающаяся на test peer/mode-config, заблокировала возврат пустой ревизии409; native peer и identity сохранились. Очистка по точным ID/name/comment; исходные ID пяти коллекций совпали. PKI/profile missing guards добавлены, но их отдельная native fault injection не заявляется. [Отчёт](ux-ui/acceptance/ipsec-restore-2026-10-09.json). + +**Docker и инцидент.** Установка системных пакетов runner перенесена до COPY приложения/APP_VERSION для повторного использования кэша. Первая правка ошибочно выбрала apt block deps и сократила Dockerfile: build формально прошёл, backend не стартовал. Health gate остановил тест до RouterOS/БД mutations; возвращён healthy ux-ipsec79. Dockerfile восстановлен из HEAD, перемещён только runner apt block, diff проверен. Перед повторным deploy проверены entry file/node --check и pg_restore18.6 внутри образа; compose --wait подтвердил healthy. Это две сборки80, включая исправление собственной ошибки; не считать первую успешной приёмкой. Повторное использование apt слоя будет проверено на следующем изменении. + +**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.79 — IPsec: сертификатный online и поздняя ошибка истории (2026-10-09) diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index 957ff429..ed1990b0 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -1912,3 +1912,17 @@ flowchart LR **Runtime.** Одна backend production сборка: mmapp-test/backend:ux-ipsec79/version1.0.0-test.ipsec79, image sha256:26b5b05122b7b55aa83b1f80b880d1aee6eaf33caa225afe09327b02a219c5dc1. Frontend ux-ipsec77 сохранён. Context предыдущего ipsec78 + пять backend-файлов из рабочего состояния перед коммитом; archiveSHA50cdacc0016810bd8c87c0bf2eaedbe9ddd4e1d6134a960f6ca11207c4faaff6. SHA runtime исходников совпали локально/удалённо: ipsec-live.ts676b51c9ed7edd9bc5d0cf5a455409b445c46235a55e2bedeff9e07208401bb6, routes/ipsec.ts192a3a7c5628c5e68c5cb20c26b55ce97c9a4c2cce8effea8ed1a26c79ee442d. Label revision=ipsec79, не итоговый Git SHA. .env backup приватный; миграций нет. Следующий12.5.80: исправить restore mode-config без неподдерживаемого comment, порядок зависимостей и проверить реальный откат изменений. Обнаружен воспроизводимый локальный отказ: существующие reserved mode-config планируются как новые PUT; новая профильная проверка для следующего блока пока красная. Остальные ограничения12.5.77, составные/экранированные DN, NAT/интернет/IPv6/клиентские ОС/RBAC/throughput открыты. Один локальный коммит без push. + +## 12.5.80 — IPsec: восстановление истории и зависимости (2026-10-09) + +**Исправления.** Restore распознаёт reserved mode-config по имени без неподдерживаемого RouterOS comment; существующий объект обновляется вместо повторного PUT. Пул создаётся до mode-config; restored responder=yes и персональный address-prefix-length=32. Лишние mode-config/peer/pool удаляются после переключения identities. Для проверки ссылок restore state содержит полный live набор, а план изменяет managed объекты. Чужие identity/static policy/пул mode-config и конфликт имени блокируют разрушительный план до записей; HTTP409 с объяснением отсутствия изменений. Отсутствующие сертификаты/profile ревизии также проверяются до исполнения; история не содержит ключи PKI и не может создать их. + +**Проверки.** config +80 state checks, IPsec HTTP history test, новый ipsec-restore test (reserved objects, pool dependency, responder/32, переключение перед удалением, foreign identity/policy/name conflict), entity-snapshots tests, backend TypeScript/ESLint/diff check. Сохраняется исходное предупреждение entity-snapshots.ts unused bool; новых ESLint ошибок нет. + +**Реальная приёмка.** Собственные profile/peer/pool/mode-config/PSK identity наCHR1. Production API сохранил observed ревизию, PATCH изменил198.18.80.250→249 и disabled=true; restore вернул250/disabled=false, native responder=true. Повторный restore200 не создал дубликат. Собственная дополнительная identity без managed marker, ссылающаяся на test peer/mode-config, заблокировала возврат пустой ревизии409; native peer и identity сохранились. Очистка по точным ID/name/comment; исходные ID пяти коллекций совпали. PKI/profile missing guards добавлены, но их отдельная native fault injection не заявляется. [Отчёт](ux-ui/acceptance/ipsec-restore-2026-10-09.json). + +**Docker и инцидент.** Установка системных пакетов runner перенесена до COPY приложения/APP_VERSION для повторного использования кэша. Первая правка ошибочно выбрала apt block deps и сократила Dockerfile: build формально прошёл, backend не стартовал. Health gate остановил тест до RouterOS/БД mutations; возвращён healthy ux-ipsec79. Dockerfile восстановлен из HEAD, перемещён только runner apt block, diff проверен. Перед повторным deploy проверены entry file/node --check и pg_restore18.6 внутри образа; compose --wait подтвердил healthy. Это две сборки80, включая исправление собственной ошибки; не считать первую успешной приёмкой. Повторное использование apt слоя будет проверено на следующем изменении. + +**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 открыты. diff --git a/docs/ux-ui-test-server.md b/docs/ux-ui-test-server.md index cd36a147..d971478c 100644 --- a/docs/ux-ui-test-server.md +++ b/docs/ux-ui-test-server.md @@ -1,6 +1,6 @@ # Тестовый сервер UX/UI — 2026-10-09 -Сервер161.104.46.222 предоставлен для временного тестирования. Backend ux-ipsec79/version1.0.0-test.ipsec79, 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-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 доступны, пароли прежние. | Параметр | Проверенное состояние | | --- | --- | @@ -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-ipsec79 и mmapp-test/frontend:ux-ipsec77; запущены | +| Образы приложения | mmapp-test/backend:ux-ipsec80 и mmapp-test/frontend:ux-ipsec77; запущены | Compose SHA256:3052bd6ab5d9865f97605cebfb5ff27a4410cd3357b81153a65a87fb7c92582f. OptionalBACKEND_IMAGE_TAG позволяетобновлятьbackendотдельно. Локальный и удалённый файлы совпали. Шаблоны: [Compose](../deploy/docker-compose.test.yml), [env без секретов](../deploy/env.test.example). @@ -22,6 +22,8 @@ IPsec78: PSK active-peer/online и mode-config приняты через native IPsec79: сертификатный responder online=true/static mode-config198.18.79.250 принят; recordRevision=null после native patch даёт502/state=applied. Собственные объекты и private files удалены. [Отчёт](ux-ui/acceptance/ipsec-certificate-2026-10-09.json). +IPsec80: real restore staticIp/disabled, повтор без дубликатов, foreign-reference409 и cleanup приняты. Первоначальный неполный образ откатан; исправленный runtime healthy. [Отчёт](ux-ui/acceptance/ipsec-restore-2026-10-09.json). + ## Развёртывание приложения Чистые frontend/backend build contexts подготовлены по .gitea/workflows/docker.yml из закреплённого Git HEAD;образы собраны на сервере без push. Локально docker CLI сейчас отсутствует, на сервере доступен Docker. В корневом .dockerignore исключён backend, поэтому обычный backend build из корня не повторяет CI: использовать отдельный backend context, как main workflow. Не передавать .env.local,backend/.env,ключи и локальные node_modules/.next. diff --git a/docs/ux-ui/acceptance/ipsec-restore-2026-10-09.json b/docs/ux-ui/acceptance/ipsec-restore-2026-10-09.json new file mode 100644 index 00000000..72898dbf --- /dev/null +++ b/docs/ux-ui/acceptance/ipsec-restore-2026-10-09.json @@ -0,0 +1,29 @@ +{ + "date": "2026-10-09", + "name": "mm-ipsec80-1791535642119", + "serverId": 1, + "checks": [ + { + "scenario": "restore static address and enabled state", + "httpStatus": 200, + "nativeAddress": "198.18.80.250", + "nativeResponder": true + }, + { + "scenario": "repeat restore without duplicate mode-config", + "httpStatus": 200 + }, + { + "scenario": "foreign reference blocks restore before writes", + "httpStatus": 409, + "nativeObjectsPreserved": true + } + ], + "cleanup": { + "/ip/ipsec/identity": true, + "/ip/ipsec/peer": true, + "/ip/ipsec/mode-config": true, + "/ip/pool": true, + "/ip/ipsec/profile": true + } +} \ No newline at end of file