fix(ipsec): обеспечить NAT и изоляцию политик для передачи данных

This commit is contained in:
Denozordec committed 2026-10-09 17:10:33 +07:00
1 parent 43b00e0740
commit 89de7e134b
17 files changed
+571 -25

No files matched your search

@@ -170,7 +170,7 @@ import { fingerprintPayload, revisionItemCount } from "./config-revisions.js"
identities: [{ peerName: "ipsec-vpn", authMethod: "rsa-key", certificate: "ipsec-server", remoteCertificate: "ipsec-user-alice", matchBy: "certificate", secret: "", remoteId: "", modeConfig: "ipsec-vpn", generatePolicy: "port-strict", comment: "MikrotikManager:ipsec user=alice", disabled: false }],
modeConfigs: [{ name: "ipsec-vpn", addressPool: "ipsec-vpn", address: "", staticDns: "10.77.0.1", comment: "MikrotikManager:ipsec" }],
pools: [{ name: "ipsec-vpn", ranges: "10.77.0.2-10.77.0.254", comment: "MikrotikManager:ipsec" }],
nat: [{ chain: "srcnat", action: "masquerade", srcAddress: "10.77.0.0/24", comment: "MikrotikManager:ipsec интернет клиентам VPN" }],
nat: [{ chain: "srcnat", action: "masquerade", srcAddress: "10.77.0.0/24", outInterface: "ether1", ipsecPolicy: "out,none", comment: "MikrotikManager:ipsec интернет клиентам VPN" }],
})
// round-trip через payload ревизии
const restored = parseIpsecSnapshot(JSON.parse(JSON.stringify(desired)))
+17 -2
View File
@@ -190,6 +190,7 @@ export interface IpsecSnapshotIdentity {
generatePolicy: string
comment: string
disabled: boolean
policyTemplateGroup?: string
}
export interface IpsecSnapshotModeConfig {
@@ -218,6 +219,9 @@ export interface IpsecSnapshotNat {
action: string
srcAddress: string
comment: string
outInterface?: string
outInterfaceList?: string
ipsecPolicy?: string
}
export interface IpsecSnapshot {
@@ -626,6 +630,7 @@ function canonicalIpsecIdentityRaw(i: Partial<IpsecSnapshotIdentity>): IpsecSnap
remoteId: str(i.remoteId),
modeConfig: str(i.modeConfig),
generatePolicy: str(i.generatePolicy),
...(str(i.policyTemplateGroup) ? { policyTemplateGroup: str(i.policyTemplateGroup) } : {}),
comment: str(i.comment),
disabled: Boolean(i.disabled),
}
@@ -668,7 +673,7 @@ export function canonicalIpsecSnapshot(input: {
.filter((p) => p.dstAddress)
.sort((a, b) => a.dstAddress.localeCompare(b.dstAddress)),
nat: (input.nat ?? [])
.map((n) => ({ chain: str(n.chain), action: str(n.action), srcAddress: str(n.srcAddress), comment: str(n.comment) }))
.map((n) => ({ chain: str(n.chain), action: str(n.action), srcAddress: str(n.srcAddress), comment: str(n.comment), ...(str(n.outInterface) ? { outInterface: str(n.outInterface) } : {}), ...(str(n.outInterfaceList) ? { outInterfaceList: str(n.outInterfaceList) } : {}), ...(str(n.ipsecPolicy) ? { ipsecPolicy: str(n.ipsecPolicy) } : {}) }))
.filter((n) => n.comment)
.sort((a, b) => a.comment.localeCompare(b.comment)),
}
@@ -712,6 +717,7 @@ function ipsecIdentityBody(i: IpsecSnapshotIdentity): Record<string, string> {
"remote-id": i.remoteId,
"mode-config": i.modeConfig,
"generate-policy": i.generatePolicy,
"policy-template-group": i.policyTemplateGroup,
comment: i.comment,
disabled: rosYesNo(i.disabled),
})
@@ -733,12 +739,16 @@ function ipsecPoolBody(p: IpsecSnapshotPool): Record<string, string> {
}
function ipsecNatBody(n: IpsecSnapshotNat): Record<string, string> {
return compactBody({
const body = compactBody({
chain: n.chain,
action: n.action,
"src-address": n.srcAddress,
comment: n.comment,
"ipsec-policy": n.ipsecPolicy,
"out-interface": n.outInterface,
"out-interface-list": n.outInterfaceList,
})
return body
}
/** Restore только managed-объектов IKEv2 (peer/identity/mode-config/pool/nat; секреты (hidden) не перезаписываем;
@@ -757,11 +767,16 @@ export function planIpsecRestore(
const desired = canonicalIpsecSnapshot(desiredInput)
const ops: RosWriteOp[] = []
const removeDependents: RosWriteOp[] = []
if (desired.nat.some(n => n.action === "masquerade" && !n.outInterface && !n.outInterfaceList)) throw new IpsecRestoreConflict("Ревизия NAT не содержит исходящий интерфейс. Настройте NAT вручную или используйте новую ревизию")
const managedPeers = current.peers.filter((p) => isIpsecManagedComment(p.comment))
const managedIdentities = current.identities.filter((i) => isIpsecManagedComment(i.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))
for (const want of desired.nat) {
const live = managedNat.find(n => n.comment === want.comment)
if (live && ((live.outInterface && !want.outInterface) || (live.outInterfaceList && !want.outInterfaceList))) throw new IpsecRestoreConflict("Откат требует снять прежнее ограничение интерфейса NAT. Снимите его вручную перед повтором")
}
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))
+7 -5
View File
@@ -1,20 +1,22 @@
import type { MikrotikClient } from "./mikrotik.js"
import { IPSEC_CA_CERT, IPSEC_SERVER_CERT, isManagedIpsecModeConfig, isIpsecManagedComment } from "./ipsec-config.js"
import { IPSEC_CA_CERT, IPSEC_SERVER_CERT, IPSEC_POLICY_GROUP, isManagedIpsecModeConfig, isIpsecManagedComment } from "./ipsec-config.js"
import { listByPath } from "./ipsec-ros.js"
/** Read the whole deletion scope before writing; never treat unreadable state as empty. */
export async function removeManagedIpsecServer(client: MikrotikClient, removeCertificates: boolean): Promise<void> {
const paths = ["/ip/ipsec/identity", "/ip/ipsec/peer", "/ip/ipsec/policy", "/ip/ipsec/mode-config", "/ip/pool", "/ip/firewall/nat"]
const paths = ["/ip/ipsec/identity", "/ip/ipsec/peer", "/ip/ipsec/policy", "/ip/ipsec/mode-config", "/ip/pool", "/ip/firewall/nat", "/ip/ipsec/policy/group"]
if (removeCertificates) paths.push("/certificate")
const snapshots = await Promise.all(paths.map(async path => ({ path, rows: await listByPath(client, path) })))
const owned = (path: string, row: Record<string, string | undefined>) => isIpsecManagedComment(row.comment) || (path === "/ip/ipsec/mode-config" && isManagedIpsecModeConfig(row)) || (path === "/certificate" && (row.name === IPSEC_CA_CERT || row.name === IPSEC_SERVER_CERT || row.name?.startsWith("ipsec-user-")))
const linkedGroups = new Set(snapshots.find(s => s.path === "/ip/ipsec/policy")?.rows.filter(r => isIpsecManagedComment(r.comment)).map(r => r.group))
const owned = (path: string, row: Record<string, string | undefined>) => path === "/ip/ipsec/policy/group" ? row.name === IPSEC_POLICY_GROUP && linkedGroups.has(row.name) : isIpsecManagedComment(row.comment) || (path === "/ip/ipsec/mode-config" && isManagedIpsecModeConfig(row)) || (path === "/certificate" && (row.name === IPSEC_CA_CERT || row.name === IPSEC_SERVER_CERT || row.name?.startsWith("ipsec-user-")))
const names = (path: string) => new Set(snapshots.find(s => s.path === path)?.rows.filter(row => owned(path, row)).map(row => row.name).filter((name): name is string => typeof name === "string" && name.length > 0))
const peerNames = names("/ip/ipsec/peer"), modeConfigs = names("/ip/ipsec/mode-config"), certificates = names("/certificate")
const peerNames = names("/ip/ipsec/peer"), modeConfigs = names("/ip/ipsec/mode-config"), certificates = names("/certificate"), groups = names("/ip/ipsec/policy/group")
for (const { path, rows } of snapshots) {
for (const row of rows.filter(row => !owned(path, row))) {
if (path === "/certificate" && certificates.has(row.ca ?? "")) throw new Error("Удаление заблокировано: управляемый CA подписал чужой сертификат")
if (path === "/ip/ipsec/identity" && (peerNames.has(row.peer ?? "") || modeConfigs.has(row["mode-config"] ?? "") || certificates.has(row.certificate ?? "") || certificates.has(row["remote-certificate"] ?? ""))) throw new Error("Удаление заблокировано: объекты IPsec используются чужой identity")
if (path === "/ip/ipsec/identity" && (groups.has(row["policy-template-group"] ?? "") || peerNames.has(row.peer ?? "") || modeConfigs.has(row["mode-config"] ?? "") || certificates.has(row.certificate ?? "") || certificates.has(row["remote-certificate"] ?? ""))) throw new Error("Удаление заблокировано: объекты IPsec используются чужой identity")
if (path === "/ip/ipsec/policy" && row.dynamic !== "true" && row.dynamic !== "yes" && (row.peer ?? "").split(",").some(peer => peerNames.has(peer.trim()))) throw new Error("Удаление заблокировано: peer используется чужой policy")
if (path === "/ip/ipsec/policy" && row.dynamic !== "true" && row.dynamic !== "yes" && groups.has(row.group ?? "")) throw new Error("Удаление заблокировано: группа используется чужой policy")
}
}
for (const { path, rows } of snapshots) {
+2
View File
@@ -43,6 +43,7 @@ export interface Ike2IdentityLike extends IdentityLike {
certificate?: string
"auth-method"?: string
"mode-config"?: string
"policy-template-group"?: string
}
/** Peer (/ip/ipsec/peer). */
@@ -95,6 +96,7 @@ export const IPSEC_CA_CERT = "ipsec-ca"
export const IPSEC_SERVER_CERT = "ipsec-server"
/** Общее имя peer / profile / proposal / пула / общего mode-config. */
export const IPSEC_COMMON_NAME = "ipsec-vpn"
export const IPSEC_POLICY_GROUP = "mmapp-ipsec-vpn"
export function ipsecSlug(name: string): string {
return name
+12
View File
@@ -63,6 +63,7 @@ export interface RosIpsecIdentity {
"remote-id"?: string
"mode-config"?: string
"generate-policy"?: string
"policy-template-group"?: string
disabled?: string
comment?: string
}
@@ -102,6 +103,9 @@ export interface RosFirewallNat {
action?: string
"src-address"?: string
comment?: string
"out-interface"?: string
"out-interface-list"?: string
"ipsec-policy"?: string
}
export interface RosIpsecActivePeer {
@@ -456,6 +460,7 @@ export async function captureIpsecSnapshot(server: ServerRow): Promise<IpsecSnap
remoteId: (i["remote-id"] ?? "").trim(),
modeConfig: (i["mode-config"] ?? "").trim(),
generatePolicy: (i["generate-policy"] ?? "").trim(),
policyTemplateGroup: i["policy-template-group"],
comment: (i.comment ?? "").trim(),
disabled: asBool(i.disabled),
})),
@@ -489,6 +494,9 @@ export async function captureIpsecSnapshot(server: ServerRow): Promise<IpsecSnap
chain: (n.chain ?? "").trim(),
action: (n.action ?? "").trim(),
srcAddress: (n["src-address"] ?? "").trim(),
outInterface: n["out-interface"],
outInterfaceList: n["out-interface-list"],
ipsecPolicy: n["ipsec-policy"],
comment: (n.comment ?? "").trim(),
})),
})
@@ -534,6 +542,7 @@ export async function fetchIpsecRestoreState(server: ServerRow): Promise<{
remoteId: (i["remote-id"] ?? "").trim(),
modeConfig: (i["mode-config"] ?? "").trim(),
generatePolicy: (i["generate-policy"] ?? "").trim(),
policyTemplateGroup: i["policy-template-group"],
comment: (i.comment ?? "").trim(),
disabled: asBool(i.disabled),
})),
@@ -559,6 +568,9 @@ export async function fetchIpsecRestoreState(server: ServerRow): Promise<{
chain: (n.chain ?? "").trim(),
action: (n.action ?? "").trim(),
srcAddress: (n["src-address"] ?? "").trim(),
outInterface: n["out-interface"],
outInterfaceList: n["out-interface-list"],
ipsecPolicy: n["ipsec-policy"],
comment: (n.comment ?? "").trim(),
})),
snapshot: await captureIpsecSnapshot(server),
@@ -27,3 +27,11 @@ assert.throws(() => planIpsecRestore(canonicalIpsecSnapshot({}), { ...base, poli
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")
const scopedNat = canonicalIpsecSnapshot({nat:[{chain:"srcnat",action:"masquerade",srcAddress:"198.18.82.0/24",outInterface:"ether1",ipsecPolicy:"out,none",comment:"MikrotikManager:ipsec NAT"}]})
const scopedOps = planIpsecRestore(scopedNat,{peers:[],identities:[],modeConfigs:[],pools:[],nat:[]})
const natOp=scopedOps.find(o=>o.op==="put"&&o.path==="/ip/firewall/nat")
assert(natOp&&"body" in natOp&&natOp.body["out-interface"]==="ether1"&&natOp.body["ipsec-policy"]==="out,none")
assert.throws(()=>planIpsecRestore(canonicalIpsecSnapshot({nat:[{chain:"srcnat",action:"masquerade",srcAddress:"198.18.82.0/24",comment:"MikrotikManager:ipsec NAT"}]}),{peers:[],identities:[],modeConfigs:[],pools:[],nat:[]}),/Ревизия NAT/)
console.log("IPsec NAT history: retains egress/policy, rejects legacy unscoped masquerade")
assert.throws(()=>planIpsecRestore(scopedNat,{peers:[],identities:[],modeConfigs:[],pools:[],nat:[{...scopedNat.nat[0]!,outInterface:undefined,outInterfaceList:"WAN",rosId:"*N"}]}),/Снимите его вручную/)
assert(natOp&&"body" in natOp&&natOp.body["out-interface-list"]===undefined,"Never send empty ambiguous RouterOS list value")
+61 -8
View File
@@ -1,5 +1,7 @@
import type { MikrotikClient } from "./mikrotik.js"
import { normalizeIpsecRemoteId,
IPSEC_POLICY_GROUP,
isIpsecManagedComment,
ipsecManagedComment,
isManagedIpsecModeConfig,
selectIke2Peers,
@@ -104,6 +106,7 @@ export async function ensurePolicyTemplate(
"src-address": "0.0.0.0/0",
"dst-address": dstCidr,
proposal: proposalName,
group: IPSEC_POLICY_GROUP,
comment,
}))
return
@@ -112,11 +115,24 @@ export async function ensurePolicyTemplate(
"src-address": "0.0.0.0/0",
"dst-address": dstCidr,
proposal: proposalName,
group: IPSEC_POLICY_GROUP,
template: "yes",
comment,
}))
}
export class IpsecPolicyGroupConflict extends Error {}
/** Reserved group reuse requires a managed template and no foreign consumers. */
export async function ipsecPolicyGroupExists(client: MikrotikClient): Promise<boolean> {
const groups = await listByPath(client, "/ip/ipsec/policy/group")
if (!groups.some(g => g.name === IPSEC_POLICY_GROUP)) return false
const [policies, identities] = await Promise.all([listByPath(client, "/ip/ipsec/policy"), listByPath(client, "/ip/ipsec/identity")])
if (!policies.some(p => p.group === IPSEC_POLICY_GROUP && isIpsecManagedComment(p.comment))) throw new IpsecPolicyGroupConflict("Зарезервированное имя группы политик занято объектом без подтверждённого управления")
if (policies.some(p => p.group === IPSEC_POLICY_GROUP && !isIpsecManagedComment(p.comment) && p.dynamic !== "true" && p.dynamic !== "yes") || identities.some(i => i["policy-template-group"] === IPSEC_POLICY_GROUP && !isIpsecManagedComment(i.comment))) throw new IpsecPolicyGroupConflict("Группа политик используется чужой конфигурацией")
return true
}
export async function ensureIpsecPeer(
client: MikrotikClient,
name: string,
@@ -134,20 +150,44 @@ export async function ensureIpsecPeer(
}))
}
/** Managed srcnat masquerade: интернет клиентам VPN. */
export async function ensureNatRule(client: MikrotikClient, poolCidr: string, comment: string): Promise<void> {
export class IpsecNatInterfaceConflict extends Error {}
export function selectIpsecNatInterface(routes: RosRow[], interfaces: RosRow[]): string {
const candidates = new Set<string>()
for (const route of routes) {
if (route["dst-address"] !== "0.0.0.0/0" || !["true", "yes"].includes(route.active ?? "") || (route["routing-table"] && route["routing-table"] !== "main")) continue
const hops = (route["immediate-gw"] ?? route.gateway ?? "").split(",")
for (const hop of hops) {
const name = hop.includes("%") ? hop.slice(hop.lastIndexOf("%") + 1).trim() : hop.trim()
if (interfaces.some(i => i.name === name && !["true", "yes"].includes(i.disabled ?? ""))) candidates.add(name)
}
}
if (candidates.size !== 1) throw new IpsecNatInterfaceConflict("Не удалось однозначно определить исходящий интерфейс по активному маршруту main. Отключите NAT в мастере и настройте правило вручную")
return [...candidates][0]!
}
export async function resolveIpsecNatInterface(client: MikrotikClient): Promise<string> {
const [routes, interfaces] = await Promise.all([listByPath(client, "/ip/route"), listByPath(client, "/interface")])
return selectIpsecNatInterface(routes, interfaces)
}
/** Managed srcnat masquerade only through the verified default-route egress. */
export async function ensureNatRule(client: MikrotikClient, poolCidr: string, comment: string, outInterface: string): Promise<void> {
const rows = await listByPath(client, "/ip/firewall/nat")
const existing = rows.find(
(r) => (r.comment ?? "").trim() === comment && r.chain === "srcnat",
)
if (existing?.[".id"]) return
await client.put("/ip/firewall/nat", toRosBody({
const body = toRosBody({
chain: "srcnat",
action: "masquerade",
"src-address": poolCidr,
"out-interface-list": "WAN",
"out-interface": outInterface,
"ipsec-policy": "out,none",
comment,
}))
})
if (existing?.[".id"]) {
await client.patch(`/ip/firewall/nat/${encodeURIComponent(existing[".id"])}`, body)
} else await client.put("/ip/firewall/nat", body)
}
// ── клиенты ─────────────────────────────────────────────────────────────────
@@ -187,6 +227,7 @@ export interface IdentityFields {
remoteId?: string
/** Legacy hint: RouterOS identities do not accept place-before; specificity uses match-by/remote-certificate. */
placeBefore?: string
policyTemplateGroup?: string
}
export function identityRosBody(f: IdentityFields): Record<string, string> {
@@ -200,11 +241,16 @@ export function identityRosBody(f: IdentityFields): Record<string, string> {
"remote-id": f.remoteId === undefined ? undefined : normalizeIpsecRemoteId(f.remoteId),
"mode-config": f.modeConfig,
"generate-policy": "port-strict",
"policy-template-group": f.policyTemplateGroup,
comment: f.comment,
})
}
export async function putIdentity(client: MikrotikClient, fields: IdentityFields): Promise<void> {
if (!fields.policyTemplateGroup) {
const shared = selectSharedIke2Identity(await listByPath(client, "/ip/ipsec/identity"), [fields.peerName])
fields = { ...fields, policyTemplateGroup: shared?.["policy-template-group"] }
}
await client.put("/ip/ipsec/identity", identityRosBody(fields))
}
@@ -214,7 +260,7 @@ export async function putIdentity(client: MikrotikClient, fields: IdentityFields
*/
export async function ensureSharedIke2Identity(
client: MikrotikClient,
args: { peerName: string; serverCertName: string; modeConfigName?: string; comment: string },
args: { peerName: string; serverCertName: string; modeConfigName?: string; comment: string; policyTemplateGroup?: string },
): Promise<string | undefined> {
const identities = await listByPath(client, "/ip/ipsec/identity")
const peers = await listByPath(client, "/ip/ipsec/peer")
@@ -223,7 +269,13 @@ export async function ensureSharedIke2Identity(
identities as Ike2IdentityLike[],
peerNames.length > 0 ? peerNames : [args.peerName],
)
if (existing?.[".id"]) return String(existing[".id"])
if (existing?.[".id"]) {
if (args.policyTemplateGroup && existing["policy-template-group"] !== args.policyTemplateGroup) {
if (!isIpsecManagedComment(existing.comment)) throw new Error("Нельзя изменить группу политик чужой listener identity")
await client.patch(`/ip/ipsec/identity/${encodeURIComponent(existing[".id"])}`, { "policy-template-group": args.policyTemplateGroup })
}
return String(existing[".id"])
}
await putIdentity(client, {
peerName: args.peerName,
@@ -231,6 +283,7 @@ export async function ensureSharedIke2Identity(
comment: args.comment,
authMethod: "certificate",
certificate: args.serverCertName,
policyTemplateGroup: args.policyTemplateGroup,
})
return undefined
}