feat: public methods for the upgrade, Unbound forwards and OSPF (0.4.0)
CI / test (3.10) (push) Successful in 31s
CI / test (3.11) (push) Successful in 30s
CI / test (3.12) (push) Successful in 32s
CI / test (3.10) (pull_request) Successful in 30s
CI / test (3.11) (pull_request) Successful in 28s
CI / test (3.12) (pull_request) Successful in 39s

netOrk reached these through the private _get/_post (netOrk #1113); they
are napalm-device-types 4.17's contracts now:

- apply_updates passes the packages it is given (it dropped them) and
  get_update_progress reports the upgrade's state and log.
- get_dns_forwards, set_dns_forward (an existing forward of the domain
  is changed, not doubled) and remove_dns_forward, each applied with a
  reconfigure; a refusal is raised.
- get_ospf translates FRR's diagnostics into neutral terms.

Requires napalm-device-types 4.17.0.
This commit is contained in:
Christian Manivong committed 2026-10-18 15:04:03 +02:00
1 parent 7183da24fb
commit 7dccd0d1f8
5 files changed
+307 -11

No files matched your search

+26 -9
View File
@@ -68,6 +68,8 @@ from napalm.base.exceptions import ConnectionException, ConnectionClosedExceptio
from napalm_opnsense.ha import config_sync, dhcp_ha
from napalm_opnsense.listening import parse_socket_statistics, wireguard_listen_ports
from napalm_opnsense.ping_mixin import OPNsensePingMixin
from napalm_opnsense.ospf_mixin import OPNsenseOspfMixin
from napalm_opnsense.resolver_mixin import OPNsenseResolverMixin
from napalm_opnsense.port_forwards import alias_index, port_forwards, wan_interfaces
from napalm_opnsense.virtual_ips import carp_addresses
@@ -89,7 +91,14 @@ def _carp_master(entry: dict[str, Any]) -> bool:
return str(entry.get("status") or "").strip().upper() == "MASTER"
class OPNsenseDriver(OPNsensePingMixin, ConfigSyncMixin, SnmpAgentMixin, FirewallDriver):
class OPNsenseDriver(
OPNsensePingMixin,
ConfigSyncMixin,
OPNsenseResolverMixin,
OPNsenseOspfMixin,
SnmpAgentMixin,
FirewallDriver,
):
"""NAPALM driver for OPNsense (read-only, REST API)."""
VENDOR = "OPNsense"
@@ -2197,22 +2206,30 @@ class OPNsenseDriver(OPNsensePingMixin, ConfigSyncMixin, SnmpAgentMixin, Firewal
}
def apply_updates(self, packages: list[str]) -> dict[str, Any]:
"""Trigger a full firmware upgrade on OPNsense.
"""Start the firmware upgrade of *packages*, or of everything for none.
Note: OPNsense upgrades the entire system at once rather than
individual packages. The ``packages`` parameter is accepted for
API compatibility but is ignored — the full upgrade is always
applied.
Calls ``POST /api/core/firmware/upgrade``.
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:
result = self._post("/api/core/firmware/upgrade")
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.
+41
View File
@@ -0,0 +1,41 @@
"""FRR's OSPF state in neutral terms (napalm-device-types ``OspfMixin``).
Reads the os-frr plugin's diagnostics (``/api/quagga/diagnostics/...``); the
field names are FRR's. Whether an interface without neighbours or without
authentication is a problem is the caller's to decide.
"""
from __future__ import annotations
from typing import Any
from napalm_device_types import OspfMixin
_DIAG = "/api/quagga/diagnostics"
def _interface(ifc: dict[str, Any]) -> dict[str, Any]:
return {
"address": ifc.get("ipAddress") or "",
"prefix_length": int(ifc.get("ipAddressPrefixlen") or 0),
"area": ifc.get("area") or "",
"network_type": ifc.get("networkType") or "",
"authentication": ifc.get("authentication") or "",
"neighbors": int(ifc.get("nbrCount") or 0),
"neighbors_adjacent": int(ifc.get("nbrAdjacentCount") or 0),
"cost": int(ifc.get("cost") or 0),
}
class OPNsenseOspfMixin(OspfMixin):
"""Needs the driver's ``_get``."""
def get_ospf(self) -> dict[str, Any]:
"""Router ID, areas and OSPF per interface; empty when OSPF does not run."""
overview = self._get(f"{_DIAG}/ospfoverview").get("response") or {} # type: ignore[attr-defined]
interfaces = (self._get(f"{_DIAG}/ospfinterface").get("response") or {}).get("interfaces") or {} # type: ignore[attr-defined]
return {
"router_id": overview.get("routerId") or None,
"areas": list((overview.get("areas") or {}).keys()),
"interfaces": {name: _interface(ifc) for name, ifc in interfaces.items()},
}
+65
View File
@@ -0,0 +1,65 @@
"""Unbound's domain forwards (napalm-device-types ``DnsForwardMixin``).
OPNsense keeps forwards and DNS-over-TLS servers in one model (``dot``); a
forward is a row of type ``forward``. Every change is applied with a
reconfigure before the method returns.
"""
from __future__ import annotations
from typing import Any
from napalm_device_types import DnsForwardMixin
_SETTINGS = "/api/unbound/settings"
_RECONFIGURE = "/api/unbound/service/reconfigure"
def _domain(name: str) -> str:
return name.strip().rstrip(".")
class OPNsenseResolverMixin(DnsForwardMixin):
"""Needs the driver's ``_post``."""
def _forward_rows(self) -> list[dict[str, Any]]:
rows = self._post(f"{_SETTINGS}/searchForward", {}).get("rows") or [] # type: ignore[attr-defined]
return [r for r in rows if r.get("type", "forward") == "forward"]
def _saved(self, result: dict[str, Any], what: str) -> None:
if result.get("result") not in ("saved", "deleted", None):
raise RuntimeError(f"Unbound refused {what}: {result.get('validations') or result}")
def get_dns_forwards(self) -> list[dict[str, Any]]:
"""Every forward Unbound has, enabled or not; DNS-over-TLS servers are not forwards."""
return [
{
"domain": _domain(r.get("domain", "")),
"server": r.get("server", ""),
"port": int(r.get("port") or 53),
"enabled": str(r.get("enabled", "1")) == "1",
}
for r in self._forward_rows()
]
def set_dns_forward(self, domain: str, server: str, port: int = 53) -> None:
"""Forward *domain* to *server*; an existing forward of the domain is changed."""
name = _domain(domain)
row = {"enabled": "1", "type": "forward", "domain": name, "server": server, "port": str(port)}
known = [r for r in self._forward_rows() if _domain(r.get("domain", "")) == name]
if known:
result = self._post(f"{_SETTINGS}/setForward/{known[0]['uuid']}", {"dot": row}) # type: ignore[attr-defined]
else:
result = self._post(f"{_SETTINGS}/addForward", {"dot": row}) # type: ignore[attr-defined]
self._saved(result or {}, f"the forward of {name}")
self._post(_RECONFIGURE) # type: ignore[attr-defined]
def remove_dns_forward(self, domain: str) -> None:
"""Delete every forward of *domain*; nothing to do when there is none."""
name = _domain(domain)
uuids = [r["uuid"] for r in self._forward_rows() if _domain(r.get("domain", "")) == name and r.get("uuid")]
if not uuids:
return
for uuid in uuids:
self._post(f"{_SETTINGS}/delForward/{uuid}") # type: ignore[attr-defined]
self._post(_RECONFIGURE) # type: ignore[attr-defined]