fix(ipsec): восстановить mode-config с проверкой зависимостей

This commit is contained in:
Denozordec committed 2026-10-09 15:49:50 +07:00
1 parent 5d9ef9dcf3
commit a83e4734df
12 files changed
+156 -34

No files matched your search

+9 -8
View File
@@ -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"]
+1 -1
View File
@@ -28,7 +28,7 @@
"test:ros-file": "tsx src/services/ros-file-contents.test.ts",
"test:scheduler": "tsx src/services/scheduler-running.test.ts && tsx src/services/scheduler-recorded-run.test.ts && tsx src/routes/scheduler.test.ts",
"test:backups": "tsx src/services/backup-scheduler-run.test.ts && tsx src/services/backup-restore.test.ts && tsx src/services/backup-deletion.test.ts && tsx src/services/backup-dto.test.ts && tsx src/services/backup-storage-update.test.ts && tsx src/services/backup-job-execution.test.ts && tsx src/routes/backups-operations.test.ts && tsx src/services/s3-backup-client.test.ts",
"test:ipsec": "tsx src/services/ipsec-config.test.ts && tsx src/services/ipsec-state.test.ts && tsx src/routes/ipsec-history.test.ts",
"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",
+8 -1
View File
@@ -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")
+35 -12
View File
@@ -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: {
+7 -6
View File
@@ -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")