1056 lines
36 KiB
TypeScript
1056 lines
36 KiB
TypeScript
/** Канонические снапшоты и планы restore для firewall / WireGuard / GRE / IPsec. */
|
|
import { isIpsecManagedComment, isManagedIpsecModeConfig } from "./ipsec-config.js"
|
|
import { readGreComment, writeGreComment } from "./gre-comment.js"
|
|
import { isIP } from "node:net"
|
|
|
|
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> }
|
|
| { op: "patch"; path: string; body: Record<string, string> }
|
|
| { op: "delete"; path: string }
|
|
| { op: "move"; path: string; body: Record<string, string> }
|
|
|
|
export interface FirewallSnapshotRule {
|
|
family: FirewallFamily
|
|
table: FirewallTable
|
|
chain: string
|
|
action: string
|
|
protocol: string
|
|
srcAddress: string
|
|
dstAddress: string
|
|
srcAddressList: string
|
|
dstAddressList: string
|
|
srcPort: string
|
|
dstPort: string
|
|
inInterface: string
|
|
outInterface: string
|
|
connectionState: string
|
|
comment: string
|
|
disabled: boolean
|
|
log: boolean
|
|
logPrefix: string
|
|
tlsHost: string
|
|
layer7Proto: string
|
|
}
|
|
|
|
export interface FirewallSnapshotList {
|
|
family: FirewallFamily
|
|
list: string
|
|
address: string
|
|
comment: string
|
|
disabled: boolean
|
|
timeout: string
|
|
}
|
|
|
|
export interface FirewallSnapshot {
|
|
rules: FirewallSnapshotRule[]
|
|
addressLists: FirewallSnapshotList[]
|
|
}
|
|
|
|
export interface FirewallLiveRule extends FirewallSnapshotRule {
|
|
rosId: string
|
|
dynamic: boolean
|
|
}
|
|
|
|
export interface FirewallLiveList extends FirewallSnapshotList {
|
|
rosId: string
|
|
dynamic: boolean
|
|
}
|
|
|
|
export interface WgSnapshotPeer {
|
|
publicKey: string
|
|
allowedAddresses: string[]
|
|
endpointAddress: string
|
|
endpointPort: string
|
|
persistentKeepalive: number | null
|
|
comment: string
|
|
name: string
|
|
disabled: boolean
|
|
privateKey: string
|
|
clientAddress: string
|
|
clientDns: string
|
|
clientEndpoint: string
|
|
}
|
|
|
|
export interface WgSnapshotIface {
|
|
name: string
|
|
listenPort: number
|
|
mtu: number
|
|
privateKey: string
|
|
address: string
|
|
innerAddresses?: GreSnapshotAddress[]
|
|
comment: string
|
|
disabled: boolean
|
|
peers: WgSnapshotPeer[]
|
|
}
|
|
|
|
export interface WgSnapshot {
|
|
interfaces: WgSnapshotIface[]
|
|
}
|
|
|
|
export interface WgLiveIface {
|
|
name: string
|
|
rosId: string
|
|
listenPort: number
|
|
mtu: number
|
|
privateKey: string
|
|
comment: string
|
|
disabled: boolean
|
|
}
|
|
|
|
export interface WgLivePeer {
|
|
rosId: string
|
|
interfaceName: string
|
|
publicKey: string
|
|
allowedAddresses: string[]
|
|
endpointAddress: string
|
|
endpointPort: string
|
|
persistentKeepalive: number | null
|
|
comment: string
|
|
name: string
|
|
disabled: boolean
|
|
privateKey: string
|
|
clientAddress: string
|
|
clientDns: string
|
|
clientEndpoint: string
|
|
}
|
|
|
|
export interface WgLiveAddr extends GreLiveAddr {}
|
|
|
|
export interface GreSnapshotTunnel {
|
|
name: string
|
|
localAddress: string
|
|
remoteAddress: string
|
|
localInnerIp: string
|
|
innerAddresses?: GreSnapshotAddress[]
|
|
remoteInnerIp: string
|
|
comment: string
|
|
disabled: boolean
|
|
mtu: number
|
|
keepalive: string
|
|
dscp: string
|
|
clampTcpMss: boolean
|
|
allowFastPath: boolean
|
|
ipsecSecret: string
|
|
}
|
|
|
|
export interface GreSnapshot {
|
|
tunnels: GreSnapshotTunnel[]
|
|
}
|
|
|
|
export interface GreLiveIface {
|
|
name: string
|
|
rosId: string
|
|
localAddress: string
|
|
remoteAddress: string
|
|
comment: string
|
|
disabled: boolean
|
|
mtu: number
|
|
keepalive: string
|
|
dscp: string
|
|
clampTcpMss: boolean
|
|
allowFastPath: boolean
|
|
ipsecSecret: string
|
|
}
|
|
|
|
export interface GreLiveAddr {
|
|
rosId: string
|
|
interfaceName: string
|
|
address: string
|
|
disabled?: boolean
|
|
advertise?: boolean
|
|
dynamic?: boolean
|
|
comment?: string
|
|
noDad?: boolean
|
|
fromPool?: string
|
|
eui64?: boolean
|
|
}
|
|
|
|
export interface GreSnapshotAddress {
|
|
address: string
|
|
disabled: boolean
|
|
advertise?: boolean
|
|
comment?: string
|
|
noDad?: boolean
|
|
fromPool?: string
|
|
eui64?: boolean
|
|
}
|
|
|
|
export class InterfaceAddressConflict extends Error {}
|
|
export const GreRestoreConflict = InterfaceAddressConflict
|
|
|
|
export function greAddressPath(address: string): "/ip/address" | "/ipv6/address" {
|
|
const parts = address.split("/")
|
|
const family = isIP(parts[0] ?? "")
|
|
if (!family || address.includes("%") || parts.length !== 2 || !/^\d+$/.test(parts[1] ?? "") || Number(parts[1]) > (family === 6 ? 128 : 32)) {
|
|
throw new InterfaceAddressConflict("Некорректный адрес интерфейса: требуется IPv4/IPv6 с префиксом")
|
|
}
|
|
return family === 6 ? "/ipv6/address" : "/ip/address"
|
|
}
|
|
|
|
export function greAddressKey(address: string): string {
|
|
const path = greAddressPath(address)
|
|
const [ip, prefix] = address.split("/")
|
|
return `${path === "/ipv6/address" ? new URL(`http://[${ip}]`).hostname : ip}/${Number(prefix)}`
|
|
}
|
|
|
|
export const interfaceAddressPath = greAddressPath
|
|
export const interfaceAddressKey = greAddressKey
|
|
|
|
// ── IPsec / IKEv2 (managed-объекты, маркер MikrotikManager:ipsec) ────────────
|
|
|
|
export interface IpsecSnapshotPeer {
|
|
name: string
|
|
address: string
|
|
exchangeMode: string
|
|
passive: boolean
|
|
certificate: string
|
|
profile: string
|
|
comment: string
|
|
disabled: boolean
|
|
}
|
|
|
|
export interface IpsecSnapshotIdentity {
|
|
peerName: string
|
|
authMethod: string
|
|
certificate: string
|
|
remoteCertificate: string
|
|
matchBy: string
|
|
secret: string
|
|
remoteId: string
|
|
modeConfig: string
|
|
generatePolicy: string
|
|
comment: string
|
|
disabled: boolean
|
|
policyTemplateGroup?: string
|
|
}
|
|
|
|
export interface IpsecSnapshotModeConfig {
|
|
name: string
|
|
addressPool: string
|
|
address: string
|
|
staticDns: string
|
|
comment: string
|
|
}
|
|
|
|
export interface IpsecSnapshotPool {
|
|
name: string
|
|
ranges: string
|
|
comment: string
|
|
}
|
|
|
|
export interface IpsecSnapshotPolicy {
|
|
srcAddress: string
|
|
dstAddress: string
|
|
proposal: string
|
|
comment: string
|
|
}
|
|
|
|
export interface IpsecSnapshotNat {
|
|
chain: string
|
|
action: string
|
|
srcAddress: string
|
|
comment: string
|
|
outInterface?: string
|
|
outInterfaceList?: string
|
|
ipsecPolicy?: string
|
|
}
|
|
|
|
export interface IpsecSnapshot {
|
|
peers: IpsecSnapshotPeer[]
|
|
identities: IpsecSnapshotIdentity[]
|
|
modeConfigs: IpsecSnapshotModeConfig[]
|
|
pools: IpsecSnapshotPool[]
|
|
policies: IpsecSnapshotPolicy[]
|
|
nat: IpsecSnapshotNat[]
|
|
}
|
|
|
|
export interface IpsecLivePeer extends IpsecSnapshotPeer {
|
|
rosId: string
|
|
}
|
|
|
|
export interface IpsecLiveIdentity extends IpsecSnapshotIdentity {
|
|
rosId: string
|
|
}
|
|
|
|
export interface IpsecLiveModeConfig extends IpsecSnapshotModeConfig {
|
|
rosId: string
|
|
}
|
|
|
|
export interface IpsecLivePool extends IpsecSnapshotPool {
|
|
rosId: string
|
|
}
|
|
|
|
export interface IpsecLivePolicy extends IpsecSnapshotPolicy {
|
|
rosId: string
|
|
}
|
|
|
|
export interface IpsecLiveNat extends IpsecSnapshotNat {
|
|
rosId: string
|
|
}
|
|
|
|
function str(v: unknown): string {
|
|
return String(v ?? "").trim()
|
|
}
|
|
|
|
function bool(v: unknown): boolean {
|
|
if (typeof v === "boolean") return v
|
|
const s = str(v).toLowerCase()
|
|
return s === "true" || s === "yes" || s === "1"
|
|
}
|
|
|
|
function num(v: unknown, fallback: number): number {
|
|
const n = typeof v === "number" ? v : Number.parseInt(str(v), 10)
|
|
return Number.isFinite(n) ? n : fallback
|
|
}
|
|
|
|
export function isHiddenSecret(value: string | undefined): boolean {
|
|
const s = str(value)
|
|
if (!s) return true
|
|
if (s === "(hidden)") return true
|
|
return /^\*+$/.test(s)
|
|
}
|
|
|
|
export function firewallRestPath(
|
|
family: FirewallFamily,
|
|
table: FirewallTable | "address-list",
|
|
): string {
|
|
const root = family === "ip6" ? "/ipv6/firewall" : "/ip/firewall"
|
|
return `${root}/${table}`
|
|
}
|
|
|
|
function rosYesNo(v: boolean | undefined): string | undefined {
|
|
if (v === true) return "yes"
|
|
if (v === false) return "no"
|
|
return undefined
|
|
}
|
|
|
|
function compactBody(obj: Record<string, string | undefined>): Record<string, string> {
|
|
const out: Record<string, string> = {}
|
|
for (const [k, v] of Object.entries(obj)) {
|
|
if (v !== undefined && v !== "") out[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
export function canonicalFirewallSnapshot(input: {
|
|
rules?: Array<Partial<FirewallSnapshotRule>>
|
|
addressLists?: Array<Partial<FirewallSnapshotList>>
|
|
}): FirewallSnapshot {
|
|
const rules = (input.rules ?? []).map((r) => ({
|
|
family: r.family === "ip6" ? "ip6" as const : "ip" as const,
|
|
table: (["filter", "nat", "mangle", "raw"] as const).includes(r.table as FirewallTable)
|
|
? (r.table as FirewallTable)
|
|
: "filter",
|
|
chain: str(r.chain),
|
|
action: str(r.action),
|
|
protocol: str(r.protocol),
|
|
srcAddress: str(r.srcAddress),
|
|
dstAddress: str(r.dstAddress),
|
|
srcAddressList: str(r.srcAddressList),
|
|
dstAddressList: str(r.dstAddressList),
|
|
srcPort: str(r.srcPort),
|
|
dstPort: str(r.dstPort),
|
|
inInterface: str(r.inInterface),
|
|
outInterface: str(r.outInterface),
|
|
connectionState: str(r.connectionState),
|
|
comment: str(r.comment),
|
|
disabled: Boolean(r.disabled),
|
|
log: Boolean(r.log),
|
|
logPrefix: str(r.logPrefix),
|
|
tlsHost: str(r.tlsHost),
|
|
layer7Proto: str(r.layer7Proto),
|
|
}))
|
|
const addressLists = (input.addressLists ?? []).map((e) => ({
|
|
family: e.family === "ip6" ? "ip6" as const : "ip" as const,
|
|
list: str(e.list),
|
|
address: str(e.address),
|
|
comment: str(e.comment),
|
|
disabled: Boolean(e.disabled),
|
|
timeout: str(e.timeout),
|
|
}))
|
|
return { rules, addressLists }
|
|
}
|
|
|
|
export function parseFirewallSnapshot(payload: unknown): FirewallSnapshot {
|
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
return { rules: [], addressLists: [] }
|
|
}
|
|
const o = payload as Record<string, unknown>
|
|
return canonicalFirewallSnapshot({
|
|
rules: Array.isArray(o.rules) ? o.rules as Partial<FirewallSnapshotRule>[] : [],
|
|
addressLists: Array.isArray(o.addressLists) ? o.addressLists as Partial<FirewallSnapshotList>[] : [],
|
|
})
|
|
}
|
|
|
|
function firewallRuleKey(r: FirewallSnapshotRule): string {
|
|
return [
|
|
r.family, r.table, r.chain, r.action, r.protocol,
|
|
r.srcAddress, r.dstAddress, r.srcAddressList, r.dstAddressList,
|
|
r.srcPort, r.dstPort, r.inInterface, r.outInterface, r.connectionState,
|
|
r.comment, r.disabled ? "1" : "0", r.log ? "1" : "0", r.logPrefix, r.tlsHost, r.layer7Proto,
|
|
].join("\0")
|
|
}
|
|
|
|
function firewallListKey(e: FirewallSnapshotList): string {
|
|
return [e.family, e.list, e.address, e.comment, e.disabled ? "1" : "0", e.timeout].join("\0")
|
|
}
|
|
|
|
function firewallRuleBody(r: FirewallSnapshotRule): Record<string, string> {
|
|
return compactBody({
|
|
chain: r.chain,
|
|
action: r.action,
|
|
protocol: r.protocol && r.protocol !== "all" ? r.protocol : undefined,
|
|
"src-address": r.srcAddress,
|
|
"dst-address": r.dstAddress,
|
|
"src-address-list": r.srcAddressList,
|
|
"dst-address-list": r.dstAddressList,
|
|
"src-port": r.srcPort,
|
|
"dst-port": r.dstPort,
|
|
"in-interface": r.inInterface,
|
|
"out-interface": r.outInterface,
|
|
"connection-state": r.connectionState,
|
|
comment: r.comment,
|
|
disabled: rosYesNo(r.disabled),
|
|
log: rosYesNo(r.log),
|
|
"log-prefix": r.logPrefix,
|
|
"tls-host": r.tlsHost,
|
|
"layer7-protocol": r.layer7Proto,
|
|
})
|
|
}
|
|
|
|
export function planFirewallRestore(
|
|
desiredInput: FirewallSnapshot,
|
|
current: { rules: FirewallLiveRule[]; addressLists: FirewallLiveList[] },
|
|
): RosWriteOp[] {
|
|
const desired = canonicalFirewallSnapshot(desiredInput)
|
|
const ops: RosWriteOp[] = []
|
|
const usedRules = new Set<string>()
|
|
const usedLists = new Set<string>()
|
|
|
|
for (const live of current.rules) {
|
|
if (live.dynamic) continue
|
|
const key = firewallRuleKey(live)
|
|
const stillWanted = desired.rules.some((d) => firewallRuleKey(d) === key)
|
|
if (!stillWanted) {
|
|
ops.push({
|
|
op: "delete",
|
|
path: `${firewallRestPath(live.family, live.table)}/${live.rosId}`,
|
|
})
|
|
} else {
|
|
usedRules.add(key)
|
|
}
|
|
}
|
|
|
|
for (const live of current.addressLists) {
|
|
if (live.dynamic) continue
|
|
const key = firewallListKey(live)
|
|
const stillWanted = desired.addressLists.some((d) => firewallListKey(d) === key)
|
|
if (!stillWanted) {
|
|
ops.push({
|
|
op: "delete",
|
|
path: `${firewallRestPath(live.family, "address-list")}/${live.rosId}`,
|
|
})
|
|
} else {
|
|
usedLists.add(key)
|
|
}
|
|
}
|
|
|
|
for (const rule of desired.rules) {
|
|
if (usedRules.has(firewallRuleKey(rule))) continue
|
|
ops.push({
|
|
op: "put",
|
|
path: firewallRestPath(rule.family, rule.table),
|
|
body: firewallRuleBody(rule),
|
|
})
|
|
}
|
|
|
|
for (const entry of desired.addressLists) {
|
|
if (usedLists.has(firewallListKey(entry))) continue
|
|
ops.push({
|
|
op: "put",
|
|
path: firewallRestPath(entry.family, "address-list"),
|
|
body: compactBody({
|
|
list: entry.list,
|
|
address: entry.address,
|
|
comment: entry.comment,
|
|
timeout: entry.timeout,
|
|
disabled: rosYesNo(entry.disabled),
|
|
}),
|
|
})
|
|
}
|
|
|
|
return ops
|
|
}
|
|
|
|
function canonicalPeer(p: Partial<WgSnapshotPeer>): WgSnapshotPeer {
|
|
const allowed = Array.isArray(p.allowedAddresses)
|
|
? p.allowedAddresses.map((a) => str(a)).filter(Boolean)
|
|
: str((p as { allowedIps?: unknown }).allowedIps)
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean)
|
|
return {
|
|
publicKey: str(p.publicKey),
|
|
allowedAddresses: allowed,
|
|
endpointAddress: str(p.endpointAddress),
|
|
endpointPort: str(p.endpointPort),
|
|
persistentKeepalive: p.persistentKeepalive == null ? null : num(p.persistentKeepalive, 0) || null,
|
|
comment: str(p.comment),
|
|
name: str(p.name),
|
|
disabled: Boolean(p.disabled),
|
|
privateKey: str(p.privateKey),
|
|
clientAddress: str(p.clientAddress),
|
|
clientDns: str(p.clientDns),
|
|
clientEndpoint: str(p.clientEndpoint),
|
|
}
|
|
}
|
|
|
|
export function canonicalWireguardSnapshot(input: {
|
|
interfaces?: Array<Partial<WgSnapshotIface> & { peers?: Array<Partial<WgSnapshotPeer>> }>
|
|
}): WgSnapshot {
|
|
const interfaces = (input.interfaces ?? [])
|
|
.map((iface) => ({
|
|
name: str(iface.name),
|
|
listenPort: num(iface.listenPort, 13231),
|
|
mtu: num(iface.mtu, 1420),
|
|
privateKey: str(iface.privateKey),
|
|
address: str(iface.address),
|
|
...(iface.innerAddresses === undefined ? {} : { innerAddresses: canonicalGreSnapshot({ tunnels: [{ name: iface.name, innerAddresses: iface.innerAddresses }] }).tunnels[0]?.innerAddresses ?? [] }),
|
|
comment: str(iface.comment),
|
|
disabled: Boolean(iface.disabled),
|
|
peers: (iface.peers ?? []).map(canonicalPeer).sort((a, b) => a.publicKey.localeCompare(b.publicKey)),
|
|
}))
|
|
.filter((i) => i.name)
|
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
return { interfaces }
|
|
}
|
|
|
|
export function parseWireguardSnapshot(payload: unknown): WgSnapshot {
|
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
return { interfaces: [] }
|
|
}
|
|
const o = payload as Record<string, unknown>
|
|
return canonicalWireguardSnapshot({
|
|
interfaces: Array.isArray(o.interfaces)
|
|
? o.interfaces as Array<Partial<WgSnapshotIface> & { peers?: Array<Partial<WgSnapshotPeer>> }>
|
|
: [],
|
|
})
|
|
}
|
|
|
|
function peerBody(interfaceName: string, p: WgSnapshotPeer): Record<string, string> {
|
|
return compactBody({
|
|
interface: interfaceName,
|
|
"public-key": p.publicKey,
|
|
"allowed-address": p.allowedAddresses.join(","),
|
|
"endpoint-address": p.endpointAddress,
|
|
"endpoint-port": p.endpointPort,
|
|
"persistent-keepalive": p.persistentKeepalive != null ? String(p.persistentKeepalive) : undefined,
|
|
comment: p.comment,
|
|
name: p.name,
|
|
"private-key": isHiddenSecret(p.privateKey) ? undefined : p.privateKey,
|
|
"client-address": p.clientAddress,
|
|
"client-dns": p.clientDns,
|
|
"client-endpoint": p.clientEndpoint,
|
|
disabled: rosYesNo(p.disabled),
|
|
})
|
|
}
|
|
|
|
export function planWireguardRestore(
|
|
desiredInput: WgSnapshot,
|
|
current: { ifaces: WgLiveIface[]; peers: WgLivePeer[]; addrs: WgLiveAddr[] },
|
|
): RosWriteOp[] {
|
|
const desired = canonicalWireguardSnapshot(desiredInput)
|
|
const wantedNames = new Set(desired.interfaces.map((i) => i.name))
|
|
const ops: RosWriteOp[] = []
|
|
|
|
for (const peer of current.peers) {
|
|
const iface = desired.interfaces.find((i) => i.name === peer.interfaceName)
|
|
const keep = iface?.peers.some((p) => p.publicKey === peer.publicKey)
|
|
if (!keep) {
|
|
ops.push({ op: "delete", path: `/interface/wireguard/peers/${peer.rosId}` })
|
|
}
|
|
}
|
|
|
|
for (const addr of current.addrs) {
|
|
if (!wantedNames.has(addr.interfaceName) && !addr.dynamic) {
|
|
ops.push({ op: "delete", path: `${interfaceAddressPath(addr.address)}/${addr.rosId}` })
|
|
}
|
|
}
|
|
|
|
for (const iface of current.ifaces) {
|
|
if (!wantedNames.has(iface.name)) {
|
|
ops.push({ op: "delete", path: `/interface/wireguard/${iface.rosId}` })
|
|
}
|
|
}
|
|
|
|
for (const want of desired.interfaces) {
|
|
const live = current.ifaces.find((i) => i.name === want.name)
|
|
const ifaceBody = compactBody({
|
|
name: want.name,
|
|
"listen-port": String(want.listenPort),
|
|
mtu: String(want.mtu),
|
|
"private-key": isHiddenSecret(want.privateKey) ? undefined : want.privateKey,
|
|
comment: want.comment,
|
|
disabled: rosYesNo(want.disabled),
|
|
})
|
|
if (!live) {
|
|
ops.push({ op: "put", path: "/interface/wireguard", body: ifaceBody })
|
|
} else {
|
|
ops.push({
|
|
op: "patch",
|
|
path: `/interface/wireguard/${live.rosId}`,
|
|
body: ifaceBody,
|
|
})
|
|
}
|
|
|
|
ops.push(...planStaticInterfaceAddresses(want.name, want.address, want.innerAddresses, current.addrs))
|
|
|
|
for (const peer of want.peers) {
|
|
if (!peer.publicKey) continue
|
|
const livePeer = current.peers.find(
|
|
(p) => p.interfaceName === want.name && p.publicKey === peer.publicKey,
|
|
)
|
|
const body = peerBody(want.name, peer)
|
|
if (!livePeer) {
|
|
ops.push({ op: "put", path: "/interface/wireguard/peers", body })
|
|
} else {
|
|
ops.push({
|
|
op: "patch",
|
|
path: `/interface/wireguard/peers/${livePeer.rosId}`,
|
|
body,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return ops
|
|
}
|
|
|
|
// ── IPsec: canonical / parse / plan ─────────────────────────────────────────
|
|
|
|
function canonicalIpsecPeerRaw(p: Partial<IpsecSnapshotPeer>): IpsecSnapshotPeer {
|
|
return {
|
|
name: str(p.name),
|
|
address: str(p.address),
|
|
exchangeMode: str(p.exchangeMode),
|
|
passive: Boolean(p.passive),
|
|
certificate: str(p.certificate),
|
|
profile: str(p.profile),
|
|
comment: str(p.comment),
|
|
disabled: Boolean(p.disabled),
|
|
}
|
|
}
|
|
|
|
function canonicalIpsecIdentityRaw(i: Partial<IpsecSnapshotIdentity>): IpsecSnapshotIdentity {
|
|
return {
|
|
peerName: str(i.peerName),
|
|
authMethod: str(i.authMethod),
|
|
certificate: str(i.certificate),
|
|
remoteCertificate: str(i.remoteCertificate),
|
|
matchBy: str(i.matchBy),
|
|
secret: str(i.secret),
|
|
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),
|
|
}
|
|
}
|
|
|
|
function canonicalIpsecModeConfigRaw(m: Partial<IpsecSnapshotModeConfig>): IpsecSnapshotModeConfig {
|
|
return {
|
|
name: str(m.name),
|
|
addressPool: str(m.addressPool),
|
|
address: str(m.address),
|
|
staticDns: str(m.staticDns),
|
|
comment: str(m.comment),
|
|
}
|
|
}
|
|
|
|
export function canonicalIpsecSnapshot(input: {
|
|
peers?: Array<Partial<IpsecSnapshotPeer>>
|
|
identities?: Array<Partial<IpsecSnapshotIdentity>>
|
|
modeConfigs?: Array<Partial<IpsecSnapshotModeConfig>>
|
|
pools?: Array<Partial<IpsecSnapshotPool>>
|
|
policies?: Array<Partial<IpsecSnapshotPolicy>>
|
|
nat?: Array<Partial<IpsecSnapshotNat>>
|
|
}): IpsecSnapshot {
|
|
return {
|
|
peers: (input.peers ?? []).map(canonicalIpsecPeerRaw).filter((p) => p.name).sort((a, b) => a.name.localeCompare(b.name)),
|
|
identities: (input.identities ?? [])
|
|
.map(canonicalIpsecIdentityRaw)
|
|
.filter((i) => i.comment)
|
|
.sort((a, b) => a.comment.localeCompare(b.comment)),
|
|
modeConfigs: (input.modeConfigs ?? [])
|
|
.map(canonicalIpsecModeConfigRaw)
|
|
.filter((m) => m.name)
|
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
pools: (input.pools ?? [])
|
|
.map((p) => ({ name: str(p.name), ranges: str(p.ranges), comment: str(p.comment) }))
|
|
.filter((p) => p.name)
|
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
policies: (input.policies ?? [])
|
|
.map((p) => ({ srcAddress: str(p.srcAddress), dstAddress: str(p.dstAddress), proposal: str(p.proposal), comment: str(p.comment) }))
|
|
.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), ...(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)),
|
|
}
|
|
}
|
|
|
|
export function parseIpsecSnapshot(payload: unknown): IpsecSnapshot {
|
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
return canonicalIpsecSnapshot({})
|
|
}
|
|
const o = payload as Record<string, unknown>
|
|
return canonicalIpsecSnapshot({
|
|
peers: Array.isArray(o.peers) ? o.peers as Array<Partial<IpsecSnapshotPeer>> : [],
|
|
identities: Array.isArray(o.identities) ? o.identities as Array<Partial<IpsecSnapshotIdentity>> : [],
|
|
modeConfigs: Array.isArray(o.modeConfigs) ? o.modeConfigs as Array<Partial<IpsecSnapshotModeConfig>> : [],
|
|
pools: Array.isArray(o.pools) ? o.pools as Array<Partial<IpsecSnapshotPool>> : [],
|
|
policies: Array.isArray(o.policies) ? o.policies as Array<Partial<IpsecSnapshotPolicy>> : [],
|
|
nat: Array.isArray(o.nat) ? o.nat as Array<Partial<IpsecSnapshotNat>> : [],
|
|
})
|
|
}
|
|
|
|
function ipsecPeerBody(p: IpsecSnapshotPeer): Record<string, string> {
|
|
return compactBody({
|
|
name: p.name,
|
|
address: p.address,
|
|
"exchange-mode": p.exchangeMode,
|
|
passive: rosYesNo(p.passive),
|
|
profile: p.profile,
|
|
comment: p.comment,
|
|
disabled: rosYesNo(p.disabled),
|
|
})
|
|
}
|
|
|
|
function ipsecIdentityBody(i: IpsecSnapshotIdentity): Record<string, string> {
|
|
return compactBody({
|
|
peer: i.peerName,
|
|
"auth-method": i.authMethod,
|
|
certificate: i.certificate,
|
|
"remote-certificate": i.remoteCertificate,
|
|
"match-by": i.matchBy,
|
|
secret: isHiddenSecret(i.secret) ? undefined : i.secret,
|
|
"remote-id": i.remoteId,
|
|
"mode-config": i.modeConfig,
|
|
"generate-policy": i.generatePolicy,
|
|
"policy-template-group": i.policyTemplateGroup,
|
|
comment: i.comment,
|
|
disabled: rosYesNo(i.disabled),
|
|
})
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
function ipsecPoolBody(p: IpsecSnapshotPool): Record<string, string> {
|
|
return compactBody({ name: p.name, ranges: p.ranges, comment: p.comment })
|
|
}
|
|
|
|
function ipsecNatBody(n: IpsecSnapshotNat): Record<string, string> {
|
|
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) не перезаписываем;
|
|
* чужие (не ipsec-managed) live-объекты не трогаем даже если их передали). */
|
|
export function planIpsecRestore(
|
|
desiredInput: IpsecSnapshot,
|
|
current: {
|
|
peers: IpsecLivePeer[]
|
|
identities: IpsecLiveIdentity[]
|
|
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[] = []
|
|
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))
|
|
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}` })
|
|
}
|
|
}
|
|
for (const mc of managedModeConfigs) {
|
|
if (!desired.modeConfigs.some((m) => m.name === mc.name)) {
|
|
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)) {
|
|
removeDependents.push({ op: "delete", path: `/ip/ipsec/peer/${peer.rosId}` })
|
|
}
|
|
}
|
|
for (const pool of managedPools) {
|
|
if (!desired.pools.some((p) => p.name === pool.name)) {
|
|
removeDependents.push({ op: "delete", path: `/ip/pool/${pool.rosId}` })
|
|
}
|
|
}
|
|
for (const rule of managedNat) {
|
|
if (!desired.nat.some((n) => n.comment === rule.comment)) {
|
|
ops.push({ op: "delete", path: `/ip/firewall/nat/${rule.rosId}` })
|
|
}
|
|
}
|
|
|
|
for (const want of desired.peers) {
|
|
const live = managedPeers.find((p) => p.name === want.name)
|
|
const body = ipsecPeerBody(want)
|
|
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.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)
|
|
if (!live) ops.push({ op: "put", path: "/ip/ipsec/identity", body })
|
|
else ops.push({ op: "patch", path: `/ip/ipsec/identity/${live.rosId}`, body })
|
|
}
|
|
for (const want of desired.nat) {
|
|
const live = managedNat.find((n) => n.comment === want.comment)
|
|
const body = ipsecNatBody(want)
|
|
if (!live) ops.push({ op: "put", path: "/ip/firewall/nat", body })
|
|
else ops.push({ op: "patch", path: `/ip/firewall/nat/${live.rosId}`, body })
|
|
}
|
|
|
|
return [...ops, ...removeDependents]
|
|
}
|
|
|
|
export function canonicalGreSnapshot(input: {
|
|
tunnels?: Array<Partial<GreSnapshotTunnel>>
|
|
}): GreSnapshot {
|
|
const tunnels = (input.tunnels ?? [])
|
|
.map((t) => ({
|
|
name: str(t.name),
|
|
localAddress: str(t.localAddress),
|
|
remoteAddress: str(t.remoteAddress),
|
|
localInnerIp: str(t.localInnerIp),
|
|
...(t.innerAddresses === undefined ? {} : {
|
|
innerAddresses: t.innerAddresses.map(a => ({
|
|
address: str(a.address), disabled: bool(a.disabled),
|
|
...(a.comment === undefined ? {} : { comment: str(a.comment) }),
|
|
...(greAddressPath(str(a.address)) === "/ipv6/address" ? { advertise: a.advertise === true,
|
|
...(a.noDad === undefined ? {} : { noDad: a.noDad === true }),
|
|
...(a.fromPool ? { fromPool: str(a.fromPool) } : {}),
|
|
...(a.eui64 ? { eui64: true } : {}),
|
|
} : {}),
|
|
})).sort((a, b) => a.address.localeCompare(b.address)),
|
|
}),
|
|
remoteInnerIp: str(t.remoteInnerIp),
|
|
comment: str(t.comment),
|
|
disabled: Boolean(t.disabled),
|
|
mtu: num(t.mtu, 1476),
|
|
keepalive: str(t.keepalive) || "0",
|
|
dscp: str(t.dscp) || "inherit",
|
|
clampTcpMss: t.clampTcpMss !== false,
|
|
allowFastPath: t.allowFastPath !== false,
|
|
ipsecSecret: str(t.ipsecSecret),
|
|
}))
|
|
.filter((t) => t.name)
|
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
return { tunnels }
|
|
}
|
|
|
|
export function parseGreSnapshot(payload: unknown): GreSnapshot {
|
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
return { tunnels: [] }
|
|
}
|
|
const o = payload as Record<string, unknown>
|
|
return canonicalGreSnapshot({
|
|
tunnels: Array.isArray(o.tunnels) ? o.tunnels as Array<Partial<GreSnapshotTunnel>> : [],
|
|
})
|
|
}
|
|
|
|
export function greInterfaceBody(t: GreSnapshotTunnel): Record<string, string> {
|
|
const stored = readGreComment(t.comment)
|
|
const peer = t.remoteInnerIp || (stored.comment === t.comment ? stored.remoteInnerIp : "")
|
|
return compactBody({
|
|
name: t.name,
|
|
"local-address": t.localAddress && t.localAddress !== "0.0.0.0" ? t.localAddress : undefined,
|
|
"remote-address": t.remoteAddress,
|
|
mtu: String(t.mtu),
|
|
keepalive: t.keepalive,
|
|
dscp: t.dscp,
|
|
"clamp-tcp-mss": t.clampTcpMss ? "yes" : "no",
|
|
"allow-fast-path": t.allowFastPath ? "yes" : "no",
|
|
comment: writeGreComment(t.comment, peer),
|
|
disabled: rosYesNo(t.disabled),
|
|
"ipsec-secret": isHiddenSecret(t.ipsecSecret) ? undefined : t.ipsecSecret,
|
|
})
|
|
}
|
|
|
|
export function planGreCreate(tunnel: GreSnapshotTunnel): RosWriteOp[] {
|
|
const t = canonicalGreSnapshot({ tunnels: [tunnel] }).tunnels[0]
|
|
if (!t) return []
|
|
const ops: RosWriteOp[] = [
|
|
{ op: "put", path: "/interface/gre", body: greInterfaceBody(t) },
|
|
]
|
|
const addresses = t.innerAddresses ?? (t.localInnerIp ? [{ address: t.localInnerIp, disabled: false }] : [])
|
|
if (new Set(addresses.map(a => greAddressKey(a.address))).size !== addresses.length) throw new GreRestoreConflict("В ревизии GRE повторяются внутренние адреса")
|
|
for (const a of addresses) {
|
|
ops.push({
|
|
op: "put",
|
|
path: greAddressPath(a.address),
|
|
body: greAddressBody(a, t.name),
|
|
})
|
|
}
|
|
return ops
|
|
}
|
|
|
|
export function planGreDelete(
|
|
name: string,
|
|
current: { gre: GreLiveIface[]; addrs: GreLiveAddr[] },
|
|
): RosWriteOp[] {
|
|
const want = str(name)
|
|
const ops: RosWriteOp[] = []
|
|
for (const addr of current.addrs) {
|
|
if (addr.interfaceName === want && !addr.dynamic) {
|
|
ops.push({ op: "delete", path: `${greAddressPath(addr.address)}/${addr.rosId}` })
|
|
}
|
|
}
|
|
for (const gre of current.gre) {
|
|
if (gre.name === want) {
|
|
ops.push({ op: "delete", path: `/interface/gre/${gre.rosId}` })
|
|
}
|
|
}
|
|
return ops
|
|
}
|
|
|
|
export function planGreRestore(
|
|
desiredInput: GreSnapshot,
|
|
current: { gre: GreLiveIface[]; addrs: GreLiveAddr[] },
|
|
): RosWriteOp[] {
|
|
const desired = canonicalGreSnapshot(desiredInput)
|
|
const wanted = new Set(desired.tunnels.map((t) => t.name))
|
|
const ops: RosWriteOp[] = []
|
|
|
|
for (const gre of current.gre) {
|
|
if (!wanted.has(gre.name)) {
|
|
ops.push(...planGreDelete(gre.name, current))
|
|
}
|
|
}
|
|
|
|
for (const want of desired.tunnels) {
|
|
const live = current.gre.find((g) => g.name === want.name)
|
|
const body = greInterfaceBody(want)
|
|
if (!live) {
|
|
ops.push({ op: "put", path: "/interface/gre", body })
|
|
} else {
|
|
ops.push({ op: "patch", path: `/interface/gre/${live.rosId}`, body })
|
|
}
|
|
|
|
ops.push(...planStaticInterfaceAddresses(want.name, want.localInnerIp, want.innerAddresses, current.addrs))
|
|
}
|
|
|
|
return ops
|
|
}
|
|
|
|
export function planStaticInterfaceAddresses(name: string, primary: string, desired: GreSnapshotAddress[] | undefined, current: GreLiveAddr[]): RosWriteOp[] {
|
|
const ops: RosWriteOp[] = []
|
|
const liveAddrs = current.filter(a => a.interfaceName === name && !a.dynamic)
|
|
if (liveAddrs.some(a => a.fromPool || a.eui64)) throw new InterfaceAddressConflict("Интерфейс содержит адрес из IPv6-пула или EUI-64. Измените его отдельно в RouterOS")
|
|
if (desired === undefined && liveAddrs.length > 1) {
|
|
throw new InterfaceAddressConflict("Старая ревизия не описывает все адреса интерфейса. Восстановление остановлено")
|
|
}
|
|
const addresses = desired ?? (primary ? [{ address: primary, disabled: false }] : [])
|
|
const keys = new Set(addresses.map(a => greAddressKey(a.address)))
|
|
if (keys.size !== addresses.length) throw new InterfaceAddressConflict("В ревизии повторяются внутренние адреса")
|
|
for (const addr of liveAddrs) {
|
|
if (!keys.has(greAddressKey(addr.address))) ops.push({ op: "delete", path: `${greAddressPath(addr.address)}/${addr.rosId}` })
|
|
}
|
|
for (const addr of addresses) {
|
|
const existing = liveAddrs.find(a => greAddressKey(a.address) === greAddressKey(addr.address))
|
|
if (!existing) ops.push({ op: "put", path: greAddressPath(addr.address), body: greAddressBody(addr, name) })
|
|
else if (Boolean(existing.disabled) !== addr.disabled || (addr.comment !== undefined && str(existing.comment) !== addr.comment) || (greAddressPath(addr.address) === "/ipv6/address" && (Boolean(existing.advertise) !== Boolean(addr.advertise) || (addr.noDad !== undefined && Boolean(existing.noDad) !== addr.noDad)))) {
|
|
ops.push({ op: "patch", path: `${greAddressPath(addr.address)}/${existing.rosId}`, body: greAddressBody(addr, name) })
|
|
}
|
|
}
|
|
return ops
|
|
}
|
|
|
|
function greAddressBody(a: GreSnapshotAddress, name: string): Record<string, string> {
|
|
if (a.fromPool || a.eui64) throw new GreRestoreConflict("Восстановление IPv6-пула/EUI-64 требует отдельной настройки RouterOS")
|
|
return {
|
|
address: a.address, interface: name,
|
|
...(a.comment === undefined ? {} : { comment: a.comment }),
|
|
...(a.disabled ? { disabled: "yes" } : { disabled: "no" }),
|
|
...(greAddressPath(a.address) === "/ipv6/address" ? { advertise: a.advertise ? "yes" : "no" } : {}),
|
|
...(a.noDad === undefined ? {} : { "no-dad": a.noDad ? "yes" : "no" }),
|
|
}
|
|
}
|
|
|
|
export function opsPaths(ops: RosWriteOp[]): string[] {
|
|
return ops.map((op) => op.path)
|
|
}
|
|
|
|
export function opsTouchOnly(ops: RosWriteOp[], prefixes: string[]): boolean {
|
|
return ops.every((op) => prefixes.some((p) => op.path === p || op.path.startsWith(`${p}/`)))
|
|
}
|