fix(ipsec): восстановить mode-config с проверкой зависимостей
This commit is contained in:
1 parent
5d9ef9dcf3
commit
a83e4734df
12 files changed
+156
-34
No files matched your search
@@ -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")
|
||||
Reference in new issue
Block a user