/** Канонические снапшоты и планы restore для firewall / WireGuard / GRE / IPsec. */ import { isIpsecManagedComment } from "./ipsec-config.js" import { readGreComment, writeGreComment } from "./gre-comment.js" export type FirewallFamily = "ip" | "ip6" export type FirewallTable = "filter" | "nat" | "mangle" | "raw" export type RosWriteOp = | { op: "put"; path: string; body: Record } | { op: "post"; path: string; body: Record } | { op: "patch"; path: string; body: Record } | { op: "delete"; path: string } | { op: "move"; path: string; body: Record } 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 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 { rosId: string interfaceName: string address: string } export interface GreSnapshotTunnel { name: string localAddress: string remoteAddress: string localInnerIp: string 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 } // ── 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 } 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 } 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): Record { const out: Record = {} for (const [k, v] of Object.entries(obj)) { if (v !== undefined && v !== "") out[k] = v } return out } export function canonicalFirewallSnapshot(input: { rules?: Array> addressLists?: Array> }): 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 return canonicalFirewallSnapshot({ rules: Array.isArray(o.rules) ? o.rules as Partial[] : [], addressLists: Array.isArray(o.addressLists) ? o.addressLists as Partial[] : [], }) } 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 { 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() const usedLists = new Set() 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 { 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 & { peers?: Array> }> }): 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), 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 return canonicalWireguardSnapshot({ interfaces: Array.isArray(o.interfaces) ? o.interfaces as Array & { peers?: Array> }> : [], }) } function peerBody(interfaceName: string, p: WgSnapshotPeer): Record { 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)) { ops.push({ op: "delete", path: `/ip/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, }) } const liveAddr = current.addrs.find((a) => a.interfaceName === want.name) if (want.address) { if (!liveAddr) { ops.push({ op: "put", path: "/ip/address", body: { address: want.address, interface: want.name } }) } else if (liveAddr.address !== want.address) { ops.push({ op: "delete", path: `/ip/address/${liveAddr.rosId}` }) ops.push({ op: "put", path: "/ip/address", body: { address: want.address, interface: want.name } }) } } else if (liveAddr) { ops.push({ op: "delete", path: `/ip/address/${liveAddr.rosId}` }) } 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 { 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 { 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), comment: str(i.comment), disabled: Boolean(i.disabled), } } function canonicalIpsecModeConfigRaw(m: Partial): 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> identities?: Array> modeConfigs?: Array> pools?: Array> policies?: Array> nat?: Array> }): 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) })) .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 return canonicalIpsecSnapshot({ peers: Array.isArray(o.peers) ? o.peers as Array> : [], identities: Array.isArray(o.identities) ? o.identities as Array> : [], modeConfigs: Array.isArray(o.modeConfigs) ? o.modeConfigs as Array> : [], pools: Array.isArray(o.pools) ? o.pools as Array> : [], policies: Array.isArray(o.policies) ? o.policies as Array> : [], nat: Array.isArray(o.nat) ? o.nat as Array> : [], }) } function ipsecPeerBody(p: IpsecSnapshotPeer): Record { return compactBody({ name: p.name, address: p.address, "exchange-mode": p.exchangeMode, passive: rosYesNo(p.passive), certificate: p.certificate, "send-cert": "always", profile: p.profile, comment: p.comment, disabled: rosYesNo(p.disabled), }) } function ipsecIdentityBody(i: IpsecSnapshotIdentity): Record { 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, comment: i.comment, disabled: rosYesNo(i.disabled), }) } function ipsecModeConfigBody(m: IpsecSnapshotModeConfig): Record { return compactBody({ name: m.name, "address-pool": m.addressPool, address: m.address, "static-dns": m.staticDns, comment: m.comment, }) } function ipsecPoolBody(p: IpsecSnapshotPool): Record { return compactBody({ name: p.name, ranges: p.ranges, comment: p.comment }) } function ipsecNatBody(n: IpsecSnapshotNat): Record { return compactBody({ chain: n.chain, action: n.action, "src-address": n.srcAddress, comment: n.comment, }) } /** 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[] }, ): RosWriteOp[] { const desired = canonicalIpsecSnapshot(desiredInput) const ops: 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 managedPools = current.pools.filter((p) => isIpsecManagedComment(p.comment)) const managedNat = current.nat.filter((n) => isIpsecManagedComment(n.comment)) 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)) { ops.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}` }) } } for (const pool of managedPools) { if (!desired.pools.some((p) => p.name === pool.name)) { ops.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.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.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 } export function canonicalGreSnapshot(input: { tunnels?: Array> }): GreSnapshot { const tunnels = (input.tunnels ?? []) .map((t) => ({ name: str(t.name), localAddress: str(t.localAddress), remoteAddress: str(t.remoteAddress), localInnerIp: str(t.localInnerIp), 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 return canonicalGreSnapshot({ tunnels: Array.isArray(o.tunnels) ? o.tunnels as Array> : [], }) } export function greInterfaceBody(t: GreSnapshotTunnel): Record { 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) }, ] if (t.localInnerIp) { ops.push({ op: "put", path: "/ip/address", body: { address: t.localInnerIp, interface: 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) { ops.push({ op: "delete", path: `/ip/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 }) } const liveAddr = current.addrs.find((a) => a.interfaceName === want.name) if (want.localInnerIp) { if (!liveAddr) { ops.push({ op: "put", path: "/ip/address", body: { address: want.localInnerIp, interface: want.name }, }) } else if (liveAddr.address !== want.localInnerIp) { ops.push({ op: "delete", path: `/ip/address/${liveAddr.rosId}` }) ops.push({ op: "put", path: "/ip/address", body: { address: want.localInnerIp, interface: want.name }, }) } } else if (liveAddr) { ops.push({ op: "delete", path: `/ip/address/${liveAddr.rosId}` }) } } return ops } 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}/`))) }