Docker images / prepare-release (push) Successful in 15s
Docker images / backend-test (push) Successful in 2m32s
Docker images / frontend-image (push) Successful in 4m19s
Docker images / updater-image (push) Successful in 50s
Docker images / backend-image (push) Successful in 2m40s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 8s
- мастер инициализации сервера: CA и серверный сертификаты, peer/profile/proposal, пул, mode-config, policy-template, managed NAT masquerade - клиенты по сертификату (RSA) и PSK: статический IP или из пула, онлайн-статус по active-peers - скачивание .p12 и strongSwan .sswan с инструкцией, перекачка с новой passphrase - история изменений (config_revisions, секция ipsec) и restore только managed-объектов - привязка IPsec-клиентов к пользователям приложения по Common Name - страница /ipsec с KPI и вкладками Клиенты/Сервер/CLI, сайдбар, command palette
961 lines
29 KiB
TypeScript
961 lines
29 KiB
TypeScript
/** Канонические снапшоты и планы restore для firewall / WireGuard / GRE / IPsec. */
|
|
import { isIpsecManagedComment } from "./ipsec-config.js"
|
|
|
|
export type FirewallFamily = "ip" | "ip6"
|
|
export type FirewallTable = "filter" | "nat" | "mangle" | "raw"
|
|
|
|
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
|
|
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<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),
|
|
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)) {
|
|
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>): 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),
|
|
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) }))
|
|
.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),
|
|
certificate: p.certificate,
|
|
"send-cert": "always",
|
|
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,
|
|
comment: i.comment,
|
|
disabled: rosYesNo(i.disabled),
|
|
})
|
|
}
|
|
|
|
function ipsecModeConfigBody(m: IpsecSnapshotModeConfig): Record<string, string> {
|
|
return compactBody({
|
|
name: m.name,
|
|
"address-pool": m.addressPool,
|
|
address: m.address,
|
|
"static-dns": m.staticDns,
|
|
comment: m.comment,
|
|
})
|
|
}
|
|
|
|
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> {
|
|
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<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),
|
|
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> {
|
|
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: t.comment,
|
|
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}/`)))
|
|
}
|