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

get_interfaces_ip listed each interface's primary address only, so a CARP address
never showed as the holder's. It now lists an address bound to a VHID on the node
that is MASTER for it, and leaves it off a BACKUP -- as keepalived puts the address
on the master alone. netOrk's VIP badge sits on that interface (netOrk #1121).
2026-10-16 15:23:08 +02:00
christianmanivong 5a1aa66db4 Merge pull request 'fix: sync_dns_zone no longer copies an override made by hand' (#18) from fix/hand-made-override-copies into master
CI / test (3.10) (push) Successful in 36s
CI / test (3.11) (push) Successful in 35s
CI / test (3.12) (push) Successful in 33s
2026-10-16 09:55:28 +00:00
Christian Manivong 5b54174cc4 fix: sync_dns_zone no longer copies an override made by hand
CI / test (3.10) (push) Successful in 32s
CI / test (3.11) (push) Successful in 32s
CI / test (3.12) (push) Successful in 40s
CI / test (3.10) (pull_request) Successful in 38s
CI / test (3.11) (pull_request) Successful in 35s
CI / test (3.12) (pull_request) Successful in 38s
The records netOrk sends include what its poll read off the same
firewall, hand-made host overrides among them. sync_dns_zone deletes only
tagged overrides and then added every record again under the tag, so each
hand-made override ended up on the device twice (NetOrk/netork#1123).

An enabled, untagged override with the same name, type and address in the
zone now stands in for its record. A copy made earlier is tagged, so the
next sync deletes it and does not add it again.
2026-10-16 11:48:58 +02:00
christianmanivong 2af99995e9 Merge pull request 'feat: report the firewall's CARP addresses as virtual IPs' (#17) from feat/carp-virtual-ips into master
CI / test (3.10) (push) Successful in 30s
CI / test (3.11) (push) Successful in 28s
CI / test (3.12) (push) Successful in 31s
2026-10-16 09:21:22 +00:00
Christian Manivong c868d41322 refactor: the CARP state table lives with the driver that speaks CARP
CI / test (3.10) (push) Failing after 15s
CI / test (3.11) (push) Failing after 15s
CI / test (3.12) (push) Successful in 30s
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 29s
carp_virtual_ip() and its state table moved here from napalm-device-types
(4.10.1, NAPALM/napalm-device-types#39): under the agreed structure the base
package carries only the shape of the reading, and what a CARP state means is
protocol knowledge. Tests moved along (test_carp_states.py).
2026-10-16 11:11:45 +02:00
Christian Manivong 00172f7a69 feat: report the firewall's CARP addresses as virtual IPs
CI / test (3.10) (push) Successful in 30s
CI / test (3.11) (push) Successful in 29s
CI / test (3.12) (push) Successful in 30s
CI / test (3.10) (pull_request) Successful in 29s
CI / test (3.11) (pull_request) Successful in 28s
CI / test (3.12) (pull_request) Successful in 30s
get_virtual_ips() reads every address bound to a CARP VHID -- the CARP
address and IP aliases on its VHID alike -- from
/api/interfaces/overview/export, with this node's CARP state, so the reading
says who holds each address. interfaces/vip_settings would list the same
addresses with each VHID's CARP password; it is not read.

vip_manager and keepalived are Linux mechanisms and are None here. Needs
napalm-device-types 4.10.0 (carp_virtual_ip).

netOrk #1121.
2026-10-16 11:07:08 +02:00
christianmanivong e318c97c20 Merge pull request 'fix: get_config reads the backup as text, not JSON' (#12) from fix/get-config-xml into master
CI / test (3.10) (push) Successful in 37s
CI / test (3.11) (push) Successful in 37s
CI / test (3.12) (push) Successful in 39s
2026-10-11 16:35:58 +00:00
Christian Manivong 852c1d59df fix: get_config reads the backup as text, not JSON
CI / test (3.10) (push) Successful in 36s
CI / test (3.11) (push) Successful in 34s
CI / test (3.12) (push) Successful in 39s
CI / test (3.10) (pull_request) Successful in 32s
CI / test (3.11) (pull_request) Successful in 31s
CI / test (3.12) (pull_request) Successful in 33s
/api/core/backup/download/this answers with config.xml itself, but
get_config went through _get(), which calls .json(). That raised on
every real device, the except swallowed it, and "running" came back
empty. The unit tests replaced _get with a lambda, so they never saw it.

_get_text() returns the body as text. A failed read now raises instead
of returning an empty string, so the caller sees why it failed.

Closes #11
2026-10-11 18:35:44 +02:00
christianmanivong fa88850535 Merge pull request 'feat: report what listens, and which OPNsense service it is' (#10) from feat/listening-sockets into master
CI / test (3.10) (push) Successful in 29s
CI / test (3.11) (push) Successful in 27s
CI / test (3.12) (push) Successful in 29s
2026-10-07 05:24:06 +00:00
Christian Manivong 2fb1226734 feat: report what listens, and which OPNsense service it is
CI / test (3.10) (push) Successful in 40s
CI / test (3.11) (push) Successful in 30s
CI / test (3.12) (push) Successful in 31s
CI / test (3.10) (pull_request) Successful in 29s
CI / test (3.11) (pull_request) Successful in 28s
CI / test (3.12) (pull_request) Successful in 30s
get_listening_sockets reads /api/diagnostics/interface/get_socket_statistics:
netstat's sockets with sockstat's user, command and PID, collected by
configd as root, so no SSH is needed. A listening socket is one without a
peer (*:*); "*" is the any-address of the socket's family.

Which service: the socket's unit is a name of the firewall's own service
list, so netOrk can match it to the service. The command (cut to ten
characters by FreeBSD) matches a service name, the start of one, or one
of the daemons whose service is called otherwise (sshd is openssh, the
FRR daemons are frr, kea-ctrl-agent is kea-dhcp). lighttpd is the web UI,
or the captive portal when it runs as www. WireGuard's sockets belong to
the kernel and are told by the listen ports of /api/wireguard/service/show.

The fixture is a real OPNsense 26.7.5 firewall's answer, cut down, with
documentation addresses. For netOrk#673.
2026-10-07 07:20:27 +02:00
christianmanivong 8ce7ea52e7 Merge pull request 'feat: restart the firewall (reboot_host)' (#9) from feat/reboot-host into master
CI / test (3.10) (push) Successful in 30s
CI / test (3.11) (push) Successful in 34s
CI / test (3.12) (push) Successful in 41s
2026-10-06 11:05:44 +00:00
Christian Manivong a6c1d77791 feat: restart the firewall (reboot_host)
CI / test (3.10) (push) Successful in 36s
CI / test (3.11) (push) Successful in 30s
CI / test (3.12) (push) Successful in 34s
CI / test (3.10) (pull_request) Successful in 33s
CI / test (3.11) (pull_request) Successful in 33s
CI / test (3.12) (pull_request) Successful in 33s
netOrk asks a driver for reboot_host before it offers a restart, and the
OPNsense driver had none, so a firewall could not be restarted from netOrk
(netOrk #637). reboot_host asks the firmware API (POST
/api/core/firmware/reboot); a refusal raises.
2026-10-06 13:05:27 +02:00
christianmanivong b3e89f003c Merge pull request 'feat: read pending updates from the cached firmware status, and run the check on request' (#8) from feat/cached-update-reader into master
CI / test (3.10) (push) Successful in 28s
CI / test (3.11) (push) Successful in 28s
CI / test (3.12) (push) Successful in 29s
2026-10-05 22:20:11 +00:00
13 changed files with 2042 additions and 23 deletions

No files matched your search

+4 -2
View File
@@ -71,9 +71,9 @@ arguments.
| Getter | Status | OPNsense API Endpoint |
|---|---|---|
| `get_facts` | ✅ | `GET /api/core/system/status` |
| `get_facts` | ✅ | `GET /api/diagnostics/system/system_information` (name, version) + `GET /api/core/system/status` (fallback on older versions) |
| `get_interfaces` | ✅ | `GET /api/interfaces/overview/export` |
| `get_interfaces_ip` | ✅ | `GET /api/interfaces/addresses/export` |
| `get_interfaces_ip` | ✅ | `GET /api/interfaces/overview/export`; a CARP address only on the node that is MASTER for it |
| `get_interfaces_counters` | ✅ | `GET /api/diagnostics/interface/get_interface_statistics` |
| `get_arp_table` | ✅ | `GET /api/diagnostics/interface/get_arp` |
| `get_ipv6_neighbors_table` | ✅ | `GET /api/diagnostics/interface/get_ndp` |
@@ -88,6 +88,8 @@ arguments.
| `get_vlans` | ✅ | `GET /api/interfaces/vlan_settings/search_item` |
| `get_mac_address_table` | ❌ | Not applicable (firewall, no L2 switching) |
| `ping` | ✅ | `POST /api/diagnostics/ping/set` + `start` + `search_jobs` + `stop`/`remove` |
| `get_listening_sockets` | ✅ | `GET /api/diagnostics/interface/get_socket_statistics` + `/api/core/service/search` + `/api/wireguard/service/show` (WireGuard's kernel sockets by listen port) |
| `get_virtual_ips` | ✅ | `GET /api/interfaces/overview/export`: every address bound to a CARP VHID (CARP address and IP aliases on it), with this node's CARP state. `interfaces/vip_settings` is not read: it carries the CARP passwords |
| `ping_sweep` | ✅ ³ | same endpoints, one batch of parallel jobs at a time |
> ¹ Requires the `os-lldpd` plugin. Returns empty dict if the plugin is not installed.
+128
View File
@@ -0,0 +1,128 @@
"""What listens on the firewall, and which OPNsense service it is. Pure: no I/O.
``/api/diagnostics/interface/get_socket_statistics`` is netstat's socket list
with sockstat's user, command and PID merged in, read by configd as root. The
contract this serves -- ``napalm_device_types.models.ListeningSocketsDict`` --
wants the sockets that listen: a TCP socket in LISTEN and a bound UDP socket,
both without a peer (``*:*``).
**Which service.** netOrk keeps a firewall's services under the names of its
service list (``/api/core/service/search``), so a socket's ``unit`` is one of
those names, or None. The statistics name the process, cut to ten characters
by FreeBSD (``mdns-repea``): a name the service list has wins, then one the
command starts with or -- cut short -- is the start of, then the few daemons
whose service is called otherwise (``sshd`` is ``openssh``, ``ospfd`` is
``frr``). The admin UI and the captive portal are both lighttpd; the portal's
runs as ``www``.
**WireGuard has no process.** Its sockets belong to the kernel (``??``) and are
told by the listen ports of the WireGuard interfaces.
"""
from __future__ import annotations
from typing import Any, Dict, Iterable, List, Optional, Set
from napalm_device_types.models import ListeningSocketDict
#: FRR's daemons, all under the one service ``frr``.
_FRR_DAEMONS = "zebra mgmtd staticd ospfd ospf6d bgpd bfdd ripd ripngd isisd pimd watchfrr".split()
#: Daemons whose OPNsense service has another name, by the command as reported.
_SERVICE_OF: Dict[str, str] = {
"sshd": "openssh",
"kea-ctrl-a": "kea-dhcp",
"kea-ctrl-agent": "kea-dhcp",
"charon": "strongswan",
**{daemon: "frr" for daemon in _FRR_DAEMONS},
}
#: FreeBSD cuts a command at this many characters in the socket list.
_COMMAND_LENGTH = 10
#: Below this length a service name is too short to match a command's start (``pf``).
_PREFIX_MIN = 4
_NOBODY = "??"
_PEERLESS = "*"
def _service_of(command: str, user: str, services: Set[str]) -> Optional[str]:
if command == "lighttpd":
name = "captiveportal" if user == "www" else "webgui"
return name if name in services else None
if command in services:
return command
alias = _SERVICE_OF.get(command)
if alias in services:
return alias
matches = [
name
for name in services
if (len(name) >= _PREFIX_MIN and command.startswith(name))
or (len(command) == _COMMAND_LENGTH and name.startswith(command))
]
return matches[0] if len(matches) == 1 else None
def _address(protocol: str, address: str) -> tuple:
"""``(address, interface)``: ``*`` is every address of the socket's family."""
if address == "*":
return ("::" if protocol.endswith("6") else "0.0.0.0"), None
host, _, zone = address.partition("%")
return host, zone or None
def _socket(
entry: Dict[str, Any], services: Set[str], wireguard_ports: Set[int]
) -> Optional[ListeningSocketDict]:
protocol = str(entry.get("protocol") or "")
proto = protocol[:3]
local, remote = entry.get("local") or {}, entry.get("remote") or {}
port = str(local.get("port") or "")
if proto not in ("tcp", "udp") or not port.isdigit():
return None
if remote.get("address") != _PEERLESS or remote.get("port") != _PEERLESS:
return None
command, pid = str(entry.get("command") or _NOBODY), str(entry.get("pid") or _NOBODY)
process = None if command == _NOBODY else command
if process is not None:
unit = _service_of(process, str(entry.get("user") or ""), services)
elif proto == "udp" and int(port) in wireguard_ports and "wireguard" in services:
unit = "wireguard"
else:
unit = None
address, interface = _address(protocol, str(local.get("address") or ""))
return {
"proto": proto,
"address": address,
"port": int(port),
"interface": interface,
"process": process,
"pid": int(pid) if pid.isdigit() else None,
"unit": unit,
"container_id": None,
}
def wireguard_listen_ports(show: Dict[str, Any]) -> Set[int]:
"""The listen ports of the WireGuard interfaces in ``/api/wireguard/service/show``."""
ports: Set[int] = set()
for row in show.get("rows") or []:
port = str(row.get("listen-port") or "")
if row.get("type") == "interface" and port.isdigit():
ports.add(int(port))
return ports
def parse_socket_statistics(
statistics: Dict[str, Any], services: Iterable[str], wireguard_ports: Set[int]
) -> List[ListeningSocketDict]:
"""The listening sockets of ``get_socket_statistics``, sorted by protocol,
port and address. Every section is read: the UNIX sockets have no ``local``."""
names = set(services)
sockets: List[ListeningSocketDict] = []
for section in (statistics.get("statistics") or {}).values():
for entry in (section or {}).values() if isinstance(section, dict) else ():
socket = _socket(entry, names, wireguard_ports) if isinstance(entry, dict) else None
if socket is not None:
sockets.append(socket)
return sorted(
sockets, key=lambda s: (s["proto"], s["port"], s["address"], s["interface"] or "")
)
+135 -17
View File
@@ -38,6 +38,7 @@ from __future__ import annotations
import difflib
import json
import logging
import re
import socket
import time
from ipaddress import ip_address, ip_network
@@ -53,10 +54,30 @@ import requests
from requests.exceptions import RequestException
from napalm_device_types import FingerprintRule, FirewallDriver
from napalm_device_types.models import ListeningSocketsDict, VirtualIpsDict
from napalm.base.exceptions import ConnectionException, ConnectionClosedException, MergeConfigException
from napalm_opnsense.listening import parse_socket_statistics, wireguard_listen_ports
from napalm_opnsense.ping_mixin import OPNsensePingMixin
from napalm_opnsense.port_forwards import alias_index, port_forwards, wan_interfaces
from napalm_opnsense.virtual_ips import carp_addresses
_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, FirewallDriver):
@@ -170,6 +191,22 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
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.
@@ -192,16 +229,33 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
def get_facts(self) -> dict[str, Any]:
"""Return a dictionary of general device facts.
Calls ``GET /api/core/system/status``.
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 = {}
hostname = status.get("hostname") or status.get("name") or ""
version = status.get("version") or status.get("product_version") or "unknown"
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())
@@ -213,7 +267,7 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
"vendor": self.VENDOR,
"model": status.get("model") or self.VENDOR,
"hostname": hostname,
"fqdn": hostname,
"fqdn": fqdn,
"os_version": version,
"serial_number": status.get("serial") or "",
"uptime": status.get("uptime", -1),
@@ -263,6 +317,10 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
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::
{
@@ -298,8 +356,14 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
_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
@@ -307,6 +371,8 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
_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
@@ -833,22 +899,19 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
the JSON-serialised staged routes when a candidate has been loaded,
or an empty string otherwise.
Calls ``GET /api/core/backup/download/this``.
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"):
try:
response = self._get("/api/core/backup/download/this")
xml_text: str = (
response if isinstance(response, str) else str(response)
)
if retrieve in ("all", "running"):
configs["running"] = xml_text
if retrieve in ("all", "startup"):
configs["startup"] = xml_text
except Exception as exc:
logger.warning("Failed to fetch running/startup config: %s", exc)
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)
@@ -1789,6 +1852,42 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
})
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 manage_service(self, name: str, action: str) -> dict[str, Any]:
"""Execute a lifecycle action on an OPNsense service.
@@ -2050,6 +2149,16 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
"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."""
@@ -2871,8 +2980,17 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
# 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]] = set()
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
+124
View File
@@ -0,0 +1,124 @@
"""The firewall's CARP addresses, read from its interface overview (netOrk #1121).
``/api/interfaces/overview/export`` lists every address of an interface; one bound
to a CARP VHID carries ``vhid`` and this node's CARP ``status``. That covers the
CARP address itself and an IP alias bound to its VHID alike -- both float with the
VHID's MASTER. An address without a VHID stays on this node and is no virtual IP.
``interfaces/vip_settings/search_item`` would list the same addresses, but with
each VHID's CARP password in it. The overview has none, so a secret never reaches
the caller.
What a state means -- MASTER holds, BACKUP stands by -- is CARP protocol
knowledge, so it lives here with the driver that speaks CARP
(:func:`carp_virtual_ip`), not in napalm-device-types, which carries only the
shape of the reading.
"""
from __future__ import annotations
import ipaddress
from typing import Any, Optional
from napalm_device_types.models import VirtualIpDict
#: CARP's state of a VHID on one member -> (running, holds).
_CARP_STATES: dict[str, tuple[bool, bool]] = {
"MASTER": (True, True),
"BACKUP": (True, False),
"INIT": (False, False),
"DISABLED": (False, False),
}
def _address(text: str) -> Optional[tuple[str, Optional[int]]]:
"""``"10.0.0.1/24"`` -> ``("10.0.0.1", 24)``; None for what is no address."""
host, slash, prefix = text.partition("/")
try:
address = ipaddress.ip_address(host)
except ValueError:
return None
if not slash:
return str(address), None
if not prefix.isdigit() or int(prefix) > address.max_prefixlen:
return None
return str(address), int(prefix)
def carp_virtual_ip(
*,
address: str,
prefix_length: Optional[int],
vhid: object,
state: Optional[str],
instance: str,
interface: Optional[str],
) -> Optional[VirtualIpDict]:
"""One address bound to a CARP VHID, as a virtual IP; None for what is no address.
Who holds it comes from CARP's *state* on this member, not from its
interfaces: MASTER holds it, BACKUP stands by, INIT and DISABLED neither run
nor hold. A state this does not know says nothing (``running`` and ``holds``
None), and the caller falls back on the interfaces. *address* may carry its
prefix (``"10.0.0.1/24"``), which then wins over *prefix_length*.
"""
parsed = _address(address.strip()) if address else None
if parsed is None:
return None
normalised, written = parsed
prefix = written if written is not None else prefix_length
if prefix is not None and not 0 <= prefix <= ipaddress.ip_address(normalised).max_prefixlen:
prefix = None
vhid_text = str(vhid).strip() if vhid is not None else ""
running, holds = _CARP_STATES.get((state or "").strip().upper(), (None, None))
return {
"mechanism": "carp",
"instance": instance or f"vhid {vhid_text}",
"address": normalised,
"prefix_length": prefix,
"interface": interface,
"group_key": f"vhid:{vhid_text}" if vhid_text.isdigit() else None,
"running": running,
"holds": holds,
}
def _interfaces(overview: Any) -> list[Any]:
"""The interface list, whether the answer is the list or wraps it."""
if isinstance(overview, dict):
return overview.get("interfaces") or []
if isinstance(overview, list):
return overview
raise ValueError(f"unexpected interface overview: {type(overview).__name__}")
def carp_addresses(overview: Any) -> list[VirtualIpDict]:
"""Every address bound to a CARP VHID in *overview*, sorted by address.
:raises ValueError: when the answer is neither the interface list nor wraps it.
"""
entries: list[VirtualIpDict] = []
for interface in _interfaces(overview):
if not isinstance(interface, dict):
continue
identifier = interface.get("identifier") or ""
for family in ("ipv4", "ipv6"):
for address in interface.get(family) or []:
if not isinstance(address, dict) or not address.get("vhid"):
continue
entry = carp_virtual_ip(
address=str(address.get("ipaddr") or ""),
prefix_length=None,
vhid=address.get("vhid"),
state=address.get("status"),
instance=interface.get("description") or identifier,
interface=interface.get("device") or identifier or None,
)
if entry is not None:
entries.append(entry)
return sorted(entries, key=_order)
def _order(entry: VirtualIpDict) -> tuple[int, int]:
address = ipaddress.ip_address(entry["address"])
return address.version, int(address)
+1 -1
View File
@@ -24,7 +24,7 @@ classifiers = [
]
dependencies = [
"napalm>=4.0.0",
"napalm_device_types>=0.2.0",
"napalm_device_types>=4.10.1",
"requests>=2.28.0",
]
@@ -0,0 +1,62 @@
[
{
"identifier": "lan",
"device": "em0",
"description": "MGMT",
"addr4": "192.0.2.2/24",
"ipv4": [
{"ipaddr": "192.0.2.2/24"},
{"ipaddr": "192.0.2.1/24", "vhid": "108", "status": "MASTER", "advbase": "1", "advskew": "0", "peer": "224.0.0.18", "peer6": "ff02::12"}
],
"ipv6": [
{"ipaddr": "fe80::1:2/64"},
{"ipaddr": "2001:db8:8::1/64", "vhid": "108", "status": "MASTER", "advbase": "1", "advskew": "0", "peer": "224.0.0.18", "peer6": "ff02::12"}
]
},
{
"identifier": "opt2",
"device": "vlan0040",
"description": "SERVER",
"addr4": "198.51.100.2/24",
"ipv4": [
{"ipaddr": "198.51.100.2/24"},
{"ipaddr": "198.51.100.140/32", "vhid": "140", "status": "MASTER", "advbase": "1", "advskew": "0", "peer": "224.0.0.18", "peer6": "ff02::12"},
{"ipaddr": "198.51.100.1/24", "vhid": "140", "status": "MASTER", "advbase": "1", "advskew": "0", "peer": "224.0.0.18", "peer6": "ff02::12"}
]
},
{
"identifier": "opt9",
"device": "vlan0011",
"description": "",
"addr4": "203.0.113.2/24",
"ipv4": [
{"ipaddr": "203.0.113.2/24"},
{"ipaddr": "203.0.113.1/24", "vhid": "111", "status": "INIT", "advbase": "1", "advskew": "0", "peer": "224.0.0.18", "peer6": "ff02::12"}
]
},
{
"identifier": "opt3",
"device": "vlan0020",
"description": "IOT",
"addr4": "192.0.2.130/25",
"ipv4": [
{"ipaddr": "192.0.2.130/25"},
{"ipaddr": "192.0.2.140/32"}
]
},
{
"identifier": "lo0",
"device": "lo0",
"description": "Loopback",
"addr4": "127.0.0.1/8",
"ipv4": [{"ipaddr": "127.0.0.1/8"}],
"ipv6": [{"ipaddr": "::1/128"}, {"ipaddr": "fe80::1/64"}]
},
{
"identifier": "wg1",
"device": "wg1",
"description": "WireGuard",
"addr4": "",
"ipv4": null
}
]
+935
View File
@@ -0,0 +1,935 @@
{
"statistics": {
"Active Internet connections": {
"tcp4/[192.0.2.1:443-192.0.2.50:38544]": {
"protocol": "tcp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "192.0.2.1",
"port": "443",
"0": "192.0.2.1:443"
},
"remote": {
"address": "192.0.2.50",
"port": "38544",
"0": "192.0.2.50:38544"
},
"receive-high-water": 65700,
"send-high-water": 65700,
"receive-low-water": 1,
"send-low-water": 2048,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 525600,
"send-mbuf-bytes-max": 525600,
"retransmit-timer": "0.00",
"persist-timer": "0.00",
"keepalive-timer": "7199.77",
"msl2-timer": "0.00",
"delay-ack-timer": "0.00",
"inactivity-timer": "0.02",
"flow-id": "f6ae054e",
"flow-type": 130,
"user": "root",
"command": "lighttpd",
"pid": "46564",
"fd": "19",
"proto": "tcp4"
},
"tcp4/[192.0.2.1:443-*:*]": {
"protocol": "tcp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "192.0.2.1",
"port": "443",
"0": "192.0.2.1:443"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 0,
"send-high-water": 0,
"receive-low-water": 0,
"send-low-water": 0,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 0,
"send-mbuf-bytes-max": 0,
"retransmit-timer": "0.00",
"persist-timer": "0.00",
"keepalive-timer": "0.00",
"msl2-timer": "0.00",
"delay-ack-timer": "0.00",
"inactivity-timer": "33552.00",
"flow-id": "0",
"flow-type": 0,
"user": "root",
"command": "lighttpd",
"pid": "46564",
"fd": "11",
"proto": "tcp4"
},
"tcp4/[198.51.100.1:443-*:*]": {
"protocol": "tcp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "198.51.100.1",
"port": "443",
"0": "198.51.100.1:443"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 0,
"send-high-water": 0,
"receive-low-water": 0,
"send-low-water": 0,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 0,
"send-mbuf-bytes-max": 0,
"retransmit-timer": "0.00",
"persist-timer": "0.00",
"keepalive-timer": "0.00",
"msl2-timer": "0.00",
"delay-ack-timer": "0.00",
"inactivity-timer": "33552.00",
"flow-id": "0",
"flow-type": 0,
"user": "root",
"command": "lighttpd",
"pid": "46564",
"fd": "10",
"proto": "tcp4"
},
"tcp4/[127.0.0.1:443-*:*]": {
"protocol": "tcp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "127.0.0.1",
"port": "443",
"0": "127.0.0.1:443"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 0,
"send-high-water": 0,
"receive-low-water": 0,
"send-low-water": 0,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 0,
"send-mbuf-bytes-max": 0,
"retransmit-timer": "0.00",
"persist-timer": "0.00",
"keepalive-timer": "0.00",
"msl2-timer": "0.00",
"delay-ack-timer": "0.00",
"inactivity-timer": "33552.00",
"flow-id": "0",
"flow-type": 0,
"user": "root",
"command": "lighttpd",
"pid": "46564",
"fd": "7",
"proto": "tcp4"
},
"tcp4/[192.0.2.1:80-*:*]": {
"protocol": "tcp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "192.0.2.1",
"port": "80",
"0": "192.0.2.1:80"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 0,
"send-high-water": 0,
"receive-low-water": 0,
"send-low-water": 0,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 0,
"send-mbuf-bytes-max": 0,
"retransmit-timer": "0.00",
"persist-timer": "0.00",
"keepalive-timer": "0.00",
"msl2-timer": "0.00",
"delay-ack-timer": "0.00",
"inactivity-timer": "33552.00",
"flow-id": "0",
"flow-type": 0,
"user": "root",
"command": "lighttpd",
"pid": "46564",
"fd": "16",
"proto": "tcp4"
},
"tcp4/[*:8000-*:*]": {
"protocol": "tcp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "*",
"port": "8000",
"0": "*:8000"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 0,
"send-high-water": 0,
"receive-low-water": 0,
"send-low-water": 0,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 0,
"send-mbuf-bytes-max": 0,
"retransmit-timer": "0.00",
"persist-timer": "0.00",
"keepalive-timer": "0.00",
"msl2-timer": "0.00",
"delay-ack-timer": "0.00",
"inactivity-timer": "41043.28",
"flow-id": "0",
"flow-type": 0,
"user": "www",
"command": "lighttpd",
"pid": "7612",
"fd": "7",
"proto": "tcp4",
"listen-queue-sizes": {
"qlen": "0",
"incqlen": "0",
"maxqlen": "128"
}
},
"tcp6/[*:8000-*:*]": {
"protocol": "tcp6",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "*",
"port": "8000",
"0": "*:8000"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 0,
"send-high-water": 0,
"receive-low-water": 0,
"send-low-water": 0,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 0,
"send-mbuf-bytes-max": 0,
"retransmit-timer": "0.00",
"persist-timer": "0.00",
"keepalive-timer": "0.00",
"msl2-timer": "0.00",
"delay-ack-timer": "0.00",
"inactivity-timer": "41043.28",
"flow-id": "0",
"flow-type": 0,
"user": "www",
"command": "lighttpd",
"pid": "7612",
"fd": "4",
"proto": "tcp6",
"listen-queue-sizes": {
"qlen": "0",
"incqlen": "0",
"maxqlen": "128"
}
},
"tcp4/[192.0.2.1:8080-*:*]": {
"protocol": "tcp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "192.0.2.1",
"port": "8080",
"0": "192.0.2.1:8080"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 0,
"send-high-water": 0,
"receive-low-water": 0,
"send-low-water": 0,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 0,
"send-mbuf-bytes-max": 0,
"retransmit-timer": "0.00",
"persist-timer": "0.00",
"keepalive-timer": "0.00",
"msl2-timer": "0.00",
"delay-ack-timer": "0.00",
"inactivity-timer": "21096.55",
"flow-id": "0",
"flow-type": 0,
"user": "root",
"command": "crowdsec",
"pid": "90423",
"fd": "26",
"proto": "tcp4"
},
"tcp4/[127.0.0.1:6060-*:*]": {
"protocol": "tcp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "127.0.0.1",
"port": "6060",
"0": "127.0.0.1:6060"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 0,
"send-high-water": 0,
"receive-low-water": 0,
"send-low-water": 0,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 0,
"send-mbuf-bytes-max": 0,
"retransmit-timer": "0.00",
"persist-timer": "0.00",
"keepalive-timer": "0.00",
"msl2-timer": "0.00",
"delay-ack-timer": "0.00",
"inactivity-timer": "41044.32",
"flow-id": "0",
"flow-type": 0,
"user": "root",
"command": "crowdsec",
"pid": "90423",
"fd": "20",
"proto": "tcp4"
},
"tcp4/[192.0.2.1:10050-*:*]": {
"protocol": "tcp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "192.0.2.1",
"port": "10050",
"0": "192.0.2.1:10050"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 0,
"send-high-water": 0,
"receive-low-water": 0,
"send-low-water": 0,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 0,
"send-mbuf-bytes-max": 0,
"retransmit-timer": "0.00",
"persist-timer": "0.00",
"keepalive-timer": "0.00",
"msl2-timer": "0.00",
"delay-ack-timer": "0.00",
"inactivity-timer": "41044.31",
"flow-id": "0",
"flow-type": 0,
"user": "zabbix",
"command": "zabbix_age",
"pid": "70051",
"fd": "5",
"proto": "tcp4"
},
"tcp4/[192.0.2.1:22-*:*]": {
"protocol": "tcp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "192.0.2.1",
"port": "22",
"0": "192.0.2.1:22"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 0,
"send-high-water": 0,
"receive-low-water": 0,
"send-low-water": 0,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 0,
"send-mbuf-bytes-max": 0,
"retransmit-timer": "0.00",
"persist-timer": "0.00",
"keepalive-timer": "0.00",
"msl2-timer": "0.00",
"delay-ack-timer": "0.00",
"inactivity-timer": "41054.14",
"flow-id": "0",
"flow-type": 0,
"user": "root",
"command": "sshd",
"pid": "12554",
"fd": "6",
"proto": "tcp4"
},
"tcp4/[127.0.0.1:22-*:*]": {
"protocol": "tcp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "127.0.0.1",
"port": "22",
"0": "127.0.0.1:22"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 0,
"send-high-water": 0,
"receive-low-water": 0,
"send-low-water": 0,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 0,
"send-mbuf-bytes-max": 0,
"retransmit-timer": "0.00",
"persist-timer": "0.00",
"keepalive-timer": "0.00",
"msl2-timer": "0.00",
"delay-ack-timer": "0.00",
"inactivity-timer": "41054.14",
"flow-id": "0",
"flow-type": 0,
"user": "root",
"command": "sshd",
"pid": "12554",
"fd": "11",
"proto": "tcp4"
},
"tcp4/[203.0.113.1:53-*:*]": {
"protocol": "tcp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "203.0.113.1",
"port": "53",
"0": "203.0.113.1:53"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 0,
"send-high-water": 0,
"receive-low-water": 0,
"send-low-water": 0,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 0,
"send-mbuf-bytes-max": 0,
"retransmit-timer": "0.00",
"persist-timer": "0.00",
"keepalive-timer": "0.00",
"msl2-timer": "0.00",
"delay-ack-timer": "0.00",
"inactivity-timer": "9830.17",
"flow-id": "0",
"flow-type": 0,
"user": "unbound",
"command": "unbound",
"pid": "10992",
"fd": "116",
"proto": "tcp4"
},
"tcp4/[127.0.0.1:953-*:*]": {
"protocol": "tcp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "127.0.0.1",
"port": "953",
"0": "127.0.0.1:953"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 0,
"send-high-water": 0,
"receive-low-water": 0,
"send-low-water": 0,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 0,
"send-mbuf-bytes-max": 0,
"retransmit-timer": "0.00",
"persist-timer": "0.00",
"keepalive-timer": "0.00",
"msl2-timer": "0.00",
"delay-ack-timer": "0.00",
"inactivity-timer": "9830.17",
"flow-id": "0",
"flow-type": 0,
"user": "unbound",
"command": "unbound",
"pid": "10992",
"fd": "117",
"proto": "tcp4"
},
"tcp4/[127.0.0.1:8000-*:*]": {
"protocol": "tcp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "127.0.0.1",
"port": "8000",
"0": "127.0.0.1:8000"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 0,
"send-high-water": 0,
"receive-low-water": 0,
"send-low-water": 0,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 0,
"send-mbuf-bytes-max": 0,
"retransmit-timer": "0.00",
"persist-timer": "0.00",
"keepalive-timer": "0.00",
"msl2-timer": "0.00",
"delay-ack-timer": "0.00",
"inactivity-timer": "10006.15",
"flow-id": "0",
"flow-type": 0,
"user": "root",
"command": "kea-ctrl-a",
"pid": "14979",
"fd": "7",
"proto": "tcp4"
},
"tcp4/[127.0.0.1:2604-*:*]": {
"protocol": "tcp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "127.0.0.1",
"port": "2604",
"0": "127.0.0.1:2604"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 0,
"send-high-water": 0,
"receive-low-water": 0,
"send-low-water": 0,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 0,
"send-mbuf-bytes-max": 0,
"retransmit-timer": "0.00",
"persist-timer": "0.00",
"keepalive-timer": "0.00",
"msl2-timer": "0.00",
"delay-ack-timer": "0.00",
"inactivity-timer": "41047.92",
"flow-id": "0",
"flow-type": 0,
"user": "frr",
"command": "ospfd",
"pid": "89416",
"fd": "11",
"proto": "tcp4"
},
"udp4/[203.0.113.1:53-*:*]": {
"protocol": "udp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "203.0.113.1",
"port": "53",
"0": "203.0.113.1:53"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 42080,
"send-high-water": 57344,
"receive-low-water": 1,
"send-low-water": 2048,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 336640,
"send-mbuf-bytes-max": 458752,
"flow-id": "b8a211",
"flow-type": 63,
"user": "unbound",
"command": "unbound",
"pid": "10992",
"fd": "115",
"proto": "udp4"
},
"udp4/[192.0.2.1:67-*:*]": {
"protocol": "udp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "192.0.2.1",
"port": "67",
"0": "192.0.2.1:67"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 42080,
"send-high-water": 57344,
"receive-low-water": 1,
"send-low-water": 2048,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 336640,
"send-mbuf-bytes-max": 458752,
"flow-id": "b899f5",
"flow-type": 63,
"user": "root",
"command": "kea-dhcp4",
"pid": "12917",
"fd": "19",
"proto": "udp4"
},
"udp4/[*:123-*:*]": {
"protocol": "udp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "*",
"port": "123",
"0": "*:123"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 42080,
"send-high-water": 57344,
"receive-low-water": 1,
"send-low-water": 2048,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 336640,
"send-mbuf-bytes-max": 458752,
"flow-id": "c23b49",
"flow-type": 63,
"user": "root",
"command": "ntpd",
"pid": "69031",
"fd": "21",
"proto": "udp4"
},
"udp6/[*:123-*:*]": {
"protocol": "udp6",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "*",
"port": "123",
"0": "*:123"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 42080,
"send-high-water": 57344,
"receive-low-water": 1,
"send-low-water": 2048,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 336640,
"send-mbuf-bytes-max": 458752,
"flow-id": "0",
"flow-type": 0,
"user": "root",
"command": "ntpd",
"pid": "69031",
"fd": "20",
"proto": "udp6"
},
"udp4/[198.51.100.1:5353-*:*]": {
"protocol": "udp4",
"receive-bytes-waiting": 42052,
"send-bytes-waiting": 0,
"local": {
"address": "198.51.100.1",
"port": "5353",
"0": "198.51.100.1:5353"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 42080,
"send-high-water": 57344,
"receive-low-water": 1,
"send-low-water": 2048,
"receive-mbuf-bytes": 321536,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 336640,
"send-mbuf-bytes-max": 458752,
"flow-id": "13f3",
"flow-type": 63,
"user": "root",
"command": "mdns-repea",
"pid": "12222",
"fd": "10",
"proto": "udp4"
},
"udp4/[*:1900-*:*]": {
"protocol": "udp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "*",
"port": "1900",
"0": "*:1900"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 42080,
"send-high-water": 57344,
"receive-low-water": 1,
"send-low-water": 2048,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 336640,
"send-mbuf-bytes-max": 458752,
"flow-id": "c27",
"flow-type": 63,
"user": "root",
"command": "udpbroadca",
"pid": "78873",
"fd": "3",
"proto": "udp4"
},
"udp4/[192.0.2.1:161-*:*]": {
"protocol": "udp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "192.0.2.1",
"port": "161",
"0": "192.0.2.1:161"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 42080,
"send-high-water": 57344,
"receive-low-water": 1,
"send-low-water": 2048,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 336640,
"send-mbuf-bytes-max": 458752,
"flow-id": "130d",
"flow-type": 63,
"user": "root",
"command": "snmpd",
"pid": "53743",
"fd": "8",
"proto": "udp4"
},
"udp4/[*:51821-*:*]": {
"protocol": "udp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "*",
"port": "51821",
"0": "*:51821"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 42080,
"send-high-water": 57344,
"receive-low-water": 1,
"send-low-water": 2048,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 336640,
"send-mbuf-bytes-max": 458752,
"flow-id": "945",
"flow-type": 63,
"user": "??",
"command": "??",
"pid": "??",
"fd": "??",
"proto": "udp4"
},
"udp6/[*:51821-*:*]": {
"protocol": "udp6",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "*",
"port": "51821",
"0": "*:51821"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 42080,
"send-high-water": 57344,
"receive-low-water": 1,
"send-low-water": 2048,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 336640,
"send-mbuf-bytes-max": 458752,
"flow-id": "0",
"flow-type": 0,
"user": "??",
"command": "??",
"pid": "??",
"fd": "??",
"proto": "udp6"
},
"udp4/[*:29281-*:*]": {
"protocol": "udp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "*",
"port": "29281",
"0": "*:29281"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 42080,
"send-high-water": 57344,
"receive-low-water": 1,
"send-low-water": 2048,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 336640,
"send-mbuf-bytes-max": 458752,
"flow-id": "c23c01",
"flow-type": 63,
"user": "??",
"command": "??",
"pid": "??",
"fd": "??",
"proto": "udp4"
},
"udp4/[*:*-*:*]": {
"protocol": "udp4",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"local": {
"address": "*",
"port": "*",
"0": "*:*"
},
"remote": {
"address": "*",
"port": "*",
"0": "*:*"
},
"receive-high-water": 42080,
"send-high-water": 57344,
"receive-low-water": 1,
"send-low-water": 2048,
"receive-mbuf-bytes": 0,
"send-mbuf-bytes": 0,
"receive-mbuf-bytes-max": 336640,
"send-mbuf-bytes-max": 458752,
"flow-id": "1441",
"flow-type": 63,
"user": "_lldpd",
"command": "lldpd",
"pid": "72768",
"fd": "6",
"proto": "udp4"
}
},
"Active UNIX domain sockets": {
"fffff80016c50c80 - /var/run/configd.socket": {
"address": "fffff80016c50c80",
"type": "stream",
"receive-bytes-waiting": 0,
"send-bytes-waiting": 0,
"vnode": "0",
"connection": "fffff8006e7b8640",
"first-reference": "0",
"next-reference": "0",
"path": "/var/run/configd.socket"
}
}
}
}
+111
View File
@@ -0,0 +1,111 @@
"""carp_virtual_ip: one CARP address as a virtual IP, with its holder in the reading.
CARP differs from vip-manager and keepalived in what the caller can see. The
address is configured on every member, and the interface list of a firewall need
not show it at all -- so the interfaces cannot say who holds it. CARP's own state
can: the member in MASTER holds it, a BACKUP stands by. That is CARP protocol
knowledge, so it lives with the driver that speaks CARP (netOrk #1121), not in
napalm-device-types.
"""
from __future__ import annotations
import pytest
from napalm_opnsense.virtual_ips import carp_virtual_ip
def _entry(**overrides):
fields = {
"address": "172.22.8.1",
"prefix_length": 24,
"vhid": "108",
"state": "MASTER",
"instance": "MGMT",
"interface": "vtnet0",
}
fields.update(overrides)
return carp_virtual_ip(**fields)
def test_a_master_holds_the_address():
assert _entry() == {
"mechanism": "carp",
"instance": "MGMT",
"address": "172.22.8.1",
"prefix_length": 24,
"interface": "vtnet0",
"group_key": "vhid:108",
"running": True,
"holds": True,
}
def test_a_backup_stands_by():
entry = _entry(state="BACKUP")
assert entry is not None
assert (entry["running"], entry["holds"]) == (True, False)
@pytest.mark.parametrize("state", ["INIT", "DISABLED"])
def test_a_member_whose_carp_is_not_up_neither_runs_nor_holds(state):
entry = _entry(state=state)
assert entry is not None
assert (entry["running"], entry["holds"]) == (False, False)
@pytest.mark.parametrize("state", [None, "", "SOMETHING-NEW"])
def test_an_unknown_state_says_nothing_about_the_holder(state):
"""None leaves the holder to the interfaces, as for the other mechanisms."""
entry = _entry(state=state)
assert entry is not None
assert (entry["running"], entry["holds"]) == (None, None)
def test_the_state_is_read_case_blind():
entry = _entry(state=" master ")
assert entry is not None
assert entry["holds"] is True
def test_the_address_is_normalised():
entry = _entry(address="FD00::0001", prefix_length=64)
assert entry is not None
assert entry["address"] == "fd00::1"
def test_an_address_written_with_its_prefix_takes_that_prefix():
entry = _entry(address="10.42.100.1/24", prefix_length=None)
assert entry is not None
assert (entry["address"], entry["prefix_length"]) == ("10.42.100.1", 24)
@pytest.mark.parametrize("address", ["", "not-an-address", "10.0.0.300"])
def test_what_is_no_address_is_no_virtual_ip(address):
assert _entry(address=address) is None
@pytest.mark.parametrize("prefix", [33, -1])
def test_an_impossible_prefix_is_dropped(prefix):
entry = _entry(prefix_length=prefix)
assert entry is not None
assert entry["prefix_length"] is None
@pytest.mark.parametrize("vhid", [None, "", "abc"])
def test_without_a_numeric_vhid_there_is_no_group(vhid):
entry = _entry(vhid=vhid)
assert entry is not None
assert entry["group_key"] is None
def test_a_vhid_given_as_a_number_groups_the_same_way():
entry = _entry(vhid=108)
assert entry is not None
assert entry["group_key"] == "vhid:108"
def test_an_empty_instance_falls_back_to_the_vhid():
entry = _entry(instance="")
assert entry is not None
assert entry["instance"] == "vhid 108"
+118 -3
View File
@@ -2,6 +2,7 @@
import json
import pytest
import requests
from unittest.mock import MagicMock, patch
from napalm_opnsense.opnsense import OPNsenseDriver
@@ -1656,24 +1657,74 @@ class TestRollback:
class TestGetConfigCandidate:
def test_candidate_empty_without_staged_config(self, driver):
driver._get = lambda path: "<opnsense/>"
driver._get_text = lambda path: "<opnsense/>"
result = driver.get_config()
assert result["candidate"] == ""
def test_candidate_contains_staged_routes(self, driver):
driver._get = lambda path: "<opnsense/>"
driver._get_text = lambda path: "<opnsense/>"
driver.load_merge_candidate(config=VALID_ROUTES_CONFIG)
result = driver.get_config()
assert "WAN_GW" in result["candidate"]
def test_candidate_is_valid_json(self, driver):
driver._get = lambda path: "<opnsense/>"
driver._get_text = lambda path: "<opnsense/>"
driver.load_merge_candidate(config=VALID_ROUTES_CONFIG)
result = driver.get_config()
parsed = json.loads(result["candidate"])
assert isinstance(parsed, list)
# ---------------------------------------------------------------------------
# get_config() — running/startup
#
# /api/core/backup/download/this answers with config.xml itself, not JSON.
# These go through the real session path: a stubbed _get hid for months that
# the getter called .json() on XML and always returned an empty "running".
# ---------------------------------------------------------------------------
CONFIG_XML = '<?xml version="1.0"?>\n<opnsense><system><hostname>gw</hostname></system></opnsense>\n'
def _make_xml_response(text):
mock_resp = MagicMock()
mock_resp.text = text
mock_resp.json.side_effect = json.JSONDecodeError("Expecting value", text, 0)
mock_resp.raise_for_status.return_value = None
return mock_resp
class TestGetConfigRunning:
def test_running_is_the_xml_the_device_sent(self, driver):
driver.session.get.return_value = _make_xml_response(CONFIG_XML)
result = driver.get_config(retrieve="running")
assert result["running"] == CONFIG_XML
def test_startup_mirrors_running(self, driver):
driver.session.get.return_value = _make_xml_response(CONFIG_XML)
result = driver.get_config()
assert result["startup"] == result["running"] == CONFIG_XML
def test_reads_the_backup_download_endpoint(self, driver):
driver.session.get.return_value = _make_xml_response(CONFIG_XML)
driver.get_config(retrieve="running")
url = driver.session.get.call_args.args[0]
assert url.endswith("/api/core/backup/download/this")
def test_failed_read_raises_instead_of_returning_empty(self, driver):
# An empty "running" says "the device has no configuration"; the
# caller needs the reason, e.g. a key without backup privilege.
resp = MagicMock()
resp.raise_for_status.side_effect = requests.HTTPError("403 Forbidden")
driver.session.get.return_value = resp
with pytest.raises(requests.HTTPError, match="403"):
driver.get_config(retrieve="running")
def test_candidate_only_does_not_touch_the_device(self, driver):
driver.get_config(retrieve="candidate")
driver.session.get.assert_not_called()
# ---------------------------------------------------------------------------
# create_dhcp_reservation()
#
@@ -2545,6 +2596,70 @@ class TestSyncDnsZone:
added = [d for p, d in calls if p.endswith("addhostoverride")]
assert len(added) == 1
def test_does_not_copy_an_override_made_by_hand(self, driver):
"""The records netOrk sends include what its poll read off this very
device, hand-made overrides among them. Adding those again under the
tag left every one of them twice on gw-01 and gw-02 (NetOrk/netork#1123)."""
rows = [_override_row("by-hand", host="nas", ip="172.22.40.8", description="")]
calls = self._record_calls(driver, rows)
driver.sync_dns_zone("office.local", [
{"hostname": "nas", "ip": "172.22.40.8", "record_type": "A", "enabled": True},
])
assert [p for p, _ in calls if p.endswith("addhostoverride")] == []
assert [p for p, _ in calls if "delhostoverride" in p] == []
def test_clears_a_copy_it_once_made_of_a_hand_made_override(self, driver):
"""Devices that already carry the copy lose it on the next sync."""
rows = [
_override_row("by-hand", host="NAS", ip="172.22.40.8", description="NAS"),
_override_row("copy", host="nas", ip="172.22.40.8"),
]
calls = self._record_calls(driver, rows)
driver.sync_dns_zone("office.local", [
{"hostname": "nas", "ip": "172.22.40.8", "record_type": "A", "enabled": True},
{"hostname": "NAS", "ip": "172.22.40.8", "record_type": "A", "enabled": True},
])
assert [p.rsplit("/", 1)[-1] for p, _ in calls if "delhostoverride" in p] == ["copy"]
assert [p for p, _ in calls if p.endswith("addhostoverride")] == []
def test_still_adds_a_record_that_differs_from_the_hand_made_one(self, driver):
rows = [_override_row("by-hand", host="nas", ip="172.22.40.8", description="")]
calls = self._record_calls(driver, rows)
driver.sync_dns_zone("office.local", [
{"hostname": "nas", "ip": "172.22.40.9", "record_type": "A", "enabled": True},
])
added = [d for p, d in calls if p.endswith("addhostoverride")]
assert [d["host"]["server"] for d in added] == ["172.22.40.9"]
def test_a_disabled_hand_made_override_does_not_stand_in(self, driver):
"""It answers nothing, so the record still has to be added."""
rows = [_override_row("by-hand", host="nas", ip="172.22.40.8", description="",
enabled="0")]
calls = self._record_calls(driver, rows)
driver.sync_dns_zone("office.local", [
{"hostname": "nas", "ip": "172.22.40.8", "record_type": "A", "enabled": True},
])
assert len([p for p, _ in calls if p.endswith("addhostoverride")]) == 1
def test_a_hand_made_override_in_another_zone_does_not_stand_in(self, driver):
rows = [_override_row("by-hand", host="nas", domain="other.local", ip="172.22.40.8",
description="")]
calls = self._record_calls(driver, rows)
driver.sync_dns_zone("office.local", [
{"hostname": "nas", "ip": "172.22.40.8", "record_type": "A", "enabled": True},
])
assert len([p for p, _ in calls if p.endswith("addhostoverride")]) == 1
# ---------------------------------------------------------------------------
# uninstall_package – says what it cannot do
@@ -0,0 +1,132 @@
"""What the firewall is called, which version it runs, and which addresses it serves.
``/api/core/system/status`` on OPNsense 26.7 carries neither name nor version
(``metadata`` and ``subsystems`` only), so every firewall read as nameless and
"unknown". ``/api/diagnostics/system/system_information`` has both: ``name`` is
the FQDN, ``versions[0]`` reads ``"OPNsense 26.7.7-amd64"``.
An address bound to a CARP VHID sits on the interface of every node, in state
MASTER or BACKUP. Only the MASTER answers for it, so only there is it one of the
node's addresses -- the same as keepalived, where only the master has the address
at all (netOrk #1121: a virtual IP's badge sits on its holder's interface).
"""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import patch
import pytest
from napalm_opnsense.opnsense import OPNsenseDriver
OVERVIEW = json.loads(
(Path(__file__).parent / "fixtures" / "interfaces_overview_carp.json").read_text()
)
SYSTEM_INFORMATION = {
"name": "gw-02.home.manivong.de",
"versions": ["OPNsense 26.7.7-amd64", "FreeBSD 15.1-RELEASE-p4", "OpenSSL 3.5.9"],
}
STATUS_26_7 = {"metadata": {"translations": {}}, "subsystems": {}}
@pytest.fixture
def driver() -> OPNsenseDriver:
with patch("napalm_opnsense.opnsense.requests.Session"):
return OPNsenseDriver(hostname="fw", username="k", password="s")
def _answers(driver, system_information=SYSTEM_INFORMATION, status=STATUS_26_7, overview=OVERVIEW):
def get(path: str):
if path == "/api/diagnostics/system/system_information":
if isinstance(system_information, Exception):
raise system_information
return system_information
if path == "/api/core/system/status":
return status
if path == "/api/interfaces/overview/export":
return overview
raise AssertionError(f"unexpected GET {path}")
driver._get = get
return driver
class TestIdentity:
def test_the_name_is_the_short_host_and_the_fqdn_the_whole(self, driver):
facts = _answers(driver).get_facts()
assert (facts["hostname"], facts["fqdn"]) == ("gw-02", "gw-02.home.manivong.de")
def test_the_version_comes_without_the_architecture(self, driver):
assert _answers(driver).get_facts()["os_version"] == "26.7.7"
@pytest.mark.parametrize(
"line, version",
[
("OPNsense 26.7.7_1-aarch64", "26.7.7_1"),
("OPNsense 25.1", "25.1"),
("OPNsense 24.7.12-i386", "24.7.12"),
],
)
def test_version_spellings(self, driver, line, version):
info = {"name": "fw", "versions": [line]}
assert _answers(driver, system_information=info).get_facts()["os_version"] == version
def test_a_name_without_a_domain_is_both(self, driver):
facts = _answers(driver, system_information={"name": "fw", "versions": []}).get_facts()
assert (facts["hostname"], facts["fqdn"]) == ("fw", "fw")
def test_without_system_information_the_status_answer_still_counts(self, driver):
"""An older OPNsense, or an API key without the diagnostics privilege."""
status = {"hostname": "opnsense01", "version": "24.7"}
facts = _answers(driver, system_information=RuntimeError("403"), status=status).get_facts()
assert (facts["hostname"], facts["os_version"]) == ("opnsense01", "24.7")
def test_nothing_known_is_empty_and_unknown(self, driver):
facts = _answers(driver, system_information={}).get_facts()
assert (facts["hostname"], facts["fqdn"], facts["os_version"]) == ("", "", "unknown")
class TestHeldAddresses:
def test_the_master_serves_its_carp_addresses(self, driver):
result = _answers(driver).get_interfaces_ip()
assert set(result["em0"]["ipv4"]) == {"192.0.2.2", "192.0.2.1"}
assert result["em0"]["ipv4"]["192.0.2.1"] == {"prefix_length": 24}
assert set(result["vlan0040"]["ipv4"]) == {
"198.51.100.2",
"198.51.100.1",
"198.51.100.140",
}
def test_a_backup_serves_its_own_address_only(self, driver):
backup = json.loads(json.dumps(OVERVIEW).replace('"MASTER"', '"BACKUP"'))
result = _answers(driver, overview=backup).get_interfaces_ip()
assert set(result["em0"]["ipv4"]) == {"192.0.2.2"}
assert set(result["vlan0040"]["ipv4"]) == {"198.51.100.2"}
def test_a_vhid_that_is_not_up_is_not_served(self, driver):
result = _answers(driver).get_interfaces_ip()
assert set(result["vlan0011"]["ipv4"]) == {"203.0.113.2"}
def test_a_held_ipv6_carp_address_is_served(self, driver):
result = _answers(driver).get_interfaces_ip()
assert "2001:db8:8::1" in result["em0"]["ipv6"]
def test_an_address_without_a_vhid_is_left_as_before(self, driver):
"""Only CARP addresses are added; a plain alias is a separate question."""
result = _answers(driver).get_interfaces_ip()
assert set(result["vlan0020"]["ipv4"]) == {"192.0.2.130"}
def test_without_a_primary_address_the_list_still_leaves_out_a_backup(self, driver):
overview = [
{
"device": "vlan0099",
"ipv4": [
{"ipaddr": "192.0.2.66/26"},
{"ipaddr": "192.0.2.65/26", "vhid": "199", "status": "BACKUP"},
],
}
]
result = _answers(driver, overview=overview).get_interfaces_ip()
assert set(result["vlan0099"]["ipv4"]) == {"192.0.2.66"}
+147
View File
@@ -0,0 +1,147 @@
"""What listens on an OPNsense firewall, and which OPNsense service it is (netOrk #673).
OPNsense answers through its API, ``diagnostics/interface/get_socket_statistics``:
netstat's sockets with sockstat's user, command and PID merged in, read by configd
as root. A socket that listens has no peer (``*:*``). Which service holds it comes
from the command -- cut to ten characters by FreeBSD -- matched against the
firewall's own service list; WireGuard's sockets belong to the kernel and are
told by their port.
The fixture is a real OPNsense 26.7.5 firewall's answer, cut down, with
documentation addresses.
"""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from napalm_opnsense.opnsense import OPNsenseDriver
STATISTICS = json.loads((Path(__file__).parent / "fixtures" / "socket_statistics.json").read_text())
SERVICES = [
"acme", "captiveportal", "configd", "cron", "crowdsec", "frr", "kea-dhcp", "lldpd",
"login", "mdns-repeater", "ntpd", "openssh", "pf", "snmpd", "udpbroadcastrelay",
"unbound", "webgui", "wireguard", "zabbix_agentd",
] # fmt: skip
WIREGUARD = {
"rows": [
{"if": "wg1", "type": "interface", "listen-port": "51821", "status": "up"},
{"if": "wg1", "type": "peer", "allowed-ips": "10.99.99.2/32"},
{"if": "wg2", "type": "interface", "listen-port": "29281", "status": "up"},
]
}
def _driver(services=SERVICES, wireguard=WIREGUARD) -> OPNsenseDriver:
with patch("napalm_opnsense.opnsense.requests.Session"):
drv = OPNsenseDriver(hostname="fw", username="k", password="s")
drv.session = MagicMock()
def get(path: str) -> dict:
if path == "/api/diagnostics/interface/get_socket_statistics":
return STATISTICS
if path == "/api/core/service/search":
return {"rows": [{"id": n, "name": n, "running": 1} for n in services]}
if path == "/api/wireguard/service/show":
if isinstance(wireguard, Exception):
raise wireguard
return wireguard
raise AssertionError(f"unexpected GET {path}")
drv._get = get # type: ignore[method-assign]
return drv
def _sockets(drv: OPNsenseDriver | None = None) -> dict:
reading = (drv or _driver()).get_listening_sockets()
return {(s["proto"], s["port"], s["address"]): s for s in reading["sockets"]}
class TestTheReading:
def test_read_as_root(self):
assert _driver().get_listening_sockets()["attributed"] is True
def test_a_socket_comes_with_its_process_and_service(self):
assert _sockets()[("tcp", 8080, "192.0.2.1")] == {
"proto": "tcp",
"address": "192.0.2.1",
"port": 8080,
"interface": None,
"process": "crowdsec",
"pid": 90423,
"unit": "crowdsec",
"container_id": None,
}
def test_only_what_listens(self):
"""A connection has a peer; a socket without a port listens on nothing."""
sockets = _driver().get_listening_sockets()["sockets"]
assert len(sockets) == 25
assert all(s["port"] > 0 for s in sockets)
@pytest.mark.parametrize(
"proto, port, address",
[
("tcp", 8000, "0.0.0.0"),
("tcp", 8000, "::"),
("udp", 123, "0.0.0.0"),
("udp", 123, "::"),
],
)
def test_every_address_of_a_family_is_its_any_address(self, proto, port, address):
assert (proto, port, address) in _sockets()
def test_sorted_by_protocol_port_and_address(self):
sockets = _driver().get_listening_sockets()["sockets"]
keys = [(s["proto"], s["port"], s["address"]) for s in sockets]
assert keys == sorted(keys)
class TestWhichService:
@pytest.mark.parametrize(
"proto, port, address, service",
[
("tcp", 22, "192.0.2.1", "openssh"),
("tcp", 443, "192.0.2.1", "webgui"),
("tcp", 8000, "0.0.0.0", "captiveportal"), # lighttpd as www
("udp", 67, "192.0.2.1", "kea-dhcp"), # kea-dhcp4
("tcp", 8000, "127.0.0.1", "kea-dhcp"), # kea-ctrl-agent, cut short
("tcp", 2604, "127.0.0.1", "frr"), # ospfd
("udp", 5353, "198.51.100.1", "mdns-repeater"), # cut short
("udp", 1900, "0.0.0.0", "udpbroadcastrelay"),
("tcp", 10050, "192.0.2.1", "zabbix_agentd"),
("udp", 53, "203.0.113.1", "unbound"),
("udp", 123, "0.0.0.0", "ntpd"),
],
)
def test_by_its_command(self, proto, port, address, service):
assert _sockets()[(proto, port, address)]["unit"] == service
def test_the_command_stays_as_reported(self):
assert _sockets()[("udp", 5353, "198.51.100.1")]["process"] == "mdns-repea"
def test_a_service_the_firewall_does_not_list_is_none(self):
without_snmpd = [n for n in SERVICES if n != "snmpd"]
assert _sockets(_driver(services=without_snmpd))[("udp", 161, "192.0.2.1")]["unit"] is None
def test_wireguard_s_kernel_sockets_by_their_port(self):
wg = _sockets()[("udp", 51821, "0.0.0.0")]
assert (wg["process"], wg["pid"], wg["unit"]) == (None, None, "wireguard")
def test_without_wireguard_a_kernel_socket_is_nobody_s(self):
sockets = _sockets(_driver(wireguard=RuntimeError("404")))
assert sockets[("udp", 29281, "0.0.0.0")]["unit"] is None
def test_netork_finds_it():
"""netOrk asks ``hasattr(driver, "get_listening_sockets")``."""
assert hasattr(OPNsenseDriver, "get_listening_sockets")
+15
View File
@@ -123,3 +123,18 @@ class TestHostStatus:
def test_no_pending_reboot(self, driver):
with patch.object(driver, "_get", return_value=_status()):
assert driver.get_host_status()["reboot_required"] is False
class TestRebootHost:
"""``reboot_host`` restarts the firewall through its firmware API (netOrk #637)."""
def test_the_firmware_api_is_asked(self, driver):
with patch.object(driver, "_post", return_value={"status": "ok"}) as post:
driver.reboot_host()
post.assert_called_once_with("/api/core/firmware/reboot")
def test_a_refusal_is_raised(self, driver):
with patch.object(driver, "_post", side_effect=ConnectionError("403 Forbidden")):
with pytest.raises(RuntimeError, match="403"):
driver.reboot_host()
+130
View File
@@ -0,0 +1,130 @@
"""The firewall's CARP addresses, with who holds each (netOrk #1121).
``/api/interfaces/overview/export`` -- the source ``get_interfaces_ip`` reads --
lists every address on an interface, and an address bound to a CARP VHID carries
the VHID and this node's CARP state. That covers both the CARP address itself and
an IP alias bound to its VHID: both float with the VHID's MASTER. An address
without a VHID stays on this node and is no virtual IP.
The overview has no CARP password in it; ``interfaces/vip_settings`` would, which
is why it is not read. The fixture is the shape of a real OPNsense 26.7 answer,
cut down, with documentation addresses.
"""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from napalm_opnsense.opnsense import OPNsenseDriver
from napalm_opnsense.virtual_ips import carp_addresses
OVERVIEW = json.loads(
(Path(__file__).parent / "fixtures" / "interfaces_overview_carp.json").read_text()
)
def _driver(overview=OVERVIEW) -> OPNsenseDriver:
with patch("napalm_opnsense.opnsense.requests.Session"):
drv = OPNsenseDriver(hostname="fw", username="k", password="s")
drv.session = MagicMock()
def get(path: str):
if path == "/api/interfaces/overview/export":
if isinstance(overview, Exception):
raise overview
return overview
raise AssertionError(f"unexpected GET {path}")
drv._get = get # type: ignore[method-assign]
return drv
def _by_address(entries) -> dict:
return {e["address"]: e for e in entries}
class TestCarpAddresses:
def test_every_address_bound_to_a_vhid_is_a_virtual_ip(self):
assert [e["address"] for e in carp_addresses(OVERVIEW)] == [
"192.0.2.1",
"198.51.100.1",
"198.51.100.140",
"203.0.113.1",
"2001:db8:8::1",
]
def test_an_address_without_a_vhid_stays_on_this_node(self):
addresses = {e["address"] for e in carp_addresses(OVERVIEW)}
assert not addresses & {"192.0.2.2", "192.0.2.140", "127.0.0.1", "fe80::1"}
def test_the_carp_address_carries_its_interface_and_state(self):
assert _by_address(carp_addresses(OVERVIEW))["192.0.2.1"] == {
"mechanism": "carp",
"instance": "MGMT",
"address": "192.0.2.1",
"prefix_length": 24,
"interface": "em0",
"group_key": "vhid:108",
"running": True,
"holds": True,
}
def test_an_alias_on_a_vhid_follows_that_vhid(self):
alias = _by_address(carp_addresses(OVERVIEW))["198.51.100.140"]
assert (alias["group_key"], alias["prefix_length"], alias["holds"]) == (
"vhid:140",
32,
True,
)
def test_a_vhid_not_up_neither_runs_nor_holds(self):
entry = _by_address(carp_addresses(OVERVIEW))["203.0.113.1"]
assert (entry["running"], entry["holds"]) == (False, False)
def test_an_interface_without_a_description_is_named_by_its_identifier(self):
assert _by_address(carp_addresses(OVERVIEW))["203.0.113.1"]["instance"] == "opt9"
def test_on_a_backup_node_nothing_is_held(self):
backup = json.loads(json.dumps(OVERVIEW).replace('"MASTER"', '"BACKUP"'))
entries = carp_addresses(backup)
assert entries and all(e["holds"] is False for e in entries)
def test_the_answer_wrapped_in_an_object_reads_the_same(self):
assert carp_addresses({"interfaces": OVERVIEW}) == carp_addresses(OVERVIEW)
@pytest.mark.parametrize("answer", [[], {}, {"interfaces": None}])
def test_a_firewall_without_interfaces_has_none(self, answer):
assert carp_addresses(answer) == []
@pytest.mark.parametrize("answer", ["nonsense", None, 42])
def test_an_answer_of_another_shape_is_refused(self, answer):
with pytest.raises(ValueError):
carp_addresses(answer)
def test_odd_entries_are_skipped(self):
overview = [
{"identifier": "opt1", "ipv4": ["192.0.2.9/24", {"vhid": "1"}, None]},
"not-an-interface",
{"identifier": "opt2", "ipv4": [{"ipaddr": "bogus", "vhid": "2"}]},
]
assert carp_addresses(overview) == []
class TestGetVirtualIps:
def test_a_firewall_reports_carp_and_does_not_look_for_the_linux_mechanisms(self):
reading = _driver().get_virtual_ips()
assert reading["vip_manager"] is None
assert reading["keepalived"] is None
assert len(reading["carp"]) == 5
def test_a_firewall_without_carp_reports_an_empty_list(self):
reading = _driver(overview=[OVERVIEW[3], OVERVIEW[4]]).get_virtual_ips()
assert reading["carp"] == []
def test_a_failed_request_is_raised_not_reported_as_none_declared(self):
with pytest.raises(RuntimeError):
_driver(overview=RuntimeError("API down")).get_virtual_ips()