"""Kea's HA peers and the firewall's configuration sync, as facts (netOrk #1125). Two answers of the OPNsense API, read as they are: * ``/api/kea/dhcpv4/get`` (its ``ha`` section) and ``/api/kea/dhcpv4/searchPeer``: whether Kea's HA hook is on, which peer this firewall is, and each peer's name, role and HA listener URL. Configuration, not state -- Kea's control agent, which knows whether the pair is in hot standby right now, listens on localhost only. * ``/api/core/hasync/get``: the address this firewall pushes its configuration to (``synchronizetoip``) and the ticked sync items. An item is named by OPNsense's sync section, not by the service it covers; :data:`SYNC_ITEM_SERVICES` maps the ones that are a service to the name ``get_services`` reports. """ from __future__ import annotations from typing import Any from napalm_device_types.models import ConfigSyncDict, DhcpHaDict, DhcpHaPeerDict #: Kea's DHCPv4 server as ``get_services`` names it. KEA_SERVICE = "kea-dhcp" #: Sync item -> the service it carries the configuration of, by the name #: ``get_services`` reports. Items that are configuration only (aliases, rules, #: NAT, users ...) have no service and are reported as unmapped. SYNC_ITEM_SERVICES: dict[str, str] = { "captiveportal": "captiveportal", "cron": "cron", "ddclient": "ddclient", "dnsforwarder": "dnsmasq", "dnsresolver": "unbound", "hostwatch": "hostwatch", "kea": KEA_SERVICE, "mdns-repeater": "mdns-repeater", "netsnmp": "snmpd", "ntpd": "ntpd", "qemuguestagent": "qemu-ga", "quagga": "frr", "radvd": "radvd", "squid": "squid", "ssh": "openssh", "sysctl": "sysctl", "syslog-ng": "syslog-ng", "webgui": "webgui", "wireguard": "wireguard", "zabbixagent": "zabbix_agentd", } def _section(answer: Any, *path: str) -> dict: node = answer for key in path: if not isinstance(node, dict) or not isinstance(node.get(key), dict): raise ValueError(f"unexpected answer: no {'.'.join(path)}") node = node[key] return node def dhcp_ha(settings: Any, peers: Any) -> DhcpHaDict: """Kea's HA configuration from the two answers. ``this_server_name`` is empty while left at its default, the hostname; the effective name is then in its ``%`` twin. :raises ValueError: when *settings* has no ``dhcpv4.ha`` section. """ ha = _section(settings, "dhcpv4", "ha") result: DhcpHaDict = { "service": KEA_SERVICE, "enabled": str(ha.get("enabled", "0")) == "1", "peers": [], } this_server = str(ha.get("this_server_name") or ha.get("%this_server_name") or "").strip() if this_server: result["this_server"] = this_server rows = peers.get("rows") if isinstance(peers, dict) else None for row in rows or []: if not isinstance(row, dict) or not row.get("name"): continue peer: DhcpHaPeerDict = {"name": str(row["name"]), "role": str(row.get("role") or "")} if row.get("url"): peer["url"] = str(row["url"]) result["peers"].append(peer) return result def config_sync(answer: Any) -> ConfigSyncDict: """Where this firewall pushes its configuration, and which services' it pushes. Ticked items without a target address push nothing, so they count for nothing. :raises ValueError: when *answer* has no ``hasync`` section. """ section = _section(answer, "hasync") peer = str(section.get("synchronizetoip") or "").strip() or None if peer is None: return {"peer": None, "services": [], "unmapped": []} items = section.get("syncitems") ticked = sorted( name for name, item in (items.items() if isinstance(items, dict) else []) if isinstance(item, dict) and str(item.get("selected", "0")) == "1" ) return { "peer": peer, "services": sorted({SYNC_ITEM_SERVICES[i] for i in ticked if i in SYNC_ITEM_SERVICES}), "unmapped": [i for i in ticked if i not in SYNC_ITEM_SERVICES], }