fix(ipsec): восстановить mode-config с проверкой зависимостей
This commit is contained in:
1 parent
5d9ef9dcf3
commit
a83e4734df
12 files changed
+156
-34
No files matched your search
+9
-8
@@ -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"]
|
||||
@@ -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",
|
||||
|
||||
@@ -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<unknown>("/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")
|
||||
|
||||
@@ -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<string, string> }
|
||||
| { op: "post"; path: string; body: Record<string, string> }
|
||||
@@ -718,6 +720,8 @@ function ipsecIdentityBody(i: IpsecSnapshotIdentity): Record<string, string> {
|
||||
function ipsecModeConfigBody(m: IpsecSnapshotModeConfig): Record<string, string> {
|
||||
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: {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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")
|
||||
@@ -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 и учётные записи. Зафиксировать ожидаемые изменения и способ восстановления стенда.
|
||||
|
||||
+1
-1
@@ -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). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат.
|
||||
|
||||
|
||||
+16
-2
@@ -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)
|
||||
|
||||
|
||||
@@ -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 открыты.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user