1 Commits
Author SHA1 Message Date
Christian Manivong b5323e2425 fix: name and version from system_information; a CARP address on its MASTER's interface
CI / test (3.10) (push) Successful in 38s
CI / test (3.11) (push) Successful in 30s
CI / test (3.12) (push) Successful in 39s
CI / test (3.10) (pull_request) Successful in 29s
CI / test (3.11) (pull_request) Successful in 28s
CI / test (3.12) (pull_request) Successful in 31s
get_facts read hostname and version from /api/core/system/status, which on
OPNsense 26.7 carries neither: every firewall read as nameless and "unknown",
and netOrk kept naming one by its address. /api/diagnostics/system/system_information
has both (name is the FQDN, versions[0] "OPNsense 26.7.7-amd64"). The status
answer still counts where the new one gives nothing.

get_interfaces_ip listed each interface's primary address only, so a CARP address
never showed as the holder's. It now lists an address bound to a VHID on the node
that is MASTER for it, and leaves it off a BACKUP -- as keepalived puts the address
on the master alone. netOrk's VIP badge sits on that interface (netOrk #1121).
2026-10-16 15:23:08 +02:00
5 changed files with 4 additions and 694 deletions

No files matched your search

-109
View File
@@ -1,109 +0,0 @@
"""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],
}
+3 -27
View File
@@ -53,16 +53,10 @@ _REFRESH_INTERVAL = 3
import requests
from requests.exceptions import RequestException
from napalm_device_types import ConfigSyncMixin, FingerprintRule, FirewallDriver
from napalm_device_types.models import (
ConfigSyncDict,
DhcpHaDict,
ListeningSocketsDict,
VirtualIpsDict,
)
from napalm_device_types import FingerprintRule, FirewallDriver
from napalm_device_types.models import 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
@@ -86,7 +80,7 @@ def _carp_master(entry: dict[str, Any]) -> bool:
return str(entry.get("status") or "").strip().upper() == "MASTER"
class OPNsenseDriver(OPNsensePingMixin, ConfigSyncMixin, FirewallDriver):
class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
"""NAPALM driver for OPNsense (read-only, REST API)."""
VENDOR = "OPNsense"
@@ -1894,24 +1888,6 @@ class OPNsenseDriver(OPNsensePingMixin, ConfigSyncMixin, 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.
+1 -1
View File
@@ -24,7 +24,7 @@ classifiers = [
]
dependencies = [
"napalm>=4.0.0",
"napalm_device_types>=4.11.0",
"napalm_device_types>=4.10.1",
"requests>=2.28.0",
]
-418
View File
@@ -1,418 +0,0 @@
{
"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
}
}
}
}
}
}
-139
View File
@@ -1,139 +0,0 @@
"""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": {}})