Files
napalm-opnsense/napalm_opnsense/ha.py
T
Christian Manivong e334dc0058
CI / test (3.10) (push) Successful in 32s
CI / test (3.11) (push) Successful in 33s
CI / test (3.12) (push) Successful in 34s
CI / test (3.10) (pull_request) Successful in 38s
CI / test (3.11) (pull_request) Successful in 37s
CI / test (3.12) (pull_request) Successful in 38s
feat: report Kea's HA peers and the firewall's config sync
get_dhcp_ha reads kea/dhcpv4/get (the ha section) and
kea/dhcpv4/searchPeer: whether Kea's HA hook is on, this firewall's peer
name -- the % twin when left at its default -- and each peer's name,
role and HA URL. get_config_sync reads core/hasync/get: the address this
firewall pushes its configuration to and the ticked sync items, mapped to
the service names get_services reports; items that are no service stay
under `unmapped`. Both raise when the request fails.

Configuration, not state: Kea's control agent, which knows whether the
pair is in hot standby right now, listens on localhost only.

Needs napalm-device-types 4.11.0 (ConfigSyncMixin). NetOrk/netork#1125.
2026-10-16 15:18:45 +02:00

110 lines
4.0 KiB
Python

"""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],
}