From e334dc00586492f1c88568dc67287d509ad1b9c9 Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Fri, 16 Oct 2026 15:18:45 +0200 Subject: [PATCH] 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. --- napalm_opnsense/ha.py | 109 ++++++++ napalm_opnsense/opnsense.py | 30 ++- pyproject.toml | 2 +- tests/unit/fixtures/ha_pair.json | 418 +++++++++++++++++++++++++++++++ tests/unit/test_ha.py | 139 ++++++++++ 5 files changed, 694 insertions(+), 4 deletions(-) create mode 100644 napalm_opnsense/ha.py create mode 100644 tests/unit/fixtures/ha_pair.json create mode 100644 tests/unit/test_ha.py diff --git a/napalm_opnsense/ha.py b/napalm_opnsense/ha.py new file mode 100644 index 0000000..5860a39 --- /dev/null +++ b/napalm_opnsense/ha.py @@ -0,0 +1,109 @@ +"""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], + } diff --git a/napalm_opnsense/opnsense.py b/napalm_opnsense/opnsense.py index f5ad73f..5069dff 100644 --- a/napalm_opnsense/opnsense.py +++ b/napalm_opnsense/opnsense.py @@ -52,17 +52,23 @@ _REFRESH_INTERVAL = 3 import requests from requests.exceptions import RequestException -from napalm_device_types import FingerprintRule, FirewallDriver -from napalm_device_types.models import ListeningSocketsDict, VirtualIpsDict +from napalm_device_types import ConfigSyncMixin, FingerprintRule, FirewallDriver +from napalm_device_types.models import ( + ConfigSyncDict, + DhcpHaDict, + ListeningSocketsDict, + VirtualIpsDict, +) from napalm.base.exceptions import ConnectionException, ConnectionClosedException, MergeConfigException +from napalm_opnsense.ha import config_sync, dhcp_ha from napalm_opnsense.listening import parse_socket_statistics, wireguard_listen_ports from napalm_opnsense.ping_mixin import OPNsensePingMixin from napalm_opnsense.port_forwards import alias_index, port_forwards, wan_interfaces from napalm_opnsense.virtual_ips import carp_addresses -class OPNsenseDriver(OPNsensePingMixin, FirewallDriver): +class OPNsenseDriver(OPNsensePingMixin, ConfigSyncMixin, FirewallDriver): """NAPALM driver for OPNsense (read-only, REST API).""" VENDOR = "OPNsense" @@ -1841,6 +1847,24 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver): "carp": carp_addresses(self._get("/api/interfaces/overview/export")), } + def get_dhcp_ha(self) -> DhcpHaDict: + """Kea's HA configuration: on or off, this firewall's peer name, every peer. + + Configuration, not state (:mod:`napalm_opnsense.ha`). + + :raises: whatever either request raises -- an unread answer is not "off". + """ + return dhcp_ha( + self._get("/api/kea/dhcpv4/get"), self._get("/api/kea/dhcpv4/searchPeer") + ) + + def get_config_sync(self) -> ConfigSyncDict: + """Where this firewall pushes its configuration (HA sync), and for which services. + + :raises: whatever the request raises -- an unread answer is not "no sync". + """ + return config_sync(self._get("/api/core/hasync/get")) + def manage_service(self, name: str, action: str) -> dict[str, Any]: """Execute a lifecycle action on an OPNsense service. diff --git a/pyproject.toml b/pyproject.toml index 1f96197..d24fdaa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ ] dependencies = [ "napalm>=4.0.0", - "napalm_device_types>=4.10.1", + "napalm_device_types>=4.11.0", "requests>=2.28.0", ] diff --git a/tests/unit/fixtures/ha_pair.json b/tests/unit/fixtures/ha_pair.json new file mode 100644 index 0000000..747872a --- /dev/null +++ b/tests/unit/fixtures/ha_pair.json @@ -0,0 +1,418 @@ +{ + "gw-01": { + "kea_dhcpv4_get": { + "dhcpv4": { + "ha": { + "enabled": "1", + "this_server_name": "", + "max_unacked_clients": "2", + "%this_server_name": "gw" + } + } + }, + "kea_dhcpv4_searchPeer": { + "rows": [ + { + "uuid": "574e7b41-a5c7-4682-bc63-f4a99e07f262", + "name": "gw", + "role": "primary", + "%role": "Primary", + "url": "http://10.5.100.2:8001/" + }, + { + "uuid": "d6a4e812-4bc8-4713-bebb-b330ea65f587", + "name": "gw-02", + "role": "standby", + "%role": "Standby", + "url": "http://10.5.100.3:8001/" + } + ], + "rowCount": 2, + "total": 2, + "current": 1 + }, + "core_hasync_get": { + "hasync": { + "synchronizetoip": "10.5.100.3", + "syncitems": { + "aliases": { + "value": "Aliases", + "selected": 1 + }, + "authservers": { + "value": "Authentication Servers", + "selected": 1 + }, + "captiveportal": { + "value": "Captive Portal", + "selected": 1 + }, + "certs": { + "value": "Certificates and Authorities", + "selected": 1 + }, + "cron": { + "value": "Cron", + "selected": 0 + }, + "ddclient": { + "value": "ddclient", + "selected": 1 + }, + "dhcrelay": { + "value": "DHCRelay", + "selected": 0 + }, + "dnsforwarder": { + "value": "Dnsmasq DNS/DHCP", + "selected": 0 + }, + "categories": { + "value": "Firewall Categories", + "selected": 1 + }, + "ifgroups": { + "value": "Firewall Groups", + "selected": 1 + }, + "lvtemplate": { + "value": "Firewall Log Templates", + "selected": 1 + }, + "rules": { + "value": "Firewall Rules", + "selected": 1 + }, + "schedules": { + "value": "Firewall Schedules", + "selected": 1 + }, + "freeradius": { + "value": "Freeradius", + "selected": 0 + }, + "quagga": { + "value": "FRR", + "selected": 0 + }, + "hostwatch": { + "value": "Host discovery service", + "selected": 0 + }, + "suricata": { + "value": "Intrusion Detection", + "selected": 0 + }, + "ipsec": { + "value": "IPsec", + "selected": 0 + }, + "kea": { + "value": "Kea DHCP", + "selected": 1 + }, + "mdns-repeater": { + "value": "mDNS Repeater", + "selected": 0 + }, + "monit": { + "value": "Monit System Monitoring", + "selected": 0 + }, + "nat": { + "value": "NAT", + "selected": 1 + }, + "syslog": { + "value": "Netflow / Insight", + "selected": 0 + }, + "netsnmp": { + "value": "Netsnmp", + "selected": 0 + }, + "ntpd": { + "value": "Network Time", + "selected": 1 + }, + "opendns": { + "value": "OpenDNS", + "selected": 0 + }, + "ssh": { + "value": "OpenSSH", + "selected": 1 + }, + "openvpn": { + "value": "OpenVPN", + "selected": 0 + }, + "qemuguestagent": { + "value": "QEMU Guest Agent", + "selected": 1 + }, + "radvd": { + "value": "Router Advertisements", + "selected": 0 + }, + "shaper": { + "value": "Shaper", + "selected": 0 + }, + "squid": { + "value": "Squid Web Proxy", + "selected": 0 + }, + "staticroutes": { + "value": "Static Routes", + "selected": 1 + }, + "syslog-ng": { + "value": "System logging", + "selected": 1 + }, + "sysctl": { + "value": "System Tunables", + "selected": 1 + }, + "dnsresolver": { + "value": "Unbound DNS", + "selected": 1 + }, + "users": { + "value": "Users and Groups", + "selected": 1 + }, + "virtualip": { + "value": "Virtual IPs", + "selected": 1 + }, + "wol": { + "value": "Wake on LAN", + "selected": 0 + }, + "webgui": { + "value": "Web GUI", + "selected": 1 + }, + "wireguard": { + "value": "WireGuard", + "selected": 1 + }, + "zabbixagent": { + "value": "Zabbix monitoring agent", + "selected": 0 + } + } + } + } + }, + "gw-02": { + "kea_dhcpv4_get": { + "dhcpv4": { + "ha": { + "enabled": "1", + "this_server_name": "", + "max_unacked_clients": "2", + "%this_server_name": "gw-02" + } + } + }, + "kea_dhcpv4_searchPeer": { + "rows": [ + { + "uuid": "574e7b41-a5c7-4682-bc63-f4a99e07f262", + "name": "gw", + "role": "primary", + "%role": "Primary", + "url": "http://10.5.100.2:8001/" + }, + { + "uuid": "d6a4e812-4bc8-4713-bebb-b330ea65f587", + "name": "gw-02", + "role": "standby", + "%role": "Standby", + "url": "http://10.5.100.3:8001/" + } + ], + "rowCount": 2, + "total": 2, + "current": 1 + }, + "core_hasync_get": { + "hasync": { + "synchronizetoip": "", + "syncitems": { + "aliases": { + "value": "Aliases", + "selected": 0 + }, + "authservers": { + "value": "Authentication Servers", + "selected": 0 + }, + "captiveportal": { + "value": "Captive Portal", + "selected": 0 + }, + "certs": { + "value": "Certificates and Authorities", + "selected": 0 + }, + "cron": { + "value": "Cron", + "selected": 0 + }, + "ddclient": { + "value": "ddclient", + "selected": 0 + }, + "dhcrelay": { + "value": "DHCRelay", + "selected": 0 + }, + "dnsforwarder": { + "value": "Dnsmasq DNS/DHCP", + "selected": 0 + }, + "categories": { + "value": "Firewall Categories", + "selected": 0 + }, + "ifgroups": { + "value": "Firewall Groups", + "selected": 0 + }, + "lvtemplate": { + "value": "Firewall Log Templates", + "selected": 0 + }, + "rules": { + "value": "Firewall Rules", + "selected": 0 + }, + "schedules": { + "value": "Firewall Schedules", + "selected": 0 + }, + "freeradius": { + "value": "Freeradius", + "selected": 0 + }, + "quagga": { + "value": "FRR", + "selected": 0 + }, + "hostwatch": { + "value": "Host discovery service", + "selected": 0 + }, + "suricata": { + "value": "Intrusion Detection", + "selected": 0 + }, + "ipsec": { + "value": "IPsec", + "selected": 0 + }, + "kea": { + "value": "Kea DHCP", + "selected": 0 + }, + "mdns-repeater": { + "value": "mDNS Repeater", + "selected": 0 + }, + "monit": { + "value": "Monit System Monitoring", + "selected": 0 + }, + "nat": { + "value": "NAT", + "selected": 0 + }, + "syslog": { + "value": "Netflow / Insight", + "selected": 0 + }, + "netsnmp": { + "value": "Netsnmp", + "selected": 0 + }, + "ntpd": { + "value": "Network Time", + "selected": 0 + }, + "opendns": { + "value": "OpenDNS", + "selected": 0 + }, + "ssh": { + "value": "OpenSSH", + "selected": 0 + }, + "openvpn": { + "value": "OpenVPN", + "selected": 0 + }, + "qemuguestagent": { + "value": "QEMU Guest Agent", + "selected": 0 + }, + "radvd": { + "value": "Router Advertisements", + "selected": 0 + }, + "shaper": { + "value": "Shaper", + "selected": 0 + }, + "squid": { + "value": "Squid Web Proxy", + "selected": 0 + }, + "staticroutes": { + "value": "Static Routes", + "selected": 0 + }, + "syslog-ng": { + "value": "System logging", + "selected": 0 + }, + "sysctl": { + "value": "System Tunables", + "selected": 0 + }, + "dnsresolver": { + "value": "Unbound DNS", + "selected": 0 + }, + "users": { + "value": "Users and Groups", + "selected": 0 + }, + "virtualip": { + "value": "Virtual IPs", + "selected": 0 + }, + "wol": { + "value": "Wake on LAN", + "selected": 0 + }, + "webgui": { + "value": "Web GUI", + "selected": 0 + }, + "wireguard": { + "value": "WireGuard", + "selected": 0 + }, + "zabbixagent": { + "value": "Zabbix monitoring agent", + "selected": 0 + } + } + } + } + } +} \ No newline at end of file diff --git a/tests/unit/test_ha.py b/tests/unit/test_ha.py new file mode 100644 index 0000000..b5044a9 --- /dev/null +++ b/tests/unit/test_ha.py @@ -0,0 +1,139 @@ +"""Kea's HA peers and the firewall's configuration sync, as facts (netOrk #1125). + +The fixture is what gw-01 and gw-02 answered on 2026-10-16: Kea in hot standby +(gw primary, gw-02 standby) and gw-01 pushing its configuration, Kea and +Unbound included, to gw-02. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from napalm_opnsense.ha import config_sync, dhcp_ha +from napalm_opnsense.opnsense import OPNsenseDriver + +PAIR = json.loads((Path(__file__).parent / "fixtures" / "ha_pair.json").read_text()) + + +@pytest.fixture +def driver() -> OPNsenseDriver: + d = OPNsenseDriver("gw-01", "user", "secret") + d._session = MagicMock() + return d + + +def _answers(node: str): + def _get(path: str): + return { + "/api/kea/dhcpv4/get": PAIR[node]["kea_dhcpv4_get"], + "/api/kea/dhcpv4/searchPeer": PAIR[node]["kea_dhcpv4_searchPeer"], + "/api/core/hasync/get": PAIR[node]["core_hasync_get"], + }[path] + + return _get + + +class TestDhcpHa: + def test_reads_the_peers_with_their_roles(self, driver): + driver._get = _answers("gw-01") + + ha = driver.get_dhcp_ha() + + assert ha["service"] == "kea-dhcp" + assert ha["enabled"] is True + assert ha["peers"] == [ + {"name": "gw", "role": "primary", "url": "http://10.5.100.2:8001/"}, + {"name": "gw-02", "role": "standby", "url": "http://10.5.100.3:8001/"}, + ] + + def test_names_this_server_from_the_effective_value(self, driver): + """`this_server_name` is empty when left at its default -- the + hostname; the effective name is in its `%` twin.""" + driver._get = _answers("gw-02") + + assert driver.get_dhcp_ha()["this_server"] == "gw-02" + + def test_an_explicit_server_name_wins(self): + ha = dhcp_ha( + {"dhcpv4": {"ha": {"enabled": "1", "this_server_name": "left", "%this_server_name": "x"}}}, + {"rows": []}, + ) + assert ha["this_server"] == "left" + + def test_ha_switched_off(self): + ha = dhcp_ha({"dhcpv4": {"ha": {"enabled": "0", "this_server_name": ""}}}, {"rows": []}) + + assert ha["enabled"] is False + assert ha["peers"] == [] + assert "this_server" not in ha + + def test_a_peer_without_url_has_no_url_key(self): + ha = dhcp_ha( + {"dhcpv4": {"ha": {"enabled": "1"}}}, + {"rows": [{"name": "a", "role": "primary", "url": ""}]}, + ) + assert ha["peers"] == [{"name": "a", "role": "primary"}] + + def test_a_failed_read_raises_and_never_says_off(self, driver): + driver._get = MagicMock(side_effect=RuntimeError("timeout")) + + with pytest.raises(RuntimeError): + driver.get_dhcp_ha() + + def test_an_answer_without_the_ha_section_raises(self): + with pytest.raises(ValueError): + dhcp_ha({"dhcpv4": {}}, {"rows": []}) + + +class TestConfigSync: + def test_the_pushing_side_names_its_peer_and_services(self, driver): + driver._get = _answers("gw-01") + + sync = driver.get_config_sync() + + assert sync["peer"] == "10.5.100.3" + assert {"kea-dhcp", "unbound", "ntpd", "openssh", "wireguard"} <= set(sync["services"]) + assert sync["services"] == sorted(sync["services"]) + + def test_items_without_a_service_stay_visible(self, driver): + driver._get = _answers("gw-01") + + unmapped = driver.get_config_sync()["unmapped"] + + assert {"aliases", "rules", "virtualip"} <= set(unmapped) + assert "kea" not in unmapped + + def test_only_selected_items_count(self, driver): + driver._get = _answers("gw-01") + + sync = driver.get_config_sync() + + assert "dnsmasq" not in sync["services"] # dnsforwarder: not selected + assert "cron" not in sync["unmapped"] and "cron" not in sync["services"] + + def test_the_receiving_side_pushes_to_no_one(self, driver): + driver._get = _answers("gw-02") + + sync = driver.get_config_sync() + + assert sync == {"peer": None, "services": [], "unmapped": []} + + def test_without_a_peer_selected_items_are_not_pushed(self): + """Ticked items without a target address push nothing.""" + answer = {"hasync": {"synchronizetoip": "", "syncitems": {"kea": {"value": "Kea", "selected": 1}}}} + + assert config_sync(answer) == {"peer": None, "services": [], "unmapped": []} + + def test_a_failed_read_raises(self, driver): + driver._get = MagicMock(side_effect=RuntimeError("timeout")) + + with pytest.raises(RuntimeError): + driver.get_config_sync() + + def test_an_answer_without_the_section_raises(self): + with pytest.raises(ValueError): + config_sync({"something": {}}) -- 2.54.0