# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """NAPALM driver for OPNsense firewalls (REST API). OPNsense exposes a JSON REST API at ``/api/``. Authentication uses an API key/secret pair generated in the OPNsense GUI (System → Access → Users → edit user → API keys). Pass them via *optional_args*: optional_args={ "api_key": "", "api_secret": "", "verify": False, # disable TLS verification for self-signed certs } If *api_key* / *api_secret* are omitted the driver falls back to the positional *username* / *password* arguments. Config management (load_merge_candidate / commit_config / rollback) operates on **static routes** via ``/api/routes/routes/``. Other parts of the OPNsense configuration (firewall rules, interface settings, …) are not exposed through a generic config-push endpoint and must be managed per-module through their respective API controllers. """ from __future__ import annotations import difflib import ipaddress import json import logging import re import socket import time from ipaddress import ip_address, ip_network from typing import Any logger = logging.getLogger(__name__) #: How long refresh_available_updates waits for the firewall's update check. _REFRESH_POLLS = 20 _REFRESH_INTERVAL = 3 import requests from napalm.base.exceptions import ( ConnectionClosedException, ConnectionException, MergeConfigException, ) from napalm_device_types import ConfigSyncMixin, FingerprintRule, FirewallDriver, SnmpAgentMixin from napalm_device_types.dhcp import normalize_mac from napalm_device_types.models import ( ConfigSyncDict, DhcpHaDict, FixStepOutcomeDict, ListeningSocketsDict, SnmpAgentSettingsDict, VirtualIpsDict, ) from requests.exceptions import RequestException from napalm_opnsense.ha import config_sync, dhcp_ha from napalm_opnsense.listening import parse_socket_statistics, wireguard_listen_ports from napalm_opnsense.ospf_mixin import OPNsenseOspfMixin from napalm_opnsense.ping_mixin import OPNsensePingMixin from napalm_opnsense.port_forwards import alias_index, port_forwards, wan_interfaces from napalm_opnsense.resolver_mixin import OPNsenseResolverMixin from napalm_opnsense.virtual_ips import carp_addresses _ARCHITECTURE_SUFFIX = re.compile(r"-(amd64|aarch64|arm64|i386|armv7)$") def _opnsense_version(versions: Any) -> str: """``["OPNsense 26.7.7-amd64", "FreeBSD ..."]`` -> ``"26.7.7"``; "" when absent.""" for line in versions if isinstance(versions, list) else []: product, _, version = str(line).partition(" ") if product == "OPNsense" and version: return _ARCHITECTURE_SUFFIX.sub("", version.strip()) return "" def _carp_master(entry: dict[str, Any]) -> bool: """Whether this node is MASTER for an interface address bound to a CARP VHID.""" return str(entry.get("status") or "").strip().upper() == "MASTER" class OPNsenseDriver( OPNsensePingMixin, ConfigSyncMixin, OPNsenseResolverMixin, OPNsenseOspfMixin, SnmpAgentMixin, FirewallDriver, ): """NAPALM driver for OPNsense (read-only, REST API).""" VENDOR = "OPNsense" DRIVER_NAME = "opnsense" # Everything runs over the REST API; there is no SSH session to open. USES_SSH = False SNMP_FINGERPRINT = [ FingerprintRule("opnsense", weight=8.0, mandatory=True), ] SSH_FINGERPRINT = [ FingerprintRule("freebsd", weight=7.0), ] HTTP_FINGERPRINT = [ FingerprintRule("opnsense", weight=8.0, mandatory=True), ] def __init__( self, hostname: str, username: str, password: str, timeout: int = 60, optional_args: dict[str, Any] | None = None, ) -> None: self.hostname = hostname self.username = username self.password = password self.timeout = timeout self.optional_args = optional_args or {} # NAPALM standard attributes self.force_no_enable = True self.use_canonical_interface = False # OPNsense REST API settings self.base_url = self.optional_args.get("base_url") or f"https://{hostname}" # Accept "verify", "verify_ssl", or "ssl_verify" (NetOrk passes "verify_ssl") _v = self.optional_args.get( "verify", self.optional_args.get("verify_ssl", self.optional_args.get("ssl_verify", True)), ) self.verify = bool(_v) self.api_key = self.optional_args.get("api_key") or username self.api_secret = self.optional_args.get("api_secret") or password self.session: requests.Session | None = None # Config-management state self._candidate_config: list[dict[str, Any]] | None = None # Backup ID of the config snapshot taken just before commit_config(). # Used by rollback() to restore the exact pre-commit state. self._pre_commit_backup_id: str | None = None # ------------------------------------------------------------------ # Connection management # ------------------------------------------------------------------ def open(self) -> None: """Open an HTTPS session to the OPNsense API and validate credentials.""" try: s = requests.Session() s.verify = self.verify s.headers.update({"Accept": "application/json"}) s.auth = (self.api_key, self.api_secret) self.session = s # Lightweight connectivity and auth check self._get("/api/core/system/status") except RequestException as exc: self.session = None raise ConnectionException( f"Cannot connect to OPNsense API at {self.base_url}: {exc}" ) from exc def close(self) -> None: """Close the HTTPS session.""" if self.session is not None: self.session.close() self.session = None def is_alive(self) -> dict[str, bool]: """Return whether the session is usable. Performs a lightweight socket-level check without sending a full HTTP request to avoid polluting API logs. """ if self.session is None: return {"is_alive": False} try: host = self.hostname port = 443 with socket.create_connection((host, port), timeout=5): pass return {"is_alive": True} except OSError: return {"is_alive": False} # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ def _get(self, path: str) -> dict[str, Any]: """Perform a GET request against the OPNsense REST API. :raises ConnectionClosedException: if called before :meth:`open`. :raises RequestException: on HTTP-level errors. """ if self.session is None: raise ConnectionClosedException("Not connected – call open() first.") url = self.base_url.rstrip("/") + path response = self.session.get(url, timeout=self.timeout) response.raise_for_status() return response.json() def _get_text(self, path: str) -> str: """Perform a GET request and return the body as text. For the few endpoints that answer with a file rather than JSON, such as the configuration backup. :raises ConnectionClosedException: if called before :meth:`open`. :raises RequestException: on HTTP-level errors. """ if self.session is None: raise ConnectionClosedException("Not connected – call open() first.") url = self.base_url.rstrip("/") + path response = self.session.get(url, timeout=self.timeout) response.raise_for_status() return response.text def _post(self, path: str, data: dict[str, Any] | None = None) -> dict[str, Any]: """Perform a POST request against the OPNsense REST API. :param path: API path, e.g. ``/api/routes/routes/addroute``. :param data: JSON-serialisable payload (sent as ``application/json``). :raises ConnectionClosedException: if called before :meth:`open`. :raises RequestException: on HTTP-level errors. """ if self.session is None: raise ConnectionClosedException("Not connected – call open() first.") url = self.base_url.rstrip("/") + path response = self.session.post(url, json=data or {}, timeout=self.timeout) response.raise_for_status() return response.json() # ------------------------------------------------------------------ # NAPALM getters # ------------------------------------------------------------------ def get_facts(self) -> dict[str, Any]: """Return a dictionary of general device facts. Name and version come from ``GET /api/diagnostics/system/system_information`` (``name`` is the FQDN, ``versions[0]`` reads ``"OPNsense 26.7.7-amd64"``): ``/api/core/system/status`` carries neither on OPNsense 26.7. The status answer is still read, and still counts where the other one gives nothing -- an older OPNsense, or an API key without the diagnostics privilege. Returned keys (NAPALM standard): ``vendor``, ``model``, ``hostname``, ``fqdn``, ``os_version``, ``serial_number``, ``uptime``, ``interface_list``. """ status = self._get("/api/core/system/status") try: information = self._get("/api/diagnostics/system/system_information") except Exception as exc: logger.debug("system_information not read: %s", exc) information = {} if not isinstance(information, dict): information = {} fqdn = str(information.get("name") or status.get("hostname") or status.get("name") or "") hostname = fqdn.split(".", 1)[0] version = ( _opnsense_version(information.get("versions")) or status.get("version") or status.get("product_version") or "unknown" ) try: interface_list = list(self.get_interfaces().keys()) except Exception as exc: logger.debug("Failed to fetch interface list: %s", exc) interface_list = [] return { "vendor": self.VENDOR, "model": status.get("model") or self.VENDOR, "hostname": hostname, "fqdn": fqdn, "os_version": version, "serial_number": status.get("serial") or "", "uptime": status.get("uptime", -1), "interface_list": interface_list, } def get_interfaces(self) -> dict[str, dict[str, Any]]: """Return interface details keyed by interface name. Calls ``GET /api/interfaces/overview/export``. Each entry contains NAPALM standard keys: ``is_up``, ``is_enabled``, ``description``, ``last_flapped``, ``mac_address``, ``speed``, ``mtu``. Plus one non-standard key, ``identifier``: OPNsense's *assigned* interface name ("lan", "opt1"), as opposed to the physical device the dict is keyed by ("em0"). Empty for unassigned interfaces. This is the name :meth:`send_wake_on_lan` requires — see the note there. """ data = self._get("/api/interfaces/overview/export") interfaces: dict[str, dict[str, Any]] = {} # API returns a bare list in newer OPNsense versions; # older/wrapped format uses {"interfaces": [...]} items: list = data if isinstance(data, list) else data.get("interfaces", []) for iface in items: name = iface.get("device") or iface.get("name", "") if not name: continue interfaces[name] = { "is_up": iface.get("status", "") == "up" if "status" in iface else bool(iface.get("up", False)), "is_enabled": bool(iface.get("enabled", True)), "description": iface.get("description") or iface.get("descr") or "", "last_flapped": -1.0, "mac_address": (iface.get("macaddr") or iface.get("mac") or "").lower(), "speed": float(iface["speed_mbps"]) if iface.get("speed_mbps") else 0.0, "mtu": int(iface["mtu"]) if iface.get("mtu") else 0, "identifier": iface.get("identifier") or "", } return interfaces def get_interfaces_ip(self) -> dict[str, dict[str, Any]]: """Return IP addresses grouped by interface name. Uses ``GET /api/interfaces/overview/export`` (same source as get_interfaces). An address bound to a CARP VHID is listed on every node; it counts as this node's only while the node is its MASTER, as keepalived puts the address on the master alone (netOrk #1121). Structure:: { "em0": { "ipv4": {"192.0.2.10": {"prefix_length": 24}}, "ipv6": {"2001:db8::1": {"prefix_length": 64}}, } } """ import ipaddress data = self._get("/api/interfaces/overview/export") items: list = data if isinstance(data, list) else data.get("interfaces", []) result: dict[str, dict[str, Any]] = {} def _add(ifname: str, cidr: str, family: str) -> None: try: iface_obj = ipaddress.ip_interface(cidr) ip = str(iface_obj.ip) prefix = iface_obj.network.prefixlen result.setdefault(ifname, {"ipv4": {}, "ipv6": {}}) result[ifname][family][ip] = {"prefix_length": prefix} except ValueError: pass for iface in items: name: str = iface.get("device") or iface.get("name", "") if not name: continue addr4: str = iface.get("addr4", "") addr6: str = iface.get("addr6", "") if addr4: _add(name, addr4, "ipv4") if addr6: _add(name, addr6, "ipv6") for family in ("ipv4", "ipv6"): for entry in iface.get(family) or []: if isinstance(entry, dict) and entry.get("vhid") and _carp_master(entry): _add(name, entry.get("ipaddr") or "", family) if not addr4: for entry in iface.get("ipv4") or []: if entry.get("vhid") and not _carp_master(entry): continue ip_field = entry.get("ipaddr") or entry.get("ip", "") subnet = entry.get("subnetbits") or entry.get("prefix_length") cidr = f"{ip_field}/{subnet}" if subnet and "/" not in ip_field else ip_field if cidr: _add(name, cidr, "ipv4") if not addr6: for entry in iface.get("ipv6") or []: if entry.get("vhid") and not _carp_master(entry): continue ip_field = entry.get("ipaddr") or entry.get("ip", "") prefix = entry.get("prefixlen") or entry.get("prefix_length") cidr = f"{ip_field}/{prefix}" if prefix and "/" not in ip_field else ip_field if cidr: _add(name, cidr, "ipv6") return result def get_networks(self) -> list[dict[str, Any]]: """Return the IP networks this firewall is authoritative for. Derived from the interface overview (``GET /api/interfaces/overview/export``). Loopback and link-local addresses are excluded. Each entry:: { "network": "192.168.1.0/24", "interface": "em0", "gateway": "192.168.1.1", "family": "ipv4", "prefix_length": 24, "vlan_id": 20, } ``vlan_id`` is ``None`` for interfaces that are not 802.1Q VLAN sub-interfaces (e.g. the untagged management/LAN interface). """ import ipaddress data = self._get("/api/interfaces/overview/export") items: list = data if isinstance(data, list) else data.get("interfaces", []) networks: list[dict[str, Any]] = [] def _add(ifname: str, cidr: str, family: str, vlan_id: int | None) -> None: """Parse a CIDR string (e.g. '10.0.0.1/24') and append to networks.""" try: iface_obj = ipaddress.ip_interface(cidr) net = iface_obj.network if net.is_loopback or net.is_link_local: return networks.append( { "network": str(net), "interface": ifname, "gateway": str(iface_obj.ip), "family": family, "prefix_length": net.prefixlen, "vlan_id": vlan_id, } ) except ValueError: pass for iface in items: name: str = iface.get("device") or iface.get("name", "") if not name: continue vlan_id: int | None = None raw_tag = iface.get("vlan_tag") if raw_tag: try: vlan_id = int(raw_tag) except (TypeError, ValueError): vlan_id = None # Primary format: flat CIDR strings in addr4/addr6 addr4: str = iface.get("addr4", "") addr6: str = iface.get("addr6", "") if addr4: _add(name, addr4, "ipv4", vlan_id) if addr6: _add(name, addr6, "ipv6", vlan_id) # Fallback: ipv4/ipv6 arrays where ipaddr may include prefix if not addr4: for entry in iface.get("ipv4") or []: ip_field = entry.get("ipaddr") or entry.get("ip", "") subnet = entry.get("subnetbits") or entry.get("prefix_length") cidr = f"{ip_field}/{subnet}" if subnet and "/" not in ip_field else ip_field if cidr: _add(name, cidr, "ipv4", vlan_id) if not addr6: for entry in iface.get("ipv6") or []: ip_field = entry.get("ipaddr") or entry.get("ip", "") prefix = entry.get("prefixlen") or entry.get("prefix_length") cidr = f"{ip_field}/{prefix}" if prefix and "/" not in ip_field else ip_field if cidr: _add(name, cidr, "ipv6", vlan_id) return networks def get_arp_table(self, vrf: str = "") -> list[dict[str, Any]]: """Return the ARP table. Calls ``GET /api/diagnostics/interface/get_arp``. Each entry contains: ``interface``, ``mac``, ``ip``, ``age``. """ data = self._get("/api/diagnostics/interface/get_arp") arp_table: list[dict[str, Any]] = [] for entry in data if isinstance(data, list) else data.get("arp", []): arp_table.append( { "interface": entry.get("intf") or entry.get("interface", ""), "mac": (entry.get("mac") or "").lower(), "ip": entry.get("ip") or entry.get("address", ""), "age": float(entry.get("expires", 0)), } ) return arp_table def get_interfaces_counters(self) -> dict[str, dict[str, Any]]: """Return per-interface packet and byte counters. Calls ``GET /api/diagnostics/interface/get_interface_statistics``. Each entry contains NAPALM standard keys: ``tx_errors``, ``rx_errors``, ``tx_discards``, ``rx_discards``, ``tx_octets``, ``rx_octets``, ``tx_unicast_packets``, ``rx_unicast_packets``, ``tx_multicast_packets``, ``rx_multicast_packets``, ``tx_broadcast_packets``, ``rx_broadcast_packets``. """ data = self._get("/api/diagnostics/interface/get_interface_statistics") counters: dict[str, dict[str, Any]] = {} for iface, stats in data.get("statistics", {}).items(): counters[iface] = { "tx_errors": int(stats.get("output-errors", 0)), "rx_errors": int(stats.get("input-errors", 0)), "tx_discards": int(stats.get("output-drops", 0)), "rx_discards": int(stats.get("input-drops", 0)), "tx_octets": int(stats.get("output-bytes", 0)), "rx_octets": int(stats.get("input-bytes", 0)), "tx_unicast_packets": int(stats.get("output-packets", 0)), "rx_unicast_packets": int(stats.get("input-packets", 0)), "tx_multicast_packets": int(stats.get("output-multicasts", 0)), "rx_multicast_packets": int(stats.get("input-multicasts", 0)), "tx_broadcast_packets": int(stats.get("output-broadcasts", 0)), "rx_broadcast_packets": int(stats.get("input-broadcasts", 0)), } return counters def get_environment(self) -> dict[str, Any]: """Return device environment data (CPU, memory, temperature). Calls: - ``GET /api/diagnostics/system/system_resources`` for CPU and memory. - ``GET /api/diagnostics/system/system_temperature`` for temperature sensors. ``fan`` and ``power`` fields are not exposed by OPNsense and are returned with assumed-healthy placeholder values. """ resources = self._get("/api/diagnostics/system/system_resources") cpu_pct = float(resources.get("cpu", {}).get("used", 0)) mem_total = int(resources.get("memory", {}).get("total", 0) or 0) mem_used = int(resources.get("memory", {}).get("used", 0) or 0) try: temp_data = self._get("/api/diagnostics/system/system_temperature") except Exception as exc: logger.debug("Failed to fetch system temperature: %s", exc) temp_data = {} temperature: dict[str, Any] = {} for sensor in temp_data.get("data", []): name = sensor.get("device") or sensor.get("name", "") temp_val = float(sensor.get("temperature", 0)) temperature[name] = { "temperature": temp_val, "is_alert": temp_val > 80.0, "is_critical": temp_val > 95.0, } return { "fans": {}, "temperature": temperature, "power": {}, "cpu": {0: {"%usage": cpu_pct}}, "memory": { "available_ram": mem_total - mem_used, "used_ram": mem_used, }, } def get_route_to( self, destination: str = "", protocol: str = "", longer: bool = False, ) -> dict[str, list[dict[str, Any]]]: """Return routing table entries. Calls ``GET /api/diagnostics/interface/get_routes``. :param destination: Filter by exact prefix (e.g. ``"192.0.2.0/24"``). :param protocol: Filter by protocol name (``"static"``, ``"connected"``). :param longer: Ignored (OPNsense does not support longer-prefixes filter). Returns a NAPALM-standard route dict keyed by network prefix. """ data = self._get("/api/diagnostics/interface/get_routes") routes: dict[str, list[dict[str, Any]]] = {} # Optionally enrich with OSPF routes from FRR/Quagga ospf_networks: set = set() try: ospf_data = self._get("/api/quagga/ospf/routes") for prefix in ( ospf_data if isinstance(ospf_data, list) else ospf_data.get("routes", {}).keys() ): ospf_networks.add(str(prefix)) except Exception as exc: logger.debug("Failed to fetch OSPF routes: %s", exc) route_list = data if isinstance(data, list) else data.get("route", []) for route in route_list: network = route.get("network") or route.get("destination", "") if not network: continue if destination and network != destination: continue flags = route.get("flags", "").upper() gateway = route.get("gateway") or route.get("nexthop", "") iface = route.get("netif") or route.get("interface", "") # Clean up BSD link-layer gateway references clean_gateway = ( "" if (not gateway or gateway.startswith("link#") or gateway == "0.0.0.0") else gateway ) # Determine address family from network address family = "ipv6" if (":" in network or (gateway and ":" in gateway)) else "ipv4" # Determine routing protocol from BSD flags: # S = Static, dynamic routes have no S flag if network in ospf_networks: proto = "ospf" elif "S" in flags: proto = "static" elif not clean_gateway: proto = "connected" else: proto = "kernel" if protocol and proto != protocol.lower(): continue entry: dict[str, Any] = { "protocol": proto, "family": family, "current_active": "U" in flags, "last_active": False, "age": -1, "next_hop": clean_gateway, "outgoing_interface": iface, "selected_next_hop": True, "preference": int(route.get("priority", 0)), "inactive_reason": "", "routing_table": "global", "protocol_attributes": {}, } routes.setdefault(network, []).append(entry) return routes def get_ipv6_neighbors_table(self) -> list[dict[str, Any]]: """Return the IPv6 Neighbor Discovery (NDP) table. Calls ``GET /api/diagnostics/interface/get_ndp``. Each entry contains: ``interface``, ``mac``, ``ip``, ``age``, ``state`` (best-effort from NDP flags). """ data = self._get("/api/diagnostics/interface/get_ndp") neighbors: list[dict[str, Any]] = [] rows = data if isinstance(data, list) else data.get("rows", []) for entry in rows: neighbors.append( { "interface": entry.get("intf") or entry.get("interface", ""), "mac": (entry.get("mac") or "").lower(), "ip": entry.get("ip") or entry.get("address", ""), "age": float(entry.get("expires", 0)), "state": entry.get("state", ""), } ) return neighbors def get_lldp_neighbors(self) -> dict[str, list[dict[str, Any]]]: """Return LLDP neighbors grouped by local port. Calls ``GET /api/lldpd/service/neighbor``. Requires the ``os-lldpd`` plugin to be installed on OPNsense. Returns an empty dict if the plugin is not present. """ try: data = self._get("/api/lldpd/service/neighbor") except Exception as exc: logger.debug("LLDP neighbor fetch failed (plugin not installed?): %s", exc) return {} neighbors: dict[str, list[dict[str, Any]]] = {} for row in data.get("rows", []): port = row.get("local_port") or row.get("port", "") neighbors.setdefault(port, []).append( { "hostname": row.get("system_name") or row.get("chassis", ""), "port": row.get("port_id") or row.get("port", ""), } ) return neighbors def get_lldp_neighbors_detail(self, interface: str = "") -> dict[str, list[dict[str, Any]]]: """Return detailed LLDP neighbor information. Calls ``GET /api/lldpd/service/neighbor``. Requires the ``os-lldpd`` plugin. Returns an empty dict if the plugin is not available. :param interface: If set, filter results to this local port. """ try: data = self._get("/api/lldpd/service/neighbor") except Exception as exc: logger.debug("LLDP neighbor detail fetch failed (plugin not installed?): %s", exc) return {} details: dict[str, list[dict[str, Any]]] = {} for row in data.get("rows", []): port = row.get("local_port") or row.get("port", "") if interface and port != interface: continue details.setdefault(port, []).append( { "parent_interface": "", "remote_port": row.get("port_id") or row.get("port", ""), "remote_port_description": row.get("port_description", ""), "remote_chassis_id": row.get("chassis_id") or row.get("chassis", ""), "remote_system_name": row.get("system_name", ""), "remote_system_description": row.get("system_description", ""), "remote_system_capab": [ c.strip().lower() for c in row.get("system_capabilities", "").split(",") if c.strip() ], "remote_system_enable_capab": [ c.strip().lower() for c in row.get("enabled_capabilities", "").split(",") if c.strip() ], } ) return details def get_ntp_servers(self) -> dict[str, dict[str, Any]]: """Return configured NTP servers. Calls ``GET /api/ntpd/service/status`` which includes the list of configured peer addresses in the ``peers`` field. Returns a dict keyed by server address with an empty value dict (NAPALM standard format). """ try: data = self._get("/api/ntpd/service/status") except Exception as exc: logger.debug("NTP status fetch failed (plugin not installed?): %s", exc) return {} servers: dict[str, dict[str, Any]] = {} for peer in data.get("peers", []): addr = peer.get("address") or peer.get("remote", "") if addr: servers[addr] = {} return servers def get_vlans(self) -> dict[str, dict[str, Any]]: """Return configured VLANs. Calls ``GET /api/interfaces/vlan_settings/search_item``. Each VLAN device configured under *Interfaces → Other Types → VLAN* becomes one entry, keyed by VLAN tag (as a string). The ``interfaces`` list contains the VLAN device name (e.g. ``em0_vlan10``). When a device is already assigned to a logical interface OPNsense appends the logical name in brackets (``"em0_vlan10 [LAN]"``); this driver strips that annotation and stores the bare device name. Returned keys (NAPALM standard): ``name`` (description, or device name if no description is set), ``interfaces`` (list with the VLAN device name). """ data = self._get("/api/interfaces/vlan_settings/search_item") vlans: dict[str, dict[str, Any]] = {} for row in data.get("rows", []): tag = str(row.get("tag", "")).strip() if not tag: continue # vlanif may be "em0_vlan10 [LAN]" when assigned to a logical interface vlanif_raw = str(row.get("vlanif", "")) vlanif = vlanif_raw.split(" [")[0].strip() descr = str(row.get("descr", "")).strip() vlans[tag] = { "name": descr or vlanif, "interfaces": [vlanif] if vlanif else [], } return vlans def get_bgp_neighbors(self) -> dict[str, Any]: """Return BGP neighbor state. Requires the FRR plugin (``os-frr``) to be installed on OPNsense. Returns an empty dict if the plugin is absent or FRR is not running. Calls: - ``GET /api/quagga/bgp/get`` — local AS number and router-id from the BGP configuration model. - ``GET /api/quagga/diagnostics/bgpneighbors`` — live neighbor state as returned by FRR (``vtysh -c "show bgp neighbors json"``). Returns a NAPALM-standard dict keyed by VRF name. Only the default VRF (``"global"``) is populated; per-VRF BGP is not yet mapped. Each peer entry contains: ``local_as``, ``remote_as``, ``remote_id``, ``is_up``, ``is_enabled``, ``description``, ``uptime``, ``address_family`` (``ipv4`` and/or ``ipv6`` with prefix counters). """ try: bgp_cfg = self._get("/api/quagga/bgp/get") neighbors_data = self._get("/api/quagga/diagnostics/bgpneighbors") except Exception as exc: logger.debug("BGP config fetch failed (plugin not installed?): %s", exc) return {} bgp = bgp_cfg.get("bgp", {}) local_as_default = int(bgp.get("asnumber", 0) or 0) router_id = bgp.get("routerid", "") raw_neighbors = neighbors_data.get("response", {}) if not isinstance(raw_neighbors, dict): return {} # FRR address-family key → NAPALM address-family key _AF_MAP = { "ipv4Unicast": "ipv4", "ipv6Unicast": "ipv6", } peers: dict[str, Any] = {} for peer_ip, nbr in raw_neighbors.items(): if not isinstance(nbr, dict): continue is_up = nbr.get("bgpState", "").lower() == "established" uptime_msec = int(nbr.get("bgpTimerUpMsec", 0) or 0) uptime = uptime_msec // 1000 if is_up else -1 address_family: dict[str, Any] = {} for frr_af, napalm_af in _AF_MAP.items(): af = nbr.get("addressFamilyInfo", {}).get(frr_af) if af is not None: address_family[napalm_af] = { "sent_prefixes": int(af.get("sentPrefixCounter", 0) or 0), "received_prefixes": int(af.get("prefixReceivedCount", 0) or 0), "accepted_prefixes": int(af.get("acceptedPrefixCounter", 0) or 0), } if not address_family: # FRR did not report AF info (session not yet established) address_family["ipv4"] = { "sent_prefixes": -1, "received_prefixes": -1, "accepted_prefixes": -1, } peers[peer_ip] = { "local_as": int(nbr.get("localAs", local_as_default) or local_as_default), "remote_as": int(nbr.get("remoteAs", 0) or 0), "remote_id": nbr.get("remoteRouterId", ""), "is_up": is_up, "is_enabled": not bool(nbr.get("adminShutdown", False)), "description": nbr.get("nbrDesc", ""), "uptime": uptime, "address_family": address_family, } return { "global": { "router_id": router_id, "peers": peers, } } def get_config( self, retrieve: str = "all", full: bool = False, sanitized: bool = False, format: str = "text", ) -> dict[str, str]: """Return device configuration. OPNsense stores its configuration as XML. This getter returns the raw XML text in the ``running`` slot. ``startup`` mirrors ``running`` (OPNsense applies config immediately). The ``candidate`` slot shows the JSON-serialised staged routes when a candidate has been loaded, or an empty string otherwise. Calls ``GET /api/core/backup/download/this``, which answers with ``config.xml`` itself rather than JSON. A failed read raises: an empty ``running`` would claim the device has no configuration and hide why the read failed, e.g. an API key without the backup privilege. """ configs: dict[str, str] = {"running": "", "startup": "", "candidate": ""} if retrieve in ("all", "running", "startup"): xml_text = self._get_text("/api/core/backup/download/this") if retrieve in ("all", "running"): configs["running"] = xml_text if retrieve in ("all", "startup"): configs["startup"] = xml_text if retrieve in ("all", "candidate") and self._candidate_config is not None: configs["candidate"] = json.dumps(self._candidate_config, indent=2) return configs # ------------------------------------------------------------------ # Config management (static routes) # ------------------------------------------------------------------ def load_merge_candidate(self, filename: str | None = None, config: str | None = None) -> None: """Stage a set of static-route additions as a candidate config. OPNsense does not offer a single generic config-push endpoint. This method targets the **routes** subsystem (``/api/routes/routes/``) and accepts a JSON list of route objects. The *config* parameter must be a JSON string containing a list of route dicts. Each dict may contain the following keys: .. code-block:: json [ { "network": "10.0.0.0/8", "gateway": "WAN_GW", "descr": "optional description", "disabled": "0" } ] ``gateway`` must be the **name** of an existing OPNsense gateway (not an IP address) as shown in *System → Gateways → Configuration*. :param filename: Path to a JSON file containing the route list. :param config: JSON string containing the route list. :raises MergeConfigException: if neither or both arguments are given, or if the JSON is malformed. """ if filename is None and config is None: raise MergeConfigException("Provide either 'filename' or 'config'.") if filename is not None and config is not None: raise MergeConfigException("Provide either 'filename' or 'config', not both.") if filename is not None: try: with open(filename, encoding="utf-8") as fh: config = fh.read() except OSError as exc: raise MergeConfigException(f"Cannot read file {filename!r}: {exc}") from exc try: routes = json.loads(config) # type: ignore[arg-type] except json.JSONDecodeError as exc: raise MergeConfigException(f"Invalid JSON in candidate config: {exc}") from exc if not isinstance(routes, list): raise MergeConfigException("Candidate config must be a JSON array of route objects.") for i, route in enumerate(routes): if not isinstance(route, dict): raise MergeConfigException(f"Route at index {i} must be a JSON object.") if "network" not in route or "gateway" not in route: raise MergeConfigException(f"Route at index {i} is missing 'network' or 'gateway'.") self._candidate_config = routes def compare_config(self) -> str: """Return a unified diff of the candidate vs the current routes. Fetches the live route list from ``GET /api/routes/routes/searchroute`` and diffs it against the staged candidate. Returns an empty string if no candidate has been loaded. """ if self._candidate_config is None: return "" current_routes = self._fetch_current_routes() current_text = json.dumps(current_routes, indent=2, sort_keys=True) candidate_text = json.dumps(self._candidate_config, indent=2, sort_keys=True) diff = difflib.unified_diff( current_text.splitlines(keepends=True), candidate_text.splitlines(keepends=True), fromfile="current", tofile="candidate", ) return "".join(diff) def commit_config(self, message: str = "", revert_in: int | None = None) -> None: """Apply the staged candidate routes to the device. Each route in the candidate is submitted via ``POST /api/routes/routes/addroute``. After all routes are added, ``POST /api/routes/routes/reconfigure`` is called to activate them. The UUIDs returned by OPNsense are stored internally so that :meth:`rollback` can remove exactly these routes. :param message: Ignored (OPNsense has no commit-message concept). :param revert_in: Ignored (auto-rollback not supported via API). :raises MergeConfigException: if no candidate is staged, or if the API returns an error for any route. """ if self._candidate_config is None: raise MergeConfigException( "No candidate config loaded. Call load_merge_candidate() first." ) # Record the current backup ID so rollback() can restore exactly # this state after OPNsense writes the new config to disk. self._pre_commit_backup_id = self._get_latest_backup_id() try: for route in self._candidate_config: payload = { "route": { "network": route["network"], "gateway": route["gateway"], "descr": route.get("descr", ""), "disabled": route.get("disabled", "0"), } } self._post("/api/routes/routes/addroute", payload) self._post("/api/routes/routes/reconfigure") except RequestException as exc: raise MergeConfigException(f"Failed to apply route config: {exc}") from exc self._candidate_config = None def discard_config(self) -> None: """Discard the staged candidate config without applying it.""" self._candidate_config = None def rollback(self) -> None: """Revert the device to the configuration state captured before the last commit. OPNsense automatically saves a backup of ``config.xml`` before applying configuration changes. :meth:`commit_config` records the ID of the most recent backup at the time of the commit so that this method can restore exactly the pre-commit state via ``POST /api/core/backup/revert_backup/{backup_id}``. If no backup ID is available (i.e. :meth:`commit_config` was never called in this session, or the backup list was empty at commit time), the most recent backup from the device is used as a fallback. If no backups exist at all, this is a no-op. """ backup_id = self._pre_commit_backup_id or self._get_latest_backup_id() if backup_id is None: return self._post(f"/api/core/backup/revert_backup/{backup_id}") self._pre_commit_backup_id = None # ------------------------------------------------------------------ # Internal helpers (config management) # ------------------------------------------------------------------ def _get_latest_backup_id(self) -> str | None: """Return the ID of the most recent server-side config backup, or ``None``. Calls ``GET /api/core/backup/backups/this``. The response is sorted newest-first by OPNsense. Returns ``None`` when no backups exist or the request fails. """ try: data = self._get("/api/core/backup/backups/this") items = data.get("items", []) return items[0]["id"] if items else None except Exception as exc: logger.debug("No backup snapshots found: %s", exc) return None def _fetch_current_routes(self) -> list[dict[str, Any]]: """Return the current static routes from the OPNsense API. Calls ``GET /api/routes/routes/searchroute`` and normalises the response to the same keys used by :meth:`load_merge_candidate`. """ data = self._get("/api/routes/routes/searchroute") routes: list[dict[str, Any]] = [] for row in data.get("rows", []): routes.append( { "network": row.get("network", ""), "gateway": row.get("gateway", ""), "descr": row.get("descr", ""), "disabled": row.get("disabled", "0"), } ) return routes # ------------------------------------------------------------------ # NetOrch extensions: packages, services, updates # ------------------------------------------------------------------ def get_packages(self) -> list[dict[str, Any]]: """Return installed OPNsense plugins. Calls ``GET /api/core/firmware/info`` and returns the ``plugin`` list filtered to entries where ``installed == "1"``. The format mirrors the OpenWrt NAPALM driver so NetOrch can render both drivers with the same UI component: ``{name, version, installed, description, size, source}`` """ info = self._get("/api/core/firmware/info") result: list[dict[str, Any]] = [] for p in info.get("plugin", []): if p.get("installed") != "1": continue result.append( { "name": p.get("name", ""), "version": p.get("version", ""), "installed": True, "description": p.get("comment", ""), "size": 0, "source": "opnsense-plugins", } ) return sorted(result, key=lambda x: x["name"].lower()) def get_certificates(self) -> list[dict[str, Any]]: """Return certificates from the OPNsense Trust store. Calls ``POST /api/trust/cert/search`` and normalises each row to ``{name, issuer, valid_from, valid_to, in_use_by}`` (Unix timestamps for the validity fields). Never includes crt_payload/prv_payload/csr_payload -- those rows carry private key material and must not leave the Trust store. """ try: data = self._post("/api/trust/cert/search") except Exception as exc: logger.warning("get_certificates() failed: %s", exc) return [] def _as_int(value: Any) -> int: try: return int(value or 0) except (TypeError, ValueError): return 0 result: list[dict[str, Any]] = [] for row in data.get("rows", []): result.append( { "name": row.get("commonname") or row.get("descr") or row.get("refid", ""), "issuer": row.get("%caref") or "", "valid_from": _as_int(row.get("valid_from")), "valid_to": _as_int(row.get("valid_to")), "in_use_by": _as_int(row.get("in_use")), } ) return result def get_ddns_status(self) -> dict[str, Any] | None: """Return Dynamic DNS (os-ddclient) enabled/running state. Calls ``GET /api/dyndns/service/status`` and ``GET /api/dyndns/settings/get`` -- note the API module is "dyndns", not "ddclient" (the service id reported by get_services() is "ddclient", but its REST controller lives under a different, historical module name). Returns ``{"enabled": bool, "running": bool}``, or ``None`` if the plugin isn't installed/reachable. Scoped to enabled/running only: no per-account "registered IP" comparison, since that would need a configured account to verify the response shape against. """ try: status = self._get("/api/dyndns/service/status") settings = self._get("/api/dyndns/settings/get") except Exception as exc: logger.warning("get_ddns_status() failed: %s", exc) return None general = settings.get("ddclient", {}).get("general", {}) return { "enabled": general.get("enabled") == "1", "running": status.get("status") == "running", } def get_radius_clients(self) -> list[dict[str, Any]]: """Return configured FreeRADIUS NAS clients. Calls ``GET /api/freeradius/client/search_client``. """ try: data = self._get("/api/freeradius/client/search_client") except Exception as exc: logger.warning("get_radius_clients() failed: %s", exc) return [] return [ { "id": row.get("uuid", ""), "name": row.get("name", ""), "ip": row.get("ip", ""), "enabled": row.get("enabled") == "1", } for row in data.get("rows", []) ] def create_radius_client(self, name: str, ip: str, secret: str) -> dict[str, Any]: """Create a FreeRADIUS NAS client and apply the change. Calls ``POST /api/freeradius/client/add_client``, then ``POST /api/freeradius/service/reconfigure`` to apply -- a saved client has no effect on the running radiusd until reconfigured. The add response carries no id, so the new client's uuid is looked up afterwards via get_radius_clients() (matched by name) -- callers need it to address this client in later set_client/del_client calls. """ payload = {"client": {"name": name, "ip": ip, "secret": secret}} result = self._post("/api/freeradius/client/add_client", payload) if result.get("result") != "saved": return {"success": False, "validations": result.get("validations", {})} self._post("/api/freeradius/service/reconfigure") created = next((c for c in self.get_radius_clients() if c["name"] == name), None) return {"success": True, "id": created["id"] if created else None} def delete_radius_client(self, client_id: str) -> dict[str, Any]: """Delete a FreeRADIUS NAS client by uuid and apply the change.""" result = self._post(f"/api/freeradius/client/del_client/{client_id}") if result.get("result") != "deleted": return {"success": False} self._post("/api/freeradius/service/reconfigure") return {"success": True} def get_radius_users(self) -> list[dict[str, Any]]: """Return configured FreeRADIUS users. Calls ``GET /api/freeradius/user/search_user``. Never includes the password field. """ try: data = self._get("/api/freeradius/user/search_user") except Exception as exc: logger.warning("get_radius_users() failed: %s", exc) return [] return [ { "id": row.get("uuid", ""), "username": row.get("username", ""), "enabled": row.get("enabled") == "1", } for row in data.get("rows", []) ] def create_radius_user(self, username: str, password: str) -> dict[str, Any]: """Create a FreeRADIUS user and apply the change. Calls ``POST /api/freeradius/user/add_user``, then ``POST /api/freeradius/service/reconfigure`` to apply. The add response carries no id, so the new user's uuid is looked up afterwards via get_radius_users() (matched by username) -- callers need it to address this user in later set_user/del_user calls. """ payload = {"user": {"username": username, "password": password}} result = self._post("/api/freeradius/user/add_user", payload) if result.get("result") != "saved": return {"success": False, "validations": result.get("validations", {})} self._post("/api/freeradius/service/reconfigure") created = next((u for u in self.get_radius_users() if u["username"] == username), None) return {"success": True, "id": created["id"] if created else None} def delete_radius_user(self, user_id: str) -> dict[str, Any]: """Delete a FreeRADIUS user by uuid and apply the change.""" result = self._post(f"/api/freeradius/user/del_user/{user_id}") if result.get("result") != "deleted": return {"success": False} self._post("/api/freeradius/service/reconfigure") return {"success": True} def get_dhcp_leases(self) -> list[dict[str, Any]]: """Return active DHCP leases from OPNsense. Tries all known DHCP backends in order: 1. **Kea DHCPv4** (os-kea plugin) — ``GET /api/kea/leases4/search`` Fields: ``hw-address``, ``ip-address``, ``hostname``, ``expire`` 2. **ISC DHCP** (legacy) — ``POST /api/dhcpv4/leases/searchlease`` Fields: ``mac``, ``address``, ``hostname``, ``ends`` 3. **ARP table** fallback — ``GET /api/diagnostics/interface/get_arp`` Provides IP only, hostname will be empty, ``state`` is ``"arp"``. The first source with leases wins. Without any, the ARP table stands in -- also when Kea or ISC answered empty, since a box can hand out addresses through a service neither covers. An empty list is a real answer only: Kea or ISC answered with no leases and the fallback added none. When neither answered and the fallback gives no rows (it failed, or the table is empty), there is no answer to give, and this raises :class:`RuntimeError` naming each failure -- a caller would otherwise take ``[]`` for a server without leases and drop the ones it knows. Each returned entry contains: * ``mac`` — lower-case MAC address * ``ip`` — assigned IP address * ``hostname`` — client hostname (may be empty) * ``lease_end`` — Unix timestamp when the lease expires (0 if unknown) * ``state`` — raw state string """ def _parse_kea(rows: list[dict]) -> list[dict[str, Any]]: result = [] for row in rows: # OPNsense Kea uses "hwaddr"; standard Kea uses "hw-address"; ISC DHCP uses "mac" mac = ( (row.get("hwaddr") or row.get("hw-address") or row.get("mac") or "") .lower() .strip() ) if not mac: continue raw_end = row.get("expire") or row.get("ends") or 0 try: lease_end = int(raw_end) except (ValueError, TypeError): lease_end = 0 result.append( { "mac": mac, "ip": ( row.get("address") or row.get("ip-address") or row.get("ip") or "" ).strip(), "hostname": (row.get("hostname") or "").strip(), "lease_end": lease_end, "state": str(row.get("state") or ""), } ) return result failures: list[str] = [] answered = False # a lease source answered, with or without leases # 1. Kea DHCPv4 plugin try: data = self._get("/api/kea/leases4/search") rows = ( data.get("rows") or data.get("leases") or (data if isinstance(data, list) else []) ) leases = _parse_kea(rows) if leases: return leases answered = True except Exception as exc: logger.debug("Kea DHCP lease fetch failed: %s", exc) failures.append(f"Kea: {exc}") # 2. ISC DHCP (legacy) try: data = self._post( "/api/dhcpv4/leases/searchlease", {"current": 1, "rowCount": -1, "searchPhrase": "", "sort": {}}, ) leases = _parse_kea(data.get("rows", [])) if leases: return leases answered = True except Exception as exc: logger.debug("ISC DHCP lease fetch failed: %s", exc) failures.append(f"ISC DHCP: {exc}") # 3. ARP table fallback (IP only, no hostname) fallback: list[dict[str, Any]] = [] try: fallback = [ {"mac": e["mac"], "ip": e["ip"], "hostname": "", "lease_end": 0, "state": "arp"} for e in self.get_arp_table() if e.get("mac") and e.get("ip") ] if not fallback: failures.append("ARP table: no entries") except Exception as exc: logger.warning("ARP table fallback failed: %s", exc) failures.append(f"ARP table: {exc}") if fallback or answered: return fallback raise RuntimeError("No DHCP lease source answered -- " + "; ".join(failures)) def create_dhcp_reservation(self, mac: str, ip: str, hostname: str = "") -> None: """Create (or update) a Kea DHCPv4 static reservation (MAC → IP). Only the Kea backend (os-kea plugin) is supported — this driver has no active OPNsense environment with legacy ISC DHCP to verify a second code path against, unlike ``get_dhcp_leases()``'s read-only try-then-fallback. Callers should treat a missing/disabled Kea plugin as "reservations unsupported" (``RuntimeError``), not fall back to plain DHCP silently. A reservation that already reads exactly as requested is left alone: no ``setReservation`` and no ``service/reconfigure``. The reconfigure stops and starts Kea rather than reloading it, so restarting the DHCP server for a write that changes nothing is pure risk. :param mac: NIC MAC address (any common formatting; sent as-is to Kea's ``hw_address`` field). :param ip: IP address to reserve; must fall inside a subnet Kea already manages (``searchSubnet``), or this raises ValueError. :param hostname: optional hostname to record on the reservation. :raises ValueError: if no Kea subnet contains ``ip``. :raises RuntimeError: if Kea rejects the reservation (validation errors) or the Kea plugin isn't installed/enabled. """ try: subnets = self._get("/api/kea/dhcpv4/searchSubnet").get("rows") or [] except Exception as exc: raise RuntimeError(f"Kea DHCPv4 plugin unavailable: {exc}") from exc ip_obj = ip_address(ip) subnet_uuid = None subnet_cidr = "" for row in subnets: try: if ip_obj in ip_network(row["subnet"], strict=False): subnet_uuid = row["uuid"] subnet_cidr = str(row["subnet"]).strip() break except ValueError: continue if subnet_uuid is None: raise ValueError(f"No Kea-managed subnet contains {ip}") # Idempotency: reuse an existing reservation for this IP if one # already exists (e.g. a retried provisioning job), rather than # creating a duplicate Kea rejects anyway. existing_row: dict[str, Any] | None = None try: existing = self._post( "/api/kea/dhcpv4/searchReservation", {"current": 1, "rowCount": -1, "searchPhrase": ip}, ) for row in existing.get("rows") or []: if row.get("ip_address") == ip: existing_row = row break except Exception as exc: logger.debug("Kea reservation search failed, proceeding to add: %s", exc) payload = { "reservation": { "subnet": subnet_uuid, "ip_address": ip, "hw_address": mac, "hostname": hostname, "description": self._NETORK_TAG, } } if existing_row is not None and self._kea_reservation_unchanged( existing_row, payload["reservation"], subnet_cidr ): logger.debug("Kea reservation for %s is already as requested, not touched", ip) return existing_uuid = existing_row.get("uuid") if existing_row is not None else None path = ( f"/api/kea/dhcpv4/setReservation/{existing_uuid}" if existing_uuid else "/api/kea/dhcpv4/addReservation" ) result = self._post(path, payload) if result.get("result") != "saved": raise RuntimeError(f"Kea rejected DHCP reservation for {ip}: {result}") self._post("/api/kea/service/reconfigure") @staticmethod def _kea_reservation_unchanged( row: dict[str, Any], wanted: dict[str, Any], subnet_cidr: str ) -> bool: """Whether a ``searchReservation`` row already holds every field we would write. The MAC is compared normalised: Kea keeps it as it was entered, in either case and with ``:`` or ``-``. The subnet is a model relation that comes back as the subnet's CIDR or its UUID depending on the OPNsense version, so either counts. A field the row does not carry counts as a difference -- writing once too often is the safe side. """ if str(row.get("subnet") or "").strip() not in {wanted["subnet"], subnet_cidr}: return False if normalize_mac(row.get("hw_address")) != normalize_mac(wanted["hw_address"]): return False return all( str(row.get(field) or "") == str(wanted[field] or "") for field in ("ip_address", "hostname", "description") ) def delete_dhcp_reservation_and_lease(self, mac: str, ip: str) -> dict[str, Any]: """Remove a Kea DHCPv4 static reservation and any active lease for (mac, ip). Combined per NetOrk's VM-deletion cleanup flow — reservation and lease removal are always requested together, so a single driver call keeps callers from having to sequence two calls themselves. Reservation removal follows the same search-then-act pattern as ``create_dhcp_reservation`` (``searchReservation`` -> ``del/{uuid}`` -> ``service/reconfigure``) and raises on failure, mirroring that method's "never silently no-op on the thing the caller explicitly asked for" contract. Lease removal is best-effort and non-fatal — a failure here just means a stale lease record lingers in Kea until its own natural cleanup, which is cosmetic, not a functional problem (a deleted reservation already prevents the client from getting the same IP back). Endpoint verified live against a real OPNsense instance: ``LeasesController`` is documented as "Abstract [non-callable]" with a ``del_lease($ips=null)`` action (https://docs.opnsense.org/development/api/core/kea.html) — the concrete, callable route is the ``leases4`` controller (matching ``search``, used above), and despite the ``$ips`` parameter name the IP is passed as a URL path segment, not a JSON body field — a POST body of ``{"ips": [ip]}`` (the natural reading of the signature) returns ``{"status": "error", "message": "Missing lease IP parameter"}``; only ``POST /api/kea/leases4/del_lease/{ip}`` works. :param mac: NIC MAC address of the reservation to remove. :param ip: IP address of the reservation/lease to remove. :returns: {"reservation_found", "reservation_deleted", "lease_found", "lease_deleted"} — all bool. :raises RuntimeError: Kea plugin unavailable, or Kea rejects deletion of a reservation that does exist. """ result: dict[str, Any] = { "reservation_found": False, "reservation_deleted": False, "lease_found": False, "lease_deleted": False, } # --- Reservation --- try: existing = self._post( "/api/kea/dhcpv4/searchReservation", {"current": 1, "rowCount": -1, "searchPhrase": ip}, ) except Exception as exc: raise RuntimeError(f"Kea DHCPv4 plugin unavailable: {exc}") from exc reservation_uuid = None for row in existing.get("rows") or []: if row.get("ip_address") == ip: reservation_uuid = row.get("uuid") break if reservation_uuid: result["reservation_found"] = True del_result = self._post(f"/api/kea/dhcpv4/delReservation/{reservation_uuid}") if del_result.get("result") != "deleted": raise RuntimeError(f"Kea rejected reservation delete for {ip}: {del_result}") result["reservation_deleted"] = True self._post("/api/kea/service/reconfigure") # --- Lease (best-effort) --- try: leases = self._get("/api/kea/leases4/search") rows = leases.get("rows") or leases.get("leases") or [] if any((row.get("address") or row.get("ip-address")) == ip for row in rows): result["lease_found"] = True del_lease = self._post(f"/api/kea/leases4/del_lease/{ip}") result["lease_deleted"] = bool( del_lease.get("result") == "deleted" or del_lease.get("status") == "ok" ) except Exception as exc: logger.warning("DHCP lease delete for %s failed (non-fatal): %s", ip, exc) return result # ------------------------------------------------------------------ # DhcpServerMixin implementation (Kea DHCPv4). The diff and the apply # loop are generic and live in napalm_device_types.dhcp; only these # three methods know about Kea's REST shape. # ------------------------------------------------------------------ def _kea_subnets(self) -> list[dict[str, Any]]: """Return Kea's managed subnets, or raise if the plugin is absent.""" try: return self._get("/api/kea/dhcpv4/searchSubnet").get("rows") or [] except Exception as exc: raise RuntimeError(f"Kea DHCPv4 plugin unavailable: {exc}") from exc def _kea_subnet_uuid_for(self, subnet: str, ip: str) -> str: """Resolve a reservation's target subnet to Kea's subnet UUID. Prefers an exact CIDR match on ``subnet``; falls back to whichever managed subnet contains ``ip`` when the caller left ``subnet`` empty. """ rows = self._kea_subnets() if subnet: for row in rows: if str(row.get("subnet") or "").strip() == subnet: return str(row["uuid"]) raise ValueError(f"No Kea-managed subnet matches {subnet}") ip_obj = ip_address(ip) for row in rows: try: if ip_obj in ip_network(row["subnet"], strict=False): return str(row["uuid"]) except (ValueError, KeyError): continue raise ValueError(f"No Kea-managed subnet contains {ip}") def get_dhcp_reservations(self) -> list[dict[str, Any]]: """Return all Kea DHCPv4 static reservations, vendor-neutral. This is the *configured* state — ``get_dhcp_leases()`` returns what is actually leased. Kea's ``subnet`` field on a reservation is a model relation: depending on version it comes back as the related subnet's CIDR or as its UUID, so both are accepted and normalised to a CIDR here. An unresolvable relation degrades to an empty string rather than raising — one orphaned reservation must not make the whole inventory unreadable. :raises RuntimeError: if the Kea plugin isn't installed/enabled. """ try: data = self._post( "/api/kea/dhcpv4/searchReservation", {"current": 1, "rowCount": -1}, ) except Exception as exc: raise RuntimeError(f"Kea DHCPv4 plugin unavailable: {exc}") from exc rows = data.get("rows") or [] if not rows: return [] cidr_by_uuid = { str(row.get("uuid") or ""): str(row.get("subnet") or "") for row in self._kea_subnets() } known_cidrs = set(cidr_by_uuid.values()) reservations: list[dict[str, Any]] = [] for row in rows: raw_subnet = str(row.get("subnet") or "").strip() if raw_subnet in known_cidrs: subnet = raw_subnet else: subnet = cidr_by_uuid.get(raw_subnet, "") reservations.append( { "uuid": str(row.get("uuid") or ""), "mac": str(row.get("hw_address") or ""), "ip": str(row.get("ip_address") or ""), "hostname": str(row.get("hostname") or ""), "description": str(row.get("description") or ""), "subnet": subnet, } ) return reservations def apply_dhcp_reservation( self, reservation: dict[str, Any], *, uuid: str | None = None ) -> dict[str, Any]: """Create or update a single Kea DHCPv4 static reservation. Does not reconfigure the service — call ``commit_dhcp_reservations()`` once after a batch, so a ruleset apply costs one daemon reload rather than one per reservation. :raises ValueError: if no Kea-managed subnet matches the reservation. :raises RuntimeError: if Kea rejects the write. """ subnet_uuid = self._kea_subnet_uuid_for( str(reservation.get("subnet") or "").strip(), str(reservation.get("ip") or ""), ) payload = { "reservation": { "subnet": subnet_uuid, "ip_address": reservation.get("ip", ""), "hw_address": reservation.get("mac", ""), "hostname": reservation.get("hostname", ""), "description": reservation.get("description", ""), } } path = ( f"/api/kea/dhcpv4/setReservation/{uuid}" if uuid else "/api/kea/dhcpv4/addReservation" ) result = self._post(path, payload) if result.get("result") != "saved": raise RuntimeError( f"Kea rejected DHCP reservation for {reservation.get('ip')}: {result}" ) return {"success": True} def commit_dhcp_reservations(self) -> dict[str, Any]: """Reload Kea so pending reservation writes take effect.""" self._post("/api/kea/service/reconfigure") return {"success": True} # ── Kea DHCPv4 subnets ──────────────────────────────────────────────── # OPNsense renders repeatable option fields ("AsList") as comma-separated # strings, and the pool list as a newline-separated text block. Which of # the two shapes a given field uses is a property of the model, not of the # request, so the mapping is a constant rather than something to sniff. _KEA_LIST_OPTIONS = ( "routers", "domain_name_servers", "domain_search", "ntp_servers", ) _KEA_SCALAR_OPTIONS = ("domain_name",) @staticmethod def _kea_read_list(raw: Any) -> list[str]: """Read an OPNsense list field, in either shape it comes back in. Plain string: ``"a,b"``. Selection map: ``{"a": {"selected": 1}, ...}`` -- which OPNsense uses for some model field types and versions. Both appear in the wild for the same logical field, so both are accepted rather than pinning the driver to one OPNsense release. """ if isinstance(raw, dict): return [ str(key) for key, meta in raw.items() if isinstance(meta, dict) and str(meta.get("selected", 0)) == "1" ] if isinstance(raw, list): return [str(item).strip() for item in raw if str(item).strip()] return [part.strip() for part in str(raw or "").split(",") if part.strip()] @staticmethod def _kea_read_scalar(raw: Any) -> str: if isinstance(raw, dict): selected = [ str(key) for key, meta in raw.items() if isinstance(meta, dict) and str(meta.get("selected", 0)) == "1" ] return selected[0] if selected else "" return str(raw or "").strip() @staticmethod def _kea_subnet_record(detail: dict[str, Any]) -> dict[str, Any]: """The subnet record out of a ``getSubnet`` response. OPNsense wraps it under ``subnet4``; older builds used ``subnet``, and keeping the fallback costs nothing. Reading the wrong key costs a great deal: the record comes back empty, so a subnet reports no pools and no options at all, and on update the partial-option merge has nothing to preserve and blanks every option Kea autocollected -- stranding a whole VLAN without a gateway, which is the exact failure that merge exists to prevent. """ return detail.get("subnet4") or detail.get("subnet") or {} def get_dhcp_subnets(self) -> list[dict[str, Any]]: """Return every Kea DHCPv4 subnet with its pools and options. ``searchSubnet`` only carries uuid/subnet/description, so each row is followed by a ``getSubnet`` call for the option data. That is one request per subnet; a firewall serves a handful of them, so the extra round trips cost less than the reconfigure they help avoid. An option Kea does not carry is *omitted* from ``option_data`` rather than reported as empty. The generic diff treats an absent key as "not managed", so reporting ``[]`` here would make every unmanaged option look like a pending change and reload the daemon on every run. A subnet whose detail fetch fails is skipped with a log line instead of aborting: one broken record must not make the whole inventory unreadable, same rule as ``get_dhcp_reservations()``. :raises RuntimeError: if the Kea plugin isn't installed/enabled. """ rows = self._kea_subnets() subnets: list[dict[str, Any]] = [] for row in rows: uuid = str(row.get("uuid") or "") if not uuid: continue try: detail = self._get(f"/api/kea/dhcpv4/getSubnet/{uuid}") except Exception as exc: logger.warning("Kea subnet %s detail fetch failed, skipping: %s", uuid, exc) continue record = self._kea_subnet_record(detail) raw_options = record.get("option_data") or {} option_data: dict[str, Any] = {} for name in self._KEA_LIST_OPTIONS: values = self._kea_read_list(raw_options.get(name)) if values: option_data[name] = values for name in self._KEA_SCALAR_OPTIONS: value = self._kea_read_scalar(raw_options.get(name)) if value: option_data[name] = value pools = [ line.strip() for line in str(record.get("pools") or "").splitlines() if line.strip() ] subnets.append( { "uuid": uuid, "subnet": str(record.get("subnet") or row.get("subnet") or ""), "description": str(record.get("description") or ""), "pools": pools, "option_data": option_data, "match_client_id": str(record.get("match-client-id") or "0") == "1", } ) return subnets def apply_dhcp_subnet( self, subnet: dict[str, Any], *, uuid: str | None = None ) -> dict[str, Any]: """Create or update a single Kea DHCPv4 subnet. ``option_data`` is a partial update, as the mixin contract requires: on an update the subnet's current options are read first and only the named ones are replaced. Without that, managing `domain_search` alone would blank the `routers` Kea autocollected and strand every client on that VLAN without a gateway. Setting any option explicitly also turns ``option_data_autocollect`` off. Left on, Kea keeps re-filling routers/DNS/NTP from the interface and the next diff sees a change again -- a reconfigure loop rather than a converged state. Does not reconfigure the service -- call ``commit_dhcp_subnets()`` once after a batch. :raises RuntimeError: if Kea rejects the write. """ desired_options = dict(subnet.get("option_data") or {}) merged_options: dict[str, str] = {} if uuid and desired_options: try: current = self._get(f"/api/kea/dhcpv4/getSubnet/{uuid}") raw = self._kea_subnet_record(current).get("option_data") or {} except Exception as exc: raise RuntimeError( f"Kea subnet {uuid} could not be read before update: {exc}" ) from exc for name in self._KEA_LIST_OPTIONS: values = self._kea_read_list(raw.get(name)) if values: merged_options[name] = ",".join(values) for name in self._KEA_SCALAR_OPTIONS: value = self._kea_read_scalar(raw.get(name)) if value: merged_options[name] = value for name, value in desired_options.items(): merged_options[name] = ",".join(value) if isinstance(value, list) else str(value) record: dict[str, Any] = {"subnet": subnet.get("subnet", "")} if subnet.get("description") is not None: record["description"] = subnet.get("description") or "" if subnet.get("pools") is not None: record["pools"] = "\n".join(subnet.get("pools") or []) if subnet.get("match_client_id") is not None: record["match-client-id"] = "1" if subnet.get("match_client_id") else "0" if desired_options: record["option_data"] = merged_options record["option_data_autocollect"] = "0" path = f"/api/kea/dhcpv4/setSubnet/{uuid}" if uuid else "/api/kea/dhcpv4/addSubnet" result = self._post(path, {"subnet": record}) if result.get("result") != "saved": raise RuntimeError(f"Kea rejected DHCP subnet {subnet.get('subnet')}: {result}") return {"success": True} def commit_dhcp_subnets(self) -> dict[str, Any]: """Reload Kea so pending subnet writes take effect.""" self._post("/api/kea/service/reconfigure") return {"success": True} def get_services(self) -> list[dict[str, Any]]: """Return running services from OPNsense. Calls ``GET /api/core/service/search`` and normalises the rows to ``{name, running, enabled, pid}`` — the same format used by the OpenWrt driver so the UI can render them identically. """ data = self._get("/api/core/service/search") result: list[dict[str, Any]] = [] for row in data.get("rows", []): result.append( { "name": row.get("name") or row.get("id", ""), "running": bool(row.get("running", 0)), "enabled": True, # OPNsense has no separate enabled/disabled state "pid": 0, } ) return sorted(result, key=lambda x: x["name"].lower()) def get_listening_sockets(self) -> ListeningSocketsDict: """Every listening TCP and bound UDP socket, with the OPNsense service behind it. Read from ``/api/diagnostics/interface/get_socket_statistics``, which configd collects as root, so every socket names its process where it has one (``attributed``). Which service: :mod:`napalm_opnsense.listening`. """ statistics = self._get("/api/diagnostics/interface/get_socket_statistics") services = [service["name"] for service in self.get_services()] try: wireguard = wireguard_listen_ports(self._get("/api/wireguard/service/show")) except Exception as exc: # No WireGuard here: its kernel sockets stay nobody's. logger.debug("WireGuard listen ports not read: %s", exc) wireguard = set() return { "attributed": True, "sockets": parse_socket_statistics(statistics, services, wireguard), } def get_virtual_ips(self) -> VirtualIpsDict: """The firewall's CARP addresses, and whether this node holds each. From ``/api/interfaces/overview/export``: every address bound to a CARP VHID, the CARP address and IP aliases on its VHID alike, with this node's CARP state (:mod:`napalm_opnsense.virtual_ips`). vip-manager and keepalived are Linux mechanisms and are not looked for (None). :raises: whatever the request raises -- an unread overview is not "no CARP". """ return { "vip_manager": None, "keepalived": None, "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. OPNsense exposes per-plugin service endpoints at ``/api//service/``. The ``name`` parameter must match the service ``id`` returned by :meth:`get_services`. Supported actions: ``start``, ``stop``, ``restart``. ``enable`` and ``disable`` are not supported on OPNsense (plugins are enabled/disabled by installing or removing them). """ import re as _re if not _re.match(r"^[a-zA-Z0-9_\-]+$", name): raise ValueError(f"Invalid service name: {name!r}") if action not in ("start", "stop", "restart"): return { "success": False, "output": f"Action '{action}' is not supported on OPNsense services", } try: result = self._post(f"/api/{name}/service/{action}") return {"success": True, "output": str(result)} except Exception as exc: logger.warning("Service action failed: %s", exc) return {"success": False, "output": str(exc)} def get_vpn_tunnels(self) -> dict[str, dict[str, Any]]: """Return status of all configured VPN tunnels. Queries IPsec, OpenVPN, and WireGuard in order and merges results into a single dict keyed by a unique tunnel identifier. Each entry follows the ``VPNTunnelDict`` schema: ``type``, ``local_endpoint``, ``remote_endpoint``, ``is_up``, ``uptime``, ``bytes_in``, ``bytes_out``, ``description``. * **IPsec** — ``GET /api/ipsec/sessions`` Falls back to ``GET /api/ipsec/leases/searchPhase2`` on older OPNsense releases. * **OpenVPN** — ``GET /api/openvpn/instances/search`` Instance status obtained from ``GET /api/openvpn/service/show``. * **WireGuard** — ``GET /api/wireguard/service/show`` """ tunnels: dict[str, dict[str, Any]] = {} # ── IPsec ────────────────────────────────────────────────────────── try: data = self._get("/api/ipsec/sessions") # OPNsense 24.x returns {"response": [...]} or a bare list sessions = data.get("response") if isinstance(data, dict) else data if isinstance(sessions, list): for session in sessions: # Each session object may contain multiple child SAs; we # report one entry per IKE peer. remote_host = ( session.get("remote-host") or session.get("remote_host") or session.get("remote-id") or "" ) local_host = ( session.get("local-host") or session.get("local_host") or session.get("local-id") or "" ) name = ( session.get("uniqueid") or session.get("con-id") or session.get("name") or remote_host or f"ipsec-{len(tunnels)}" ) state = str(session.get("state") or session.get("ikey-state") or "").lower() is_up = state in ("established", "up", "installed") uptime_raw = session.get("established") or session.get("uptime") or 0 try: uptime = int(uptime_raw) except (ValueError, TypeError): uptime = 0 # Byte counters may live in child-sa entries bytes_in = 0 bytes_out = 0 for child in ( session.get("child-sas", {}).values() if isinstance(session.get("child-sas"), dict) else [] ): try: bytes_in += int(child.get("bytes-in", 0) or 0) bytes_out += int(child.get("bytes-out", 0) or 0) except (ValueError, TypeError): pass description = session.get("local-id") or session.get("description") or "" key = f"ipsec-{name}" tunnels[key] = { "type": "IPsec", "local_endpoint": local_host, "remote_endpoint": remote_host, "is_up": is_up, "uptime": uptime, "bytes_in": bytes_in, "bytes_out": bytes_out, "description": description, } except Exception as exc: logger.debug("IPsec session status fetch failed: %s", exc) # Try legacy Phase-2 leases endpoint (OPNsense < 23.x) try: data = self._post( "/api/ipsec/leases/searchPhase2", {"current": 1, "rowCount": -1, "searchPhrase": "", "sort": {}}, ) for row in data.get("rows", []): name = row.get("id") or row.get("con") or f"ipsec-{len(tunnels)}" key = f"ipsec-{name}" state = str(row.get("state") or "").lower() is_up = state in ("established", "installed", "up") tunnels[key] = { "type": "IPsec", "local_endpoint": row.get("local-ts", ""), "remote_endpoint": row.get("remote-ts", ""), "is_up": is_up, "uptime": 0, "bytes_in": int(row.get("bytes-in", 0) or 0), "bytes_out": int(row.get("bytes-out", 0) or 0), "description": row.get("con", ""), } except Exception as exc: logger.debug("Legacy IPsec Phase-2 lease fetch failed: %s", exc) # ── OpenVPN ──────────────────────────────────────────────────────── try: data = self._get("/api/openvpn/instances/search") instances = data.get("rows", []) # Fetch live service status to determine up/down state try: show = self._get("/api/openvpn/service/show") # show is a dict of {instance_id: {status, ...}} status_map: dict[str, Any] = show if isinstance(show, dict) else {} except Exception as exc: logger.debug("OpenVPN service status fetch failed: %s", exc) status_map = {} for inst in instances: iid = inst.get("id") or inst.get("vpnid") or f"ovpn-{len(tunnels)}" name = inst.get("description") or inst.get("dev") or str(iid) key = f"openvpn-{iid}" svc = status_map.get(str(iid), {}) is_up = str(svc.get("running", False)).lower() in ("true", "1", "yes") local_addr = inst.get("local") or inst.get("interface") or "" remote_addr = inst.get("server") or inst.get("remote") or "" tunnels[key] = { "type": "SSL", "local_endpoint": local_addr, "remote_endpoint": remote_addr, "is_up": is_up, "uptime": 0, "bytes_in": 0, "bytes_out": 0, "description": name, } except Exception as exc: logger.debug("OpenVPN instance fetch failed (plugin not installed?): %s", exc) # ── WireGuard ────────────────────────────────────────────────────── try: # /api/wireguard/service/show returns structured JSON: # {"total": N, "rows": [ # {"if":"wg0", "type":"interface", ...}, # {"if":"wg0", "type":"peer", "public-key":"...", # "endpoint":"1.2.3.4:51820", "transfer-rx":N, "transfer-tx":N, # "name":"HCQ", "peer-status":"online", # "latest-handshake-age":45, "ifname":"HCQ"}, ... # ]} data = self._get("/api/wireguard/service/show") rows = data.get("rows", []) if isinstance(data, dict) else [] wg_idx = 0 for row in rows: if row.get("type") != "peer": continue wg_idx += 1 endpoint = (row.get("endpoint") or "").strip() if endpoint and endpoint != "(none)": remote_ip = endpoint.rsplit(":", 1)[0].strip("[]") else: remote_ip = "" is_up = row.get("peer-status") == "online" bytes_in = int(row.get("transfer-rx") or 0) bytes_out = int(row.get("transfer-tx") or 0) hs_age = row.get("latest-handshake-age") uptime = int(hs_age) if hs_age else 0 description = ( (row.get("name") or "").strip() or (row.get("ifname") or "").strip() or f"wg-peer-{wg_idx}" ) tunnels[f"wireguard-{wg_idx}"] = { "type": "WireGuard", "local_endpoint": "", "remote_endpoint": remote_ip, "is_up": is_up, "uptime": uptime, "bytes_in": bytes_in, "bytes_out": bytes_out, "description": description, } except Exception as exc: logger.debug("WireGuard status fetch failed (plugin not installed?): %s", exc) return tunnels def get_available_updates(self) -> list[dict[str, Any]]: """Return the pending firmware and package updates the firewall last found. Reads the cached ``GET /api/core/firmware/status``; it triggers no check (that is :meth:`refresh_available_updates`). An empty list means the last check found nothing. :raises RuntimeError: when the firewall never checked or cannot reach its mirror -- never an empty list for "don't know". """ status = self._checked_firmware_status() if status.get("status") not in ("update", "upgrade"): return [] return sorted( ( { "name": u.get("name", ""), "current_version": u.get("current_version", u.get("version", "")), "new_version": u.get("new_version", u.get("version", "")), } for u in status.get("upgrade_packages") or status.get("updates") or [] ), key=lambda u: u["name"], ) def _checked_firmware_status(self) -> dict[str, Any]: status = self._get("/api/core/firmware/status") if not status.get("last_check"): raise RuntimeError("The firewall has not checked for updates yet") for field in ("connection", "repository"): if status.get(field, "ok") != "ok": raise RuntimeError(f"The firmware {field} is {status.get(field)!r}") return status def refresh_available_updates(self) -> dict[str, Any]: """Run the firewall's update check (``firmware/check``) and wait for it.""" before = self._get("/api/core/firmware/status").get("last_check") self._post("/api/core/firmware/check") for _ in range(_REFRESH_POLLS): time.sleep(_REFRESH_INTERVAL) status = self._get("/api/core/firmware/status") if status.get("last_check") and status.get("last_check") != before: return {"success": True, "output": status.get("status_msg", "")} return { "success": False, "output": f"The update check did not finish within {_REFRESH_POLLS * _REFRESH_INTERVAL} s", } def reboot_host(self) -> None: """Restart the firewall through its firmware API (``HostRebootMixin``). :raises RuntimeError: when the API refuses the request. """ try: self._post("/api/core/firmware/reboot") except Exception as exc: raise RuntimeError(f"The firewall refused to reboot: {exc}") from exc def get_host_status(self) -> dict[str, Any]: """Whether the firewall needs a reboot to finish an update; it does not patch itself as far as netOrk can tell.""" pending = self._get("/api/core/firmware/status").get("needs_reboot") == "1" return { "reboot_required": pending, "reboot_reason": "the firmware status reports a pending reboot" if pending else None, "auto_updates": None, } def apply_updates(self, packages: list[str]) -> dict[str, Any]: """Start the firmware upgrade of *packages*, or of everything for none. Returns once the firewall has taken the request; the upgrade runs on and :meth:`get_update_progress` follows it. Calls ``POST /api/core/firmware/upgrade``. """ try: if packages: result = self._post( "/api/core/firmware/upgrade", {"upgrade_packages": list(packages)} ) else: result = self._post("/api/core/firmware/upgrade") return {"success": True, "output": str(result)} except Exception as exc: logger.warning("Firmware upgrade failed: %s", exc) return {"success": False, "output": str(exc)} def get_update_progress(self) -> dict[str, Any]: """Where the firmware upgrade stands, and its whole log (``GET /api/core/firmware/upgradestatus``).""" st = self._get("/api/core/firmware/upgradestatus") status = st.get("status") or "" state = {"running": "running", "done": "done", "": "idle"}.get(status, "failed") return {"state": state, "log": st.get("log") or ""} def search_packages(self, query: str) -> list[dict[str, Any]]: """Search available OPNsense plugins by name or description. Filters the full plugin list from ``GET /api/core/firmware/info`` against *query* (case-insensitive substring match on name and comment). Returns all matching plugins with an ``installed`` flag so the UI can show which ones are already active. """ q = query.lower() info = self._get("/api/core/firmware/info") result: list[dict[str, Any]] = [] for p in info.get("plugin", []): name = p.get("name", "") comment = p.get("comment", "") if q in name.lower() or q in comment.lower(): result.append( { "name": name, "version": p.get("version", ""), "installed": p.get("installed") == "1", "description": comment, "size": 0, "source": "opnsense-plugins", } ) return sorted(result, key=lambda x: x["name"].lower()) def install_package(self, name: str) -> dict[str, Any]: """Install an OPNsense plugin by name. Calls ``POST /api/core/firmware/install/{name}``. """ import re as _re if not _re.match(r"^[a-zA-Z0-9_\-\.]+$", name): raise ValueError(f"Invalid package name: {name!r}") try: result = self._post(f"/api/core/firmware/install/{name}") return {"success": True, "output": str(result)} except Exception as exc: logger.warning("Package install failed: %s", exc) return {"success": False, "output": str(exc)} def uninstall_package(self, name: str) -> dict[str, Any]: """Remove an OPNsense plugin by name. Calls ``POST /api/core/firmware/remove/{name}``. **Plugins only, and it says so.** ``firmware/remove`` acts on the plugin set, and ``get_packages`` reads the same list — so software installed as a plain FreeBSD package is invisible to the one and unreachable by the other. The Wazuh agent is exactly that, on a driver the agent plugin lists as supported, and during a fleet-wide rollback the request posted for it could never have succeeded. Raising beats posting. A request that cannot work reports failure for the wrong reason and sends whoever reads it looking in the wrong place; netOrk turns ``NotImplementedError`` into a 501, which is the accurate answer. Reaching plain packages would need shell access, and the credentials stored for these devices are frequently API-key only — a decision of its own rather than a detail of this one. """ import re as _re if not _re.match(r"^[a-zA-Z0-9_\-\.]+$", name): raise ValueError(f"Invalid package name: {name!r}") if not name.startswith("os-"): raise NotImplementedError( f"{name!r} is not an OPNsense plugin. This driver can remove plugins " "(os-*) through the firmware API; a FreeBSD package needs shell access, " "which is not configured for OPNsense devices." ) try: result = self._post(f"/api/core/firmware/remove/{name}") return {"success": True, "output": str(result)} except Exception as exc: logger.warning("Package uninstall failed: %s", exc) return {"success": False, "output": str(exc)} # ── SNMP / Health ────────────────────────────────────────────────────────── def get_snmp_config(self): """Return SNMP config if the os-net-snmp plugin is installed and enabled. Uses GET /api/netsnmp/general/get (os-net-snmp plugin API). Returns None if the plugin is not installed or SNMP is disabled. """ try: from napalm_device_types.models import SNMPConfigDict except ImportError: return None try: # Short timeout — endpoint may not exist if os-net-snmp plugin is not installed url = self.base_url.rstrip("/") + "/api/netsnmp/general/get" response = self.session.get(url, timeout=5) if response.status_code != 200: return None data = response.json() except Exception as exc: logger.debug("SNMP config fetch failed (plugin not installed?): %s", exc) return None if not data: return None general = data.get("general", data) enabled = str(general.get("enabled", "0")) == "1" if not enabled: return None community = general.get("community", "public") or "public" return SNMPConfigDict(running=True, community=community, port=161, version="2c") # ── Wake-on-LAN ────────────────────────────────────────────────────────────── def send_wake_on_lan(self, mac_address: str, interface: str = "") -> dict[str, Any]: """Send a Wake-on-LAN magic packet via OPNsense's os-wol plugin. Calls ``POST /api/wol/wol/set`` with an ephemeral (non-persisted) entry — omitting ``uuid`` from the payload makes OPNsense's ``WolController::setAction`` validate and wake immediately without saving a host to config.xml. Requires the os-wol plugin to be installed and the target interface to have a static IPv4 address configured (OPNsense computes the broadcast address from the interface's own IP/subnet; DHCP-assigned interfaces are rejected). :param interface: OPNsense's *assigned* interface identifier (e.g. "lan", "opt1") — NOT the physical device name returned by get_interfaces()/ get_networks() (e.g. "em0"). Required by this driver. :raises ValueError: if interface is not provided. """ if not interface: raise ValueError("OPNsense requires an interface for Wake-on-LAN") try: result = self._post( "/api/wol/wol/set", {"wake": {"interface": interface, "mac": mac_address.strip()}}, ) except Exception as exc: logger.warning("Wake-on-LAN send failed for %s via %s: %s", mac_address, interface, exc) return {"success": False, "output": str(exc)} status = result.get("status") if status == "error": return { "success": False, "output": result.get("error_msg", "OPNsense rejected the Wake-on-LAN request"), } if not result: # Model validation (malformed MAC/interface) fails silently with an # empty {} response at HTTP 200 — no error_msg to surface. return { "success": False, "output": "OPNsense rejected the request (check interface identifier and MAC format)", } return {"success": True, "output": f"Magic packet sent to {mac_address} via {interface}"} # ------------------------------------------------------------------ # SNMP agent (napalm-device-types SnmpAgentMixin): the caller's values # ------------------------------------------------------------------ #: What marks a pass rule this driver added for an allowed SNMP source; the #: old any-source rule is recognised by its older description. _SNMP_RULE_PREFIX = "napalm-opnsense: SNMP from " _SNMP_RULE_OLD = "[netork] Allow SNMP" def _install_snmp_agent(self) -> FixStepOutcomeDict: # Installing over an installed os-net-snmp starts an async reinstall that # resets its configuration minutes later, so only when it is missing. try: chk = self._get("/api/netsnmp/general/get") except Exception: # noqa: BLE001 - the plugin's API is not there chk = {} if isinstance(chk, dict) and "general" in chk: return {"ok": True, "output": "os-net-snmp already installed"} result = self._post("/api/core/firmware/install/os-net-snmp") time.sleep(5) # the install settles before its API answers return {"ok": result.get("status") == "ok", "output": f"os-net-snmp install: {result}"} def _write_snmp_agent_config(self, settings: SnmpAgentSettingsDict) -> FixStepOutcomeDict: community = settings["community"] if not community or any(c.isspace() or c in '"#' for c in community): raise ValueError("the community must be one word without quotes or #") listen = settings.get("listen") or self.hostname try: listen_val = {str(ipaddress.ip_address(listen)): {"selected": 1}} except ValueError: listen_val = {} # a DNS name binds nothing; snmpd then listens everywhere result = self._post( "/api/netsnmp/general/set", { "general": { "enabled": "1", "community": community, "contact": settings.get("contact", ""), "location": settings.get("location", ""), "sysobjid": "", "bindip": "", "listen": listen_val, } }, ) if result.get("result") != "saved": return {"ok": False, "output": f"configuration refused: {result}"} return { "ok": True, "output": f"community set, listen {list(listen_val) or 'all addresses'}", } def _allow_snmp_sources(self, settings: SnmpAgentSettingsDict) -> FixStepOutcomeDict: sources = [ str(ipaddress.ip_network(s.strip(), strict=False)) for s in settings.get("allowed_sources", []) ] if not sources: return {"ok": True, "output": "no sources given; firewall unchanged"} found = self._post( "/api/firewall/filter/searchRule", {"current": 1, "rowCount": -1, "searchPhrase": "SNMP"}, ) for row in found.get("rows") or []: descr = str(row.get("description") or row.get("descr") or "") if descr == self._SNMP_RULE_OLD or descr.startswith(self._SNMP_RULE_PREFIX): self._post(f"/api/firewall/filter/delRule/{row['uuid']}") for source in sources: self._post( "/api/firewall/filter/addRule", { "rule": { "enabled": "1", "action": "pass", "quick": "1", "interface": "", # floating: wherever the source comes in "direction": "in", "ipprotocol": "inet6" if ":" in source else "inet", "protocol": "udp", "source_net": source, "source_not": "0", "destination_net": "(self)", "destination_not": "0", "destination_port": "161", "log": "0", "floating": "yes", "descr": f"{self._SNMP_RULE_PREFIX}{source}", } }, ) self._post("/api/firewall/filter/apply", {}) return {"ok": True, "output": f"udp/161 open for {', '.join(sources)} only"} def _start_snmp_agent(self) -> FixStepOutcomeDict: result = self._post("/api/netsnmp/service/restart") return {"ok": result.get("status") == "ok", "output": f"net-snmp restart: {result}"} def get_port_forwards(self) -> list[dict[str, Any]]: """Destination NAT on the WAN interfaces, as port forwards. Three reads: the rules, the interface overview (a WAN is an interface with an upstream gateway) and the aliases a rule may send to. The filtering and resolving is in :mod:`napalm_opnsense.port_forwards`. A box without the destination-NAT API (older than its MVC rework) raises: an empty list would claim "nothing forwarded", which nobody checked. """ rules = self._get("/api/firewall/d_nat/search_rule?current=1&rowCount=-1").get("rows", []) overview = self._get("/api/interfaces/overview/interfaces_info?current=1&rowCount=-1") aliases = self._get("/api/firewall/alias/searchItem?current=1&rowCount=-1").get("rows", []) return port_forwards(rules, wan_interfaces(overview), alias_index(aliases)) def get_firewall_aliases(self) -> list[dict[str, Any]]: """Return all firewall aliases, sorted by type then name. Each entry contains: * ``name`` — alias name * ``type`` — alias type (host, network, port, url, urltable, geoip, etc.) * ``description`` — human-readable description * ``content`` — list of values (IPs, networks, ports, URLs, …) * ``enabled`` — bool * ``counters`` — optional dict with packet/byte stats if available """ try: resp = self._get("/api/firewall/alias/searchItem?current=1&rowCount=-1") except Exception as exc: logger.warning("Failed to fetch firewall aliases: %s", exc) return [] rows = resp.get("rows") or [] result = [] for row in rows: content_raw = row.get("content", "") or "" # OPNsense stores content as newline-separated values content = [v.strip() for v in content_raw.splitlines() if v.strip()] result.append( { "name": row.get("name", ""), "type": row.get("type", ""), "description": row.get("description", "") or "", "content": content, "enabled": str(row.get("enabled", "1")) == "1", "proto": row.get("proto", "") or "", } ) return sorted(result, key=lambda x: (x["type"], x["name"].lower())) def get_firewall_rules(self) -> list[dict[str, Any]]: """Return all firewall filter rules with interface labels. Extra fields beyond NAPALM standard: * ``floating`` — bool, rule applies across all interfaces * ``interface_label`` — human-readable interface description * ``is_group`` — bool, interface is an interface group * ``gateway`` — the gateway a pass rule policy-routes to, or ``""``. Such a rule sends what it matches to that gateway, local destinations included, so it does not reach a host on another internal network. """ try: resp = self._get("/api/firewall/filter/searchRule?current=1&rowCount=-1") except Exception as exc: logger.warning("Failed to fetch firewall rules: %s", exc) return [] # OPNsense provides all human-readable values via %-prefixed fields — # no separate lookup needed for interface labels or category names. # Interface group names (to distinguish groups from plain interfaces) group_names: set = set() try: grp = self._get("/api/ifgroups/ifgroups/searchItem?current=1&rowCount=-1") for g in grp.get("rows") or []: n = g.get("ifname") or g.get("name") or "" if n: group_names.add(n) except Exception as exc: logger.warning("Failed to fetch interface groups: %s", exc) rows = resp.get("rows") or [] result = [] for row in rows: iface = row.get("interface", "") or "" iface_list = [i.strip() for i in iface.split(",") if i.strip()] # %interface already has the resolved friendly names (e.g. "MGMT, wgadmin") iface_label = (row.get("%interface") or "").strip() or ", ".join(iface_list) explicit_floating = ( str(row.get("floating", "0")) in ("1", "yes") or row.get("floating") is True ) floating = explicit_floating or len(iface_list) > 1 # %categories is the resolved category name; categories is the UUID category = (row.get("%categories") or row.get("category") or "").strip() # Use %-prefixed display values for source/destination source_net = (row.get("%source_net") or row.get("source_net") or "any").strip() destination_net = ( row.get("%destination_net") or row.get("destination_net") or "any" ).strip() result.append( { "uuid": row.get("uuid", ""), "sequence": int(row.get("sequence", 0) or 0), "action": row.get("action", "pass"), "quick": str(row.get("quick", "1")) == "1", "interface": iface, "interface_label": iface_label, "floating": floating, "is_group": len(iface_list) == 1 and iface_list[0] in group_names, "direction": row.get("direction", "in"), "ipprotocol": row.get("ipprotocol", "inet"), "protocol": row.get("protocol", "any") or "any", "source_net": source_net, "source_port": row.get("source_port", "") or "", "source_not": str(row.get("source_not", "0")) == "1", "destination_net": destination_net, "destination_port": row.get("destination_port", "") or "", "destination_not": str(row.get("destination_not", "0")) == "1", "description": row.get("description", "") or "", "log": str(row.get("log", "0")) == "1", "enabled": str(row.get("enabled", "1")) == "1", "category": category, "gateway": row.get("gateway", "") or "", } ) return sorted( result, key=lambda x: (x["floating"], x["is_group"], x["interface"], x["sequence"]) ) def apply_firewall_rule( self, rule: dict[str, Any], *, uuid: str | None = None ) -> dict[str, Any]: """Create or update a single OPNsense firewall filter rule. `rule` uses the vendor-neutral field names from ``napalm_device_types.models.FirewallRuleDict`` (see ``FirewallDriver.diff_firewall_rules``/``apply_firewall_ruleset``, the generic reconciliation engine that calls this method). This is the OPNsense-specific half: translating those fields into the ``/api/firewall/filter/addRule``/``setRule`` payload shape (string "1"/"0" booleans, empty ``interface`` means a floating rule — same payload shape as the SNMP self-provisioning rule in ``_action_fix_snmp``). """ payload = { "rule": { "enabled": "1" if rule.get("enabled", True) else "0", "sequence": "1", "action": rule.get("action", "pass"), "quick": "1" if rule.get("quick", True) else "0", "interface": rule.get("interface", "") or "", "direction": rule.get("direction", "in"), "ipprotocol": "inet", "protocol": rule.get("protocol", "any"), "source_net": rule.get("source_net", "any") or "any", "source_port": rule.get("source_port", "") or "", "destination_net": rule.get("destination_net", "any") or "any", "destination_port": rule.get("destination_port", "") or "", "log": "1" if rule.get("log", False) else "0", "floating": "yes" if not rule.get("interface") else "no", "descr": rule.get("description", ""), } } path = f"/api/firewall/filter/setRule/{uuid}" if uuid else "/api/firewall/filter/addRule" return self._post(path, payload) def commit_firewall_rules(self) -> dict[str, Any]: """Reload the firewall filter to activate pending rule changes. Final step after one or more `apply_firewall_rule()` calls — same as the last step of `_action_fix_snmp`. """ return self._post("/api/firewall/filter/apply", {}) # ------------------------------------------------------------------ # Hostname management # ------------------------------------------------------------------ def set_hostname(self, new_hostname: str) -> None: """Set the system hostname on OPNsense and regenerate the web GUI certificate. Accepts either a bare hostname or an FQDN (``host.domain``). When an FQDN is passed the domain part is also updated. Only sends the fields that need to change — never overwrites the full general config object to avoid accidental data loss. Requires OPNsense 22.x or later with the general settings REST API. Raises NotImplementedError on older versions that lack this endpoint. """ if "." in new_hostname: hostname, domain = new_hostname.split(".", 1) else: hostname = new_hostname domain = None payload: dict = {"general": {"hostname": hostname}} if domain: payload["general"]["domain"] = domain try: result = self._post("/api/core/general/set", payload) except Exception as exc: raise NotImplementedError( f"OPNsense at {self.hostname} does not support hostname management " "via REST API (requires OPNsense 22.x+). " "Please set the hostname manually via System → Settings → General " f"in the web UI. (Detail: {exc})" ) from exc logger.info("OPNsense set_hostname → %s (domain: %s): %s", hostname, domain, result) # ── 3. Regenerate web GUI TLS certificate ── self._regenerate_web_cert(hostname, domain) def _regenerate_web_cert(self, hostname: str, domain: str) -> None: """Attempt to regenerate the OPNsense web GUI self-signed certificate. Tries the configd helper first (OPNsense 22.x+), then falls back to the Trust API (older releases). Logs a warning if neither works — the operator should then regenerate the cert manually in the web GUI. """ fqdn = f"{hostname}.{domain}" if domain else hostname # Attempt 1: configd helper (most common on 22.x / 23.x / 24.x) try: self._post("/api/core/configd/generateRootCert", {}) logger.info("OPNsense web cert regenerated via configd (fqdn: %s)", fqdn) return except Exception as exc: logger.debug("configd/generateRootCert not available: %s", exc) # Attempt 2: Trust API — create a new internal self-signed cert try: # Fetch the list of internal CAs to find the right one ca_data = self._post("/api/trust/ca/search", {}) internal_ca_uuid = None for row in ca_data.get("rows") or []: if row.get("internal") == "1" or row.get("catype") == "internal": internal_ca_uuid = row.get("uuid") break cert_payload: dict = { "cert": { "descr": f"netork-generated for {fqdn}", "caref": internal_ca_uuid or "", "keytype": "RSA", "keylen": "2048", "digest_alg": "sha256", "lifetime": "825", "dn_commonname": fqdn, "dn_sans": fqdn, } } result = self._post("/api/trust/cert/generate", cert_payload) logger.info("OPNsense web cert generated via Trust API: %s", result) return except Exception as exc: logger.debug("Trust API cert generation failed: %s", exc) logger.warning( "OPNsense: could not regenerate web GUI certificate automatically after " "hostname change to %s — please regenerate it manually in the web GUI " "(System → Trust → Certificates).", fqdn, ) # ------------------------------------------------------------------ # DNS host overrides # ------------------------------------------------------------------ def _detect_active_dns(self) -> str | None: """Return 'unbound' or 'dnsmasq' depending on which service is running.""" try: data = self._get("/api/core/service/search") for row in data.get("rows", []): name = (row.get("name") or "").lower() if name in ("unbound", "dnsmasq") and row.get("running"): return name except Exception as exc: logger.warning("DNS service detection failed: %s", exc) return None def get_dns_entries(self) -> list[dict[str, Any]]: """Return DNS host-override entries from the active DNS service. Checks whether Unbound (DNS Resolver) or Dnsmasq (DNS Forwarder) is running and returns host overrides from whichever is active. If neither is running, returns an empty list. Each entry:: { "uuid": str, # OPNsense-internal UUID "hostname": str, # host part, e.g. "gw" "domain": str, # domain part, e.g. "home.example.com" "fqdn": str, # hostname.domain "ip": str, # IP address "record_type": str, # "A" | "AAAA" | "MX" | … "description": str, "enabled": bool, "service": str, # "unbound" | "dnsmasq" } """ service = self._detect_active_dns() if service == "unbound": return self._get_unbound_host_overrides() if service == "dnsmasq": return self._get_dnsmasq_host_overrides() return [] def _get_unbound_host_overrides(self) -> list[dict[str, Any]]: result: list[dict[str, Any]] = [] seen: set[tuple[str, str, str, str]] = set() try: data = self._post( "/api/unbound/settings/searchhostoverride", {"current": 1, "rowCount": -1, "searchPhrase": ""}, ) rows = data.get("rows", []) or [] # searchhostoverride lists alias records alongside the host override # they hang off, each under its own UUID. OPNsense flags them, so # skip them by that flag and report every remaining row as it is — # including two rows that happen to hold the same name and address. # Those are duplicates on the device, and sync_dns_zone can only # clear the copies it is told about. flagged = any("isAlias" in row for row in rows) for row in rows: if flagged and row.get("isAlias"): continue host = row.get("hostname", "") or "" domain = row.get("domain", "") or "" fqdn = f"{host}.{domain}" if host and domain else host or domain ip = row.get("server", "") or "" rr = row.get("rr", "A") or "A" if not flagged: # Releases without the flag give nothing to tell an alias # apart from a copy, so collapsing by content stays the # safer read there. key = (host.lower(), domain.lower(), ip, rr) if key in seen: continue seen.add(key) # The auto-PTR flag is "addptr" since 25.x, "ptrrecord" before. ptr = row.get("addptr", row.get("ptrrecord", "1")) result.append( { "uuid": row.get("uuid", ""), "hostname": host, "domain": domain, "fqdn": fqdn, "ip": ip, "record_type": rr, "description": row.get("description", "") or "", "enabled": str(row.get("enabled", "1")) == "1", "ptrrecord": str(ptr) == "1", "service": "unbound", } ) except Exception as exc: logger.warning("Failed to fetch Unbound host overrides: %s", exc) return sorted(result, key=lambda e: e["fqdn"].lower()) def _get_dnsmasq_host_overrides(self) -> list[dict[str, Any]]: result: list[dict[str, Any]] = [] try: data = self._post( "/api/dnsmasq/settings/searchhostoverride", {"current": 1, "rowCount": -1, "searchPhrase": ""}, ) for row in data.get("rows", []): host = row.get("host", "") or "" domain = row.get("domain", "") or "" fqdn = f"{host}.{domain}" if host and domain else host or domain result.append( { "uuid": row.get("uuid", ""), "hostname": host, "domain": domain, "fqdn": fqdn, "ip": row.get("ip", "") or "", "record_type": "A", "description": row.get("description", "") or "", "enabled": str(row.get("enabled", "1")) == "1", "service": "dnsmasq", } ) except Exception as exc: logger.warning("Failed to fetch Dnsmasq host overrides: %s", exc) return sorted(result, key=lambda e: e["fqdn"].lower()) def set_dns_entry(self, uuid: str, service: str, data: dict[str, Any]) -> dict[str, Any]: """Update a single DNS host-override entry and reconfigure the service. ``data`` keys: hostname, domain, ip, record_type, description, enabled. Returns the raw API response from the set call. """ if service == "unbound": payload = { "host": { "enabled": "1" if data.get("enabled", True) else "0", "hostname": data.get("hostname", ""), "domain": data.get("domain", ""), "rr": data.get("record_type", "A"), "server": data.get("ip", ""), "description": data.get("description", "") or "", "mxprio": "", "mx": "", } } resp = self._post(f"/api/unbound/settings/sethostoverride/{uuid}", payload) self._post("/api/unbound/service/reconfigure") elif service == "dnsmasq": payload = { "hostoverride": { "enabled": "1" if data.get("enabled", True) else "0", "host": data.get("hostname", ""), "domain": data.get("domain", ""), "ip": data.get("ip", ""), "description": data.get("description", "") or "", } } resp = self._post(f"/api/dnsmasq/settings/sethostoverride/{uuid}", payload) self._post("/api/dnsmasq/service/reconfigure") else: raise NotImplementedError(f"set_dns_entry not supported for service '{service}'") return resp _NETORK_TAG = "[netork]" def sync_dns_zone(self, zone_name: str, records: list[dict[str, Any]]) -> None: """Replace all netork-managed host overrides for *zone_name* with *records*. records items: {"hostname": str, "ip": str, "record_type": str, "enabled": bool} Auto-detects whether Unbound or Dnsmasq is active. """ zone_lower = zone_name.lower() if "in-addr.arpa" in zone_lower or "ip6.arpa" in zone_lower: raise ValueError( f"Refusing to provision reverse zone '{zone_name}' as OPNsense host overrides — " "PTR records must not be managed via the host override API." ) service = self._detect_active_dns() if not service: raise RuntimeError("No active DNS service (Unbound or Dnsmasq) detected") zone_clean = zone_name.rstrip(".") # Remove existing netork-managed overrides for this zone existing = ( self._get_unbound_host_overrides() if service == "unbound" else self._get_dnsmasq_host_overrides() ) for entry in existing: if entry["domain"].rstrip(".") == zone_clean and entry["description"].startswith( self._NETORK_TAG ): uid = entry["uuid"] if uid: if service == "unbound": self._post(f"/api/unbound/settings/delhostoverride/{uid}") else: self._post(f"/api/dnsmasq/settings/delhostoverride/{uid}") logger.info( "Removed %s host override %s.%s (uuid %s)", service, entry["hostname"], zone_clean, uid, ) # Re-add all enabled A/AAAA records, one override per logical record. # Two rows for the same name is a state netOrk's own database can # legitimately be in; the device must still end up with one. # An enabled override made by hand already answers for its record: the # caller's records include what it read off this device, and adding # those again under the tag left each one twice (NetOrk/netork#1123). added = 0 pushed: set[tuple[str, str, str]] = { (entry["hostname"].lower(), entry.get("record_type", "A"), entry["ip"]) for entry in existing if entry["domain"].rstrip(".") == zone_clean and not entry["description"].startswith(self._NETORK_TAG) and entry.get("enabled", True) } for rec in records: if not rec.get("enabled", True): continue rtype = rec.get("record_type", "A") if rtype not in ("A", "AAAA"): continue key = (str(rec.get("hostname", "")).lower(), rtype, str(rec.get("ip", ""))) if key in pushed: continue pushed.add(key) if service == "unbound": self._post( "/api/unbound/settings/addhostoverride", { "host": { "enabled": "1", "hostname": rec.get("hostname", ""), "domain": zone_clean, "rr": rtype, "server": rec.get("ip", ""), "description": self._NETORK_TAG, "addptr": "1", "ptrrecord": "1", "mxprio": "", "mx": "", } }, ) else: self._post( "/api/dnsmasq/settings/addhostoverride", { "hostoverride": { "enabled": "1", "host": rec.get("hostname", ""), "domain": zone_clean, "ip": rec.get("ip", ""), "description": self._NETORK_TAG, } }, ) added += 1 logger.info( "Added %s host override %s.%s → %s", service, rec.get("hostname"), zone_clean, rec.get("ip"), ) if service == "unbound": self._post("/api/unbound/service/reconfigure") else: self._post("/api/dnsmasq/service/reconfigure") logger.info("sync_dns_zone %s via %s: %d records provisioned", zone_clean, service, added) def deprovision_dns_zone(self, zone_name: str) -> None: """Remove all netork-managed host overrides for *zone_name*.""" self.sync_dns_zone(zone_name, [])