Author SHA1 Message Date
Christian Manivong 7dccd0d1f8 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.
2026-10-18 15:04:03 +02:00
christianmanivong 7183da24fb Merge pull request 'feat: drop get_device_warnings; the pending updates are the fact (0.3.0)' (#22) from feat/facts-not-warnings into master
CI / test (3.10) (push) Successful in 31s
CI / test (3.11) (push) Successful in 29s
CI / test (3.12) (push) Successful in 30s
2026-10-17 08:34:57 +00:00
Christian Manivong 5ab33d67f3 feat: drop get_device_warnings; the pending updates are the fact (0.3.0)
CI / test (3.10) (push) Successful in 30s
CI / test (3.11) (push) Successful in 28s
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
updates_available came from the cached firmware status with ten package
names; netOrk builds it from get_available_updates with the full list
(netOrk #1111).
2026-10-17 09:38:34 +02:00
christianmanivong 39cc177bf2 Merge pull request 'fix!: the SNMP agent takes the caller's values and opens only to its sources (0.2.0)' (#21) from feat/snmp-agent-template into master
CI / test (3.10) (push) Successful in 30s
CI / test (3.11) (push) Successful in 31s
CI / test (3.12) (push) Successful in 34s
2026-10-17 06:48:18 +00:00
Christian Manivong c4edfc88e4 fix!: the SNMP agent takes the caller's values and opens only to its sources (0.2.0)
CI / test (3.10) (push) Successful in 38s
CI / test (3.11) (push) Successful in 32s
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 31s
fix_snmp enabled os-net-snmp with community "public", contact
netork@localhost and location "Managed by netOrk", and added a
floating pass rule for UDP/161 from any source on every interface: a
firewall managed over WAN answered its SNMP tree to anyone there (#13).

The driver implements napalm-device-types 4.13's configure_snmp_agent
steps instead:
- install: os-net-snmp only when missing (a reinstall resets it);
- configure: community, contact, location and listen address from the
  caller; a DNS name is not a listen address; a refused save fails;
- firewall: one floating pass rule per allowed source, the old
  any-source rule and rules of an earlier run replaced, operators'
  rules untouched; no sources, no change;
- start: restart net-snmp.
The hand-built UDP probe goes; the caller asks the agent itself.

BREAKING CHANGE: run_device_action is removed. Requires
napalm-device-types 4.13.

Closes #13
2026-10-17 08:26:15 +02:00
christianmanivong 9fe6245940 Merge pull request 'fix: name and version from system_information; a CARP address on its MASTER's interface' (#20) from fix/hostname-and-carp-addresses into master
CI / test (3.10) (push) Successful in 32s
CI / test (3.11) (push) Successful in 32s
CI / test (3.12) (push) Successful in 31s
2026-10-16 13:50:03 +00:00
Christian Manivong e3ed0f1bdd fix: name and version from system_information; a CARP address on its MASTER's interface
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 30s
CI / test (3.11) (pull_request) Successful in 28s
CI / test (3.12) (pull_request) Successful in 29s
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:37:43 +02:00
christianmanivong 055ec3da71 Merge pull request 'feat: report Kea's HA peers and the firewall's config sync' (#19) from feat/dhcp-ha-config-sync into master
CI / test (3.10) (push) Successful in 37s
CI / test (3.11) (push) Successful in 33s
CI / test (3.12) (push) Successful in 33s
2026-10-16 13:32:24 +00:00
Christian Manivong e334dc0058 feat: report Kea's HA peers and the firewall's config sync
CI / test (3.10) (push) Successful in 32s
CI / test (3.11) (push) Successful in 33s
CI / test (3.12) (push) Successful in 34s
CI / test (3.10) (pull_request) Successful in 38s
CI / test (3.11) (pull_request) Successful in 37s
CI / test (3.12) (pull_request) Successful in 38s
get_dhcp_ha reads kea/dhcpv4/get (the ha section) and
kea/dhcpv4/searchPeer: whether Kea's HA hook is on, this firewall's peer
name -- the % twin when left at its default -- and each peer's name,
role and HA URL. get_config_sync reads core/hasync/get: the address this
firewall pushes its configuration to and the ticked sync items, mapped to
the service names get_services reports; items that are no service stay
under `unmapped`. Both raise when the request fails.

Configuration, not state: Kea's control agent, which knows whether the
pair is in hot standby right now, listens on localhost only.

Needs napalm-device-types 4.11.0 (ConfigSyncMixin). NetOrk/netork#1125.
2026-10-16 15:18:45 +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
20 changed files with 3298 additions and 203 deletions

No files matched your search

+4 -2
View File
@@ -71,9 +71,9 @@ arguments.
| Getter | Status | OPNsense API Endpoint | | 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` | ✅ | `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_interfaces_counters` | ✅ | `GET /api/diagnostics/interface/get_interface_statistics` |
| `get_arp_table` | ✅ | `GET /api/diagnostics/interface/get_arp` | | `get_arp_table` | ✅ | `GET /api/diagnostics/interface/get_arp` |
| `get_ipv6_neighbors_table` | ✅ | `GET /api/diagnostics/interface/get_ndp` | | `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_vlans` | ✅ | `GET /api/interfaces/vlan_settings/search_item` |
| `get_mac_address_table` | ❌ | Not applicable (firewall, no L2 switching) | | `get_mac_address_table` | ❌ | Not applicable (firewall, no L2 switching) |
| `ping` | ✅ | `POST /api/diagnostics/ping/set` + `start` + `search_jobs` + `stop`/`remove` | | `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 | | `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. > ¹ Requires the `os-lldpd` plugin. Returns empty dict if the plugin is not installed.
+109
View File
@@ -0,0 +1,109 @@
"""Kea's HA peers and the firewall's configuration sync, as facts (netOrk #1125).
Two answers of the OPNsense API, read as they are:
* ``/api/kea/dhcpv4/get`` (its ``ha`` section) and ``/api/kea/dhcpv4/searchPeer``:
whether Kea's HA hook is on, which peer this firewall is, and each peer's name,
role and HA listener URL. Configuration, not state -- Kea's control agent, which
knows whether the pair is in hot standby right now, listens on localhost only.
* ``/api/core/hasync/get``: the address this firewall pushes its configuration
to (``synchronizetoip``) and the ticked sync items. An item is named by
OPNsense's sync section, not by the service it covers; :data:`SYNC_ITEM_SERVICES`
maps the ones that are a service to the name ``get_services`` reports.
"""
from __future__ import annotations
from typing import Any
from napalm_device_types.models import ConfigSyncDict, DhcpHaDict, DhcpHaPeerDict
#: Kea's DHCPv4 server as ``get_services`` names it.
KEA_SERVICE = "kea-dhcp"
#: Sync item -> the service it carries the configuration of, by the name
#: ``get_services`` reports. Items that are configuration only (aliases, rules,
#: NAT, users ...) have no service and are reported as unmapped.
SYNC_ITEM_SERVICES: dict[str, str] = {
"captiveportal": "captiveportal",
"cron": "cron",
"ddclient": "ddclient",
"dnsforwarder": "dnsmasq",
"dnsresolver": "unbound",
"hostwatch": "hostwatch",
"kea": KEA_SERVICE,
"mdns-repeater": "mdns-repeater",
"netsnmp": "snmpd",
"ntpd": "ntpd",
"qemuguestagent": "qemu-ga",
"quagga": "frr",
"radvd": "radvd",
"squid": "squid",
"ssh": "openssh",
"sysctl": "sysctl",
"syslog-ng": "syslog-ng",
"webgui": "webgui",
"wireguard": "wireguard",
"zabbixagent": "zabbix_agentd",
}
def _section(answer: Any, *path: str) -> dict:
node = answer
for key in path:
if not isinstance(node, dict) or not isinstance(node.get(key), dict):
raise ValueError(f"unexpected answer: no {'.'.join(path)}")
node = node[key]
return node
def dhcp_ha(settings: Any, peers: Any) -> DhcpHaDict:
"""Kea's HA configuration from the two answers.
``this_server_name`` is empty while left at its default, the hostname; the
effective name is then in its ``%`` twin.
:raises ValueError: when *settings* has no ``dhcpv4.ha`` section.
"""
ha = _section(settings, "dhcpv4", "ha")
result: DhcpHaDict = {
"service": KEA_SERVICE,
"enabled": str(ha.get("enabled", "0")) == "1",
"peers": [],
}
this_server = str(ha.get("this_server_name") or ha.get("%this_server_name") or "").strip()
if this_server:
result["this_server"] = this_server
rows = peers.get("rows") if isinstance(peers, dict) else None
for row in rows or []:
if not isinstance(row, dict) or not row.get("name"):
continue
peer: DhcpHaPeerDict = {"name": str(row["name"]), "role": str(row.get("role") or "")}
if row.get("url"):
peer["url"] = str(row["url"])
result["peers"].append(peer)
return result
def config_sync(answer: Any) -> ConfigSyncDict:
"""Where this firewall pushes its configuration, and which services' it pushes.
Ticked items without a target address push nothing, so they count for nothing.
:raises ValueError: when *answer* has no ``hasync`` section.
"""
section = _section(answer, "hasync")
peer = str(section.get("synchronizetoip") or "").strip() or None
if peer is None:
return {"peer": None, "services": [], "unmapped": []}
items = section.get("syncitems")
ticked = sorted(
name
for name, item in (items.items() if isinstance(items, dict) else [])
if isinstance(item, dict) and str(item.get("selected", "0")) == "1"
)
return {
"peer": peer,
"services": sorted({SYNC_ITEM_SERVICES[i] for i in ticked if i in SYNC_ITEM_SERVICES}),
"unmapped": [i for i in ticked if i not in SYNC_ITEM_SERVICES],
}
+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 "")
)
+260 -196
View File
@@ -36,8 +36,10 @@ per-module through their respective API controllers.
from __future__ import annotations from __future__ import annotations
import difflib import difflib
import ipaddress
import json import json
import logging import logging
import re
import socket import socket
import time import time
from ipaddress import ip_address, ip_network from ipaddress import ip_address, ip_network
@@ -52,14 +54,51 @@ _REFRESH_INTERVAL = 3
import requests import requests
from requests.exceptions import RequestException from requests.exceptions import RequestException
from napalm_device_types import FingerprintRule, FirewallDriver from napalm_device_types import ConfigSyncMixin, FingerprintRule, FirewallDriver, SnmpAgentMixin
from napalm_device_types.models import (
FixStepOutcomeDict,
SnmpAgentSettingsDict,
ConfigSyncDict,
DhcpHaDict,
ListeningSocketsDict,
VirtualIpsDict,
)
from napalm.base.exceptions import ConnectionException, ConnectionClosedException, MergeConfigException from napalm.base.exceptions import ConnectionException, ConnectionClosedException, MergeConfigException
from napalm_opnsense.ha import config_sync, dhcp_ha
from napalm_opnsense.listening import parse_socket_statistics, wireguard_listen_ports
from napalm_opnsense.ping_mixin import OPNsensePingMixin from napalm_opnsense.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.port_forwards import alias_index, port_forwards, wan_interfaces
from napalm_opnsense.virtual_ips import carp_addresses
class OPNsenseDriver(OPNsensePingMixin, FirewallDriver): _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).""" """NAPALM driver for OPNsense (read-only, REST API)."""
VENDOR = "OPNsense" VENDOR = "OPNsense"
@@ -170,6 +209,22 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
response.raise_for_status() response.raise_for_status()
return response.json() 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]: def _post(self, path: str, data: dict[str, Any] | None = None) -> dict[str, Any]:
"""Perform a POST request against the OPNsense REST API. """Perform a POST request against the OPNsense REST API.
@@ -192,16 +247,33 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
def get_facts(self) -> dict[str, Any]: def get_facts(self) -> dict[str, Any]:
"""Return a dictionary of general device facts. """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): Returned keys (NAPALM standard):
``vendor``, ``model``, ``hostname``, ``fqdn``, ``os_version``, ``vendor``, ``model``, ``hostname``, ``fqdn``, ``os_version``,
``serial_number``, ``uptime``, ``interface_list``. ``serial_number``, ``uptime``, ``interface_list``.
""" """
status = self._get("/api/core/system/status") 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 "" fqdn = str(information.get("name") or status.get("hostname") or status.get("name") or "")
version = status.get("version") or status.get("product_version") or "unknown" hostname = fqdn.split(".", 1)[0]
version = (
_opnsense_version(information.get("versions"))
or status.get("version")
or status.get("product_version")
or "unknown"
)
try: try:
interface_list = list(self.get_interfaces().keys()) interface_list = list(self.get_interfaces().keys())
@@ -213,7 +285,7 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
"vendor": self.VENDOR, "vendor": self.VENDOR,
"model": status.get("model") or self.VENDOR, "model": status.get("model") or self.VENDOR,
"hostname": hostname, "hostname": hostname,
"fqdn": hostname, "fqdn": fqdn,
"os_version": version, "os_version": version,
"serial_number": status.get("serial") or "", "serial_number": status.get("serial") or "",
"uptime": status.get("uptime", -1), "uptime": status.get("uptime", -1),
@@ -263,6 +335,10 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
Uses ``GET /api/interfaces/overview/export`` (same source as get_interfaces). 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:: Structure::
{ {
@@ -298,8 +374,14 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
_add(name, addr4, "ipv4") _add(name, addr4, "ipv4")
if addr6: if addr6:
_add(name, addr6, "ipv6") _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: if not addr4:
for entry in iface.get("ipv4") or []: 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", "") ip_field = entry.get("ipaddr") or entry.get("ip", "")
subnet = entry.get("subnetbits") or entry.get("prefix_length") subnet = entry.get("subnetbits") or entry.get("prefix_length")
cidr = f"{ip_field}/{subnet}" if subnet and "/" not in ip_field else ip_field cidr = f"{ip_field}/{subnet}" if subnet and "/" not in ip_field else ip_field
@@ -307,6 +389,8 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
_add(name, cidr, "ipv4") _add(name, cidr, "ipv4")
if not addr6: if not addr6:
for entry in iface.get("ipv6") or []: 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", "") ip_field = entry.get("ipaddr") or entry.get("ip", "")
prefix = entry.get("prefixlen") or entry.get("prefix_length") prefix = entry.get("prefixlen") or entry.get("prefix_length")
cidr = f"{ip_field}/{prefix}" if prefix and "/" not in ip_field else ip_field cidr = f"{ip_field}/{prefix}" if prefix and "/" not in ip_field else ip_field
@@ -833,22 +917,19 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
the JSON-serialised staged routes when a candidate has been loaded, the JSON-serialised staged routes when a candidate has been loaded,
or an empty string otherwise. 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": ""} configs: dict[str, str] = {"running": "", "startup": "", "candidate": ""}
if retrieve in ("all", "running", "startup"): if retrieve in ("all", "running", "startup"):
try: xml_text = self._get_text("/api/core/backup/download/this")
response = self._get("/api/core/backup/download/this") if retrieve in ("all", "running"):
xml_text: str = ( configs["running"] = xml_text
response if isinstance(response, str) else str(response) if retrieve in ("all", "startup"):
) configs["startup"] = xml_text
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)
if retrieve in ("all", "candidate") and self._candidate_config is not None: if retrieve in ("all", "candidate") and self._candidate_config is not None:
configs["candidate"] = json.dumps(self._candidate_config, indent=2) configs["candidate"] = json.dumps(self._candidate_config, indent=2)
@@ -1789,6 +1870,60 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
}) })
return sorted(result, key=lambda x: x["name"].lower()) 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]: def manage_service(self, name: str, action: str) -> dict[str, Any]:
"""Execute a lifecycle action on an OPNsense service. """Execute a lifecycle action on an OPNsense service.
@@ -2050,6 +2185,16 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
"output": f"The update check did not finish within {_REFRESH_POLLS * _REFRESH_INTERVAL} s", "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]: def get_host_status(self) -> dict[str, Any]:
"""Whether the firewall needs a reboot to finish an update; it does not """Whether the firewall needs a reboot to finish an update; it does not
patch itself as far as netOrk can tell.""" patch itself as far as netOrk can tell."""
@@ -2060,51 +2205,31 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
"auto_updates": None, "auto_updates": None,
} }
def get_device_warnings(self) -> list[dict[str, Any]]:
"""Return a list of warning dicts for issues detected on this device.
Reads the cached ``GET /api/core/firmware/status`` (no network
update trigger) to detect available package/firmware updates.
"""
warnings: list[dict[str, Any]] = []
try:
status = self._get("/api/core/firmware/status")
state = status.get("status", "none")
if state in ("update", "upgrade"):
upgrades = (
status.get("upgrade_packages")
or status.get("updates")
or []
)
if upgrades:
warnings.append({
"code": "updates_available",
"meta": {
"count": len(upgrades),
"packages": [u.get("name", "") for u in upgrades[:10]],
},
})
except Exception as exc:
logger.debug("Failed to check firmware status for device warnings: %s", exc)
return warnings
def apply_updates(self, packages: list[str]) -> dict[str, Any]: 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 Returns once the firewall has taken the request; the upgrade runs on
individual packages. The ``packages`` parameter is accepted for and :meth:`get_update_progress` follows it. Calls
API compatibility but is ignored — the full upgrade is always ``POST /api/core/firmware/upgrade``.
applied.
Calls ``POST /api/core/firmware/upgrade``.
""" """
try: 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)} return {"success": True, "output": str(result)}
except Exception as exc: except Exception as exc:
logger.warning("Firmware upgrade failed: %s", exc) logger.warning("Firmware upgrade failed: %s", exc)
return {"success": False, "output": str(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]]: def search_packages(self, query: str) -> list[dict[str, Any]]:
"""Search available OPNsense plugins by name or description. """Search available OPNsense plugins by name or description.
@@ -2258,157 +2383,87 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
} }
return {"success": True, "output": f"Magic packet sent to {mac_address} via {interface}"} return {"success": True, "output": f"Magic packet sent to {mac_address} via {interface}"}
def run_device_action(self, action: str) -> dict[str, Any]: # ------------------------------------------------------------------
"""Execute a named action on the firewall.""" # SNMP agent (napalm-device-types SnmpAgentMixin): the caller's values
if action == "fix_snmp": # ------------------------------------------------------------------
return self._action_fix_snmp()
raise NotImplementedError(f"Unknown action: {action!r}")
def _action_fix_snmp(self) -> dict[str, Any]: #: What marks a pass rule this driver added for an allowed SNMP source; the
"""Install os-net-snmp plugin, configure community 'public', open firewall. #: old any-source rule is recognised by its older description.
_SNMP_RULE_PREFIX = "napalm-opnsense: SNMP from "
_SNMP_RULE_OLD = "[netork] Allow SNMP"
Steps: def _install_snmp_agent(self) -> FixStepOutcomeDict:
1. Install os-net-snmp via firmware API (idempotent) # Installing over an installed os-net-snmp starts an async reinstall that
2. Configure via POST /api/netsnmp/general/set # resets its configuration minutes later, so only when it is missing.
3. Start/restart the service
4. Add a floating firewall rule allowing UDP/161 from any source
(OPNsense default-drops traffic arriving on non-LAN interfaces
such as WireGuard; without this rule SNMP is unreachable from
management networks even though the daemon is running)
5. Apply firewall and verify
"""
lines: list = []
# 1. Install os-net-snmp plugin ONLY if not already present.
# Calling firmware/install on an already-installed plugin triggers an
# async reinstall that overwrites the config with factory defaults a few
# minutes later — causing SNMP to stop working again after the fix.
plugin_installed = False
try: try:
chk = self._get("/api/netsnmp/general/get") chk = self._get("/api/netsnmp/general/get")
plugin_installed = isinstance(chk, dict) and "general" in chk except Exception: # noqa: BLE001 - the plugin's API is not there
except Exception: chk = {}
pass 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}"}
if not plugin_installed: def _write_snmp_agent_config(self, settings: SnmpAgentSettingsDict) -> FixStepOutcomeDict:
try: community = settings["community"]
result = self._post("/api/core/firmware/install/os-net-snmp") if not community or any(c.isspace() or c in "\"#" for c in community):
lines.append(f"[install] {result}") raise ValueError("the community must be one word without quotes or #")
import time as _time listen = settings.get("listen") or self.hostname
_time.sleep(5) # wait for install to settle
except Exception as exc:
logger.debug("os-net-snmp install failed: %s", exc)
lines.append(f"[install] failed: {exc}")
else:
lines.append("[install] os-net-snmp already installed — skipping reinstall")
# 2. Configure SNMP
# The listen field must contain the management IP so that snmpd binds
# to the correct interface. Without it snmpd may not respond on any
# non-loopback address.
# The API expects {"<ip>": {"selected": 1}} — we use self.hostname
# which is always the device's management IP in netOrk.
listen_val: dict = {self.hostname: {"selected": 1}}
try: try:
self._post("/api/netsnmp/general/set", { listen_val = {str(ipaddress.ip_address(listen)): {"selected": 1}}
"general": { 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", "enabled": "1",
"community": "public", "action": "pass",
"contact": "netork@localhost", "quick": "1",
"location": "Managed by netOrk", "interface": "", # floating: wherever the source comes in
"sysobjid": "", "direction": "in",
"bindip": "", "ipprotocol": "inet6" if ":" in source else "inet",
"listen": listen_val, "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}",
} }
}) })
lines.append(f"[config] SNMP enabled with community 'public', listen={self.hostname}.") self._post("/api/firewall/filter/apply", {})
except Exception as exc: return {"ok": True, "output": f"udp/161 open for {', '.join(sources)} only"}
logger.debug("SNMP config failed: %s", exc)
lines.append(f"[config] error: {exc}")
# 3. Start / restart the SNMP service def _start_snmp_agent(self) -> FixStepOutcomeDict:
try: result = self._post("/api/netsnmp/service/restart")
self._post("/api/netsnmp/service/restart") return {"ok": result.get("status") == "ok", "output": f"net-snmp restart: {result}"}
lines.append("[service] net-snmp restarted.")
except Exception as exc:
logger.debug("SNMP service restart failed, trying start: %s", exc)
try:
self._post("/api/netsnmp/service/start")
lines.append("[service] net-snmp started.")
except Exception as exc2:
lines.append(f"[service] start failed: {exc2}")
# 4. Add floating firewall rule for SNMP (UDP/161 → self)
# OPNsense blocks traffic arriving on non-LAN interfaces by default.
# A floating pass rule makes SNMP reachable from all management nets.
try:
existing = self._post("/api/firewall/filter/searchRule",
{"current": 1, "rowCount": -1, "searchPhrase": "[netork] Allow SNMP"})
if not (existing.get("rows") or []):
self._post("/api/firewall/filter/addRule", {
"rule": {
"enabled": "1",
"sequence": "1",
"action": "pass",
"quick": "1",
"interface": "", # floating — all interfaces
"direction": "in",
"ipprotocol": "inet",
"protocol": "udp",
"source_net": "any",
"source_not": "0",
"destination_net": "(self)",
"destination_not": "0",
"destination_port": "161",
"log": "0",
"floating": "yes",
"descr": "[netork] Allow SNMP",
}
})
lines.append("[firewall] Floating pass rule added for UDP/161.")
else:
lines.append("[firewall] Pass rule already present.")
self._post("/api/firewall/filter/apply", {})
lines.append("[firewall] Rules applied.")
except Exception as exc:
logger.debug("Firewall rule for SNMP failed: %s", exc)
lines.append(f"[firewall] error: {exc}")
# 5. Verify — try actual UDP/161 probe first, fall back to API config check
success = False
try:
import socket as _socket
sock = _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM)
sock.settimeout(3)
community = b"public"
# Minimal SNMPv2c GetRequest for sysDescr
pdu = (b"\x30\x26\x02\x01\x01\x04"
+ bytes([len(community)]) + community
+ b"\xa0\x19\x02\x04\x00\x00\x00\x01"
+ b"\x02\x01\x00\x02\x01\x00"
+ b"\x30\x0b\x30\x09\x06\x05\x2b\x06\x01\x02\x01\x05\x00")
sock.sendto(pdu, (self.hostname, 161))
try:
data, _ = sock.recvfrom(1024)
success = len(data) > 0
lines.append("[ok] SNMP UDP probe successful.")
except _socket.timeout:
lines.append("[warn] SNMP UDP probe timed out — service may be starting.")
finally:
sock.close()
except Exception as exc:
logger.debug("SNMP UDP probe failed: %s", exc)
# Fall back to API config check
try:
cfg = self._get("/api/netsnmp/general/get")
general = cfg.get("general", cfg)
success = str(general.get("enabled", "0")) == "1" and bool(general.get("community"))
lines.append("[ok] SNMP config active (UDP probe unavailable)." if success
else "[warn] SNMP config check failed.")
except Exception:
lines.append("[warn] Could not verify SNMP state.")
return {"success": success, "output": "\n".join(lines)}
def get_port_forwards(self) -> list[dict[str, Any]]: def get_port_forwards(self) -> list[dict[str, Any]]:
"""Destination NAT on the WAN interfaces, as port forwards. """Destination NAT on the WAN interfaces, as port forwards.
@@ -2871,8 +2926,17 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver):
# Re-add all enabled A/AAAA records, one override per logical record. # 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 # 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. # 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 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: for rec in records:
if not rec.get("enabled", True): if not rec.get("enabled", True):
continue continue
+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]
+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)
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "napalm-opnsense" name = "napalm-opnsense"
version = "0.1.0" version = "0.4.0"
description = "NAPALM driver for OPNsense (read-only via REST API)." description = "NAPALM driver for OPNsense (read-only via REST API)."
readme = "README.md" readme = "README.md"
license = { text = "Apache-2.0" } license = { text = "Apache-2.0" }
@@ -24,7 +24,7 @@ classifiers = [
] ]
dependencies = [ dependencies = [
"napalm>=4.0.0", "napalm>=4.0.0",
"napalm_device_types>=0.2.0", "napalm_device_types>=4.17.0",
"requests>=2.28.0", "requests>=2.28.0",
] ]
+418
View File
@@ -0,0 +1,418 @@
{
"gw-01": {
"kea_dhcpv4_get": {
"dhcpv4": {
"ha": {
"enabled": "1",
"this_server_name": "",
"max_unacked_clients": "2",
"%this_server_name": "gw"
}
}
},
"kea_dhcpv4_searchPeer": {
"rows": [
{
"uuid": "574e7b41-a5c7-4682-bc63-f4a99e07f262",
"name": "gw",
"role": "primary",
"%role": "Primary",
"url": "http://10.5.100.2:8001/"
},
{
"uuid": "d6a4e812-4bc8-4713-bebb-b330ea65f587",
"name": "gw-02",
"role": "standby",
"%role": "Standby",
"url": "http://10.5.100.3:8001/"
}
],
"rowCount": 2,
"total": 2,
"current": 1
},
"core_hasync_get": {
"hasync": {
"synchronizetoip": "10.5.100.3",
"syncitems": {
"aliases": {
"value": "Aliases",
"selected": 1
},
"authservers": {
"value": "Authentication Servers",
"selected": 1
},
"captiveportal": {
"value": "Captive Portal",
"selected": 1
},
"certs": {
"value": "Certificates and Authorities",
"selected": 1
},
"cron": {
"value": "Cron",
"selected": 0
},
"ddclient": {
"value": "ddclient",
"selected": 1
},
"dhcrelay": {
"value": "DHCRelay",
"selected": 0
},
"dnsforwarder": {
"value": "Dnsmasq DNS/DHCP",
"selected": 0
},
"categories": {
"value": "Firewall Categories",
"selected": 1
},
"ifgroups": {
"value": "Firewall Groups",
"selected": 1
},
"lvtemplate": {
"value": "Firewall Log Templates",
"selected": 1
},
"rules": {
"value": "Firewall Rules",
"selected": 1
},
"schedules": {
"value": "Firewall Schedules",
"selected": 1
},
"freeradius": {
"value": "Freeradius",
"selected": 0
},
"quagga": {
"value": "FRR",
"selected": 0
},
"hostwatch": {
"value": "Host discovery service",
"selected": 0
},
"suricata": {
"value": "Intrusion Detection",
"selected": 0
},
"ipsec": {
"value": "IPsec",
"selected": 0
},
"kea": {
"value": "Kea DHCP",
"selected": 1
},
"mdns-repeater": {
"value": "mDNS Repeater",
"selected": 0
},
"monit": {
"value": "Monit System Monitoring",
"selected": 0
},
"nat": {
"value": "NAT",
"selected": 1
},
"syslog": {
"value": "Netflow / Insight",
"selected": 0
},
"netsnmp": {
"value": "Netsnmp",
"selected": 0
},
"ntpd": {
"value": "Network Time",
"selected": 1
},
"opendns": {
"value": "OpenDNS",
"selected": 0
},
"ssh": {
"value": "OpenSSH",
"selected": 1
},
"openvpn": {
"value": "OpenVPN",
"selected": 0
},
"qemuguestagent": {
"value": "QEMU Guest Agent",
"selected": 1
},
"radvd": {
"value": "Router Advertisements",
"selected": 0
},
"shaper": {
"value": "Shaper",
"selected": 0
},
"squid": {
"value": "Squid Web Proxy",
"selected": 0
},
"staticroutes": {
"value": "Static Routes",
"selected": 1
},
"syslog-ng": {
"value": "System logging",
"selected": 1
},
"sysctl": {
"value": "System Tunables",
"selected": 1
},
"dnsresolver": {
"value": "Unbound DNS",
"selected": 1
},
"users": {
"value": "Users and Groups",
"selected": 1
},
"virtualip": {
"value": "Virtual IPs",
"selected": 1
},
"wol": {
"value": "Wake on LAN",
"selected": 0
},
"webgui": {
"value": "Web GUI",
"selected": 1
},
"wireguard": {
"value": "WireGuard",
"selected": 1
},
"zabbixagent": {
"value": "Zabbix monitoring agent",
"selected": 0
}
}
}
}
},
"gw-02": {
"kea_dhcpv4_get": {
"dhcpv4": {
"ha": {
"enabled": "1",
"this_server_name": "",
"max_unacked_clients": "2",
"%this_server_name": "gw-02"
}
}
},
"kea_dhcpv4_searchPeer": {
"rows": [
{
"uuid": "574e7b41-a5c7-4682-bc63-f4a99e07f262",
"name": "gw",
"role": "primary",
"%role": "Primary",
"url": "http://10.5.100.2:8001/"
},
{
"uuid": "d6a4e812-4bc8-4713-bebb-b330ea65f587",
"name": "gw-02",
"role": "standby",
"%role": "Standby",
"url": "http://10.5.100.3:8001/"
}
],
"rowCount": 2,
"total": 2,
"current": 1
},
"core_hasync_get": {
"hasync": {
"synchronizetoip": "",
"syncitems": {
"aliases": {
"value": "Aliases",
"selected": 0
},
"authservers": {
"value": "Authentication Servers",
"selected": 0
},
"captiveportal": {
"value": "Captive Portal",
"selected": 0
},
"certs": {
"value": "Certificates and Authorities",
"selected": 0
},
"cron": {
"value": "Cron",
"selected": 0
},
"ddclient": {
"value": "ddclient",
"selected": 0
},
"dhcrelay": {
"value": "DHCRelay",
"selected": 0
},
"dnsforwarder": {
"value": "Dnsmasq DNS/DHCP",
"selected": 0
},
"categories": {
"value": "Firewall Categories",
"selected": 0
},
"ifgroups": {
"value": "Firewall Groups",
"selected": 0
},
"lvtemplate": {
"value": "Firewall Log Templates",
"selected": 0
},
"rules": {
"value": "Firewall Rules",
"selected": 0
},
"schedules": {
"value": "Firewall Schedules",
"selected": 0
},
"freeradius": {
"value": "Freeradius",
"selected": 0
},
"quagga": {
"value": "FRR",
"selected": 0
},
"hostwatch": {
"value": "Host discovery service",
"selected": 0
},
"suricata": {
"value": "Intrusion Detection",
"selected": 0
},
"ipsec": {
"value": "IPsec",
"selected": 0
},
"kea": {
"value": "Kea DHCP",
"selected": 0
},
"mdns-repeater": {
"value": "mDNS Repeater",
"selected": 0
},
"monit": {
"value": "Monit System Monitoring",
"selected": 0
},
"nat": {
"value": "NAT",
"selected": 0
},
"syslog": {
"value": "Netflow / Insight",
"selected": 0
},
"netsnmp": {
"value": "Netsnmp",
"selected": 0
},
"ntpd": {
"value": "Network Time",
"selected": 0
},
"opendns": {
"value": "OpenDNS",
"selected": 0
},
"ssh": {
"value": "OpenSSH",
"selected": 0
},
"openvpn": {
"value": "OpenVPN",
"selected": 0
},
"qemuguestagent": {
"value": "QEMU Guest Agent",
"selected": 0
},
"radvd": {
"value": "Router Advertisements",
"selected": 0
},
"shaper": {
"value": "Shaper",
"selected": 0
},
"squid": {
"value": "Squid Web Proxy",
"selected": 0
},
"staticroutes": {
"value": "Static Routes",
"selected": 0
},
"syslog-ng": {
"value": "System logging",
"selected": 0
},
"sysctl": {
"value": "System Tunables",
"selected": 0
},
"dnsresolver": {
"value": "Unbound DNS",
"selected": 0
},
"users": {
"value": "Users and Groups",
"selected": 0
},
"virtualip": {
"value": "Virtual IPs",
"selected": 0
},
"wol": {
"value": "Wake on LAN",
"selected": 0
},
"webgui": {
"value": "Web GUI",
"selected": 0
},
"wireguard": {
"value": "WireGuard",
"selected": 0
},
"zabbixagent": {
"value": "Zabbix monitoring agent",
"selected": 0
}
}
}
}
}
}
@@ -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 json
import pytest import pytest
import requests
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from napalm_opnsense.opnsense import OPNsenseDriver from napalm_opnsense.opnsense import OPNsenseDriver
@@ -1656,24 +1657,74 @@ class TestRollback:
class TestGetConfigCandidate: class TestGetConfigCandidate:
def test_candidate_empty_without_staged_config(self, driver): def test_candidate_empty_without_staged_config(self, driver):
driver._get = lambda path: "<opnsense/>" driver._get_text = lambda path: "<opnsense/>"
result = driver.get_config() result = driver.get_config()
assert result["candidate"] == "" assert result["candidate"] == ""
def test_candidate_contains_staged_routes(self, driver): 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) driver.load_merge_candidate(config=VALID_ROUTES_CONFIG)
result = driver.get_config() result = driver.get_config()
assert "WAN_GW" in result["candidate"] assert "WAN_GW" in result["candidate"]
def test_candidate_is_valid_json(self, driver): 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) driver.load_merge_candidate(config=VALID_ROUTES_CONFIG)
result = driver.get_config() result = driver.get_config()
parsed = json.loads(result["candidate"]) parsed = json.loads(result["candidate"])
assert isinstance(parsed, list) 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() # create_dhcp_reservation()
# #
@@ -2545,6 +2596,70 @@ class TestSyncDnsZone:
added = [d for p, d in calls if p.endswith("addhostoverride")] added = [d for p, d in calls if p.endswith("addhostoverride")]
assert len(added) == 1 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 # uninstall_package – says what it cannot do
+139
View File
@@ -0,0 +1,139 @@
"""Kea's HA peers and the firewall's configuration sync, as facts (netOrk #1125).
The fixture is what gw-01 and gw-02 answered on 2026-10-16: Kea in hot standby
(gw primary, gw-02 standby) and gw-01 pushing its configuration, Kea and
Unbound included, to gw-02.
"""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from napalm_opnsense.ha import config_sync, dhcp_ha
from napalm_opnsense.opnsense import OPNsenseDriver
PAIR = json.loads((Path(__file__).parent / "fixtures" / "ha_pair.json").read_text())
@pytest.fixture
def driver() -> OPNsenseDriver:
d = OPNsenseDriver("gw-01", "user", "secret")
d._session = MagicMock()
return d
def _answers(node: str):
def _get(path: str):
return {
"/api/kea/dhcpv4/get": PAIR[node]["kea_dhcpv4_get"],
"/api/kea/dhcpv4/searchPeer": PAIR[node]["kea_dhcpv4_searchPeer"],
"/api/core/hasync/get": PAIR[node]["core_hasync_get"],
}[path]
return _get
class TestDhcpHa:
def test_reads_the_peers_with_their_roles(self, driver):
driver._get = _answers("gw-01")
ha = driver.get_dhcp_ha()
assert ha["service"] == "kea-dhcp"
assert ha["enabled"] is True
assert ha["peers"] == [
{"name": "gw", "role": "primary", "url": "http://10.5.100.2:8001/"},
{"name": "gw-02", "role": "standby", "url": "http://10.5.100.3:8001/"},
]
def test_names_this_server_from_the_effective_value(self, driver):
"""`this_server_name` is empty when left at its default -- the
hostname; the effective name is in its `%` twin."""
driver._get = _answers("gw-02")
assert driver.get_dhcp_ha()["this_server"] == "gw-02"
def test_an_explicit_server_name_wins(self):
ha = dhcp_ha(
{"dhcpv4": {"ha": {"enabled": "1", "this_server_name": "left", "%this_server_name": "x"}}},
{"rows": []},
)
assert ha["this_server"] == "left"
def test_ha_switched_off(self):
ha = dhcp_ha({"dhcpv4": {"ha": {"enabled": "0", "this_server_name": ""}}}, {"rows": []})
assert ha["enabled"] is False
assert ha["peers"] == []
assert "this_server" not in ha
def test_a_peer_without_url_has_no_url_key(self):
ha = dhcp_ha(
{"dhcpv4": {"ha": {"enabled": "1"}}},
{"rows": [{"name": "a", "role": "primary", "url": ""}]},
)
assert ha["peers"] == [{"name": "a", "role": "primary"}]
def test_a_failed_read_raises_and_never_says_off(self, driver):
driver._get = MagicMock(side_effect=RuntimeError("timeout"))
with pytest.raises(RuntimeError):
driver.get_dhcp_ha()
def test_an_answer_without_the_ha_section_raises(self):
with pytest.raises(ValueError):
dhcp_ha({"dhcpv4": {}}, {"rows": []})
class TestConfigSync:
def test_the_pushing_side_names_its_peer_and_services(self, driver):
driver._get = _answers("gw-01")
sync = driver.get_config_sync()
assert sync["peer"] == "10.5.100.3"
assert {"kea-dhcp", "unbound", "ntpd", "openssh", "wireguard"} <= set(sync["services"])
assert sync["services"] == sorted(sync["services"])
def test_items_without_a_service_stay_visible(self, driver):
driver._get = _answers("gw-01")
unmapped = driver.get_config_sync()["unmapped"]
assert {"aliases", "rules", "virtualip"} <= set(unmapped)
assert "kea" not in unmapped
def test_only_selected_items_count(self, driver):
driver._get = _answers("gw-01")
sync = driver.get_config_sync()
assert "dnsmasq" not in sync["services"] # dnsforwarder: not selected
assert "cron" not in sync["unmapped"] and "cron" not in sync["services"]
def test_the_receiving_side_pushes_to_no_one(self, driver):
driver._get = _answers("gw-02")
sync = driver.get_config_sync()
assert sync == {"peer": None, "services": [], "unmapped": []}
def test_without_a_peer_selected_items_are_not_pushed(self):
"""Ticked items without a target address push nothing."""
answer = {"hasync": {"synchronizetoip": "", "syncitems": {"kea": {"value": "Kea", "selected": 1}}}}
assert config_sync(answer) == {"peer": None, "services": [], "unmapped": []}
def test_a_failed_read_raises(self, driver):
driver._get = MagicMock(side_effect=RuntimeError("timeout"))
with pytest.raises(RuntimeError):
driver.get_config_sync()
def test_an_answer_without_the_section_raises(self):
with pytest.raises(ValueError):
config_sync({"something": {}})
@@ -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")
+173
View File
@@ -0,0 +1,173 @@
"""What netOrk reached through ``_get``/``_post``, as public methods (netOrk #1113).
netOrk followed the firmware upgrade, edited Unbound's forwards and read FRR's
OSPF state through this driver's private REST helpers. The contracts are
napalm-device-types 4.17's; the OPNsense API calls and FRR's field names stay
here.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from napalm_opnsense.opnsense import OPNsenseDriver
@pytest.fixture
def driver():
with patch("napalm_opnsense.opnsense.requests.Session"):
drv = OPNsenseDriver(hostname="fw", username="k", password="s", optional_args={"verify": False})
drv.session = MagicMock()
yield drv
class TestUpdateProgress:
@pytest.mark.parametrize(
"status, state",
[("running", "running"), ("done", "done"), ("error", "failed"), ("", "idle"), (None, "idle")],
)
def test_the_upgrade_status_as_a_state(self, driver, status, state):
st = {"status": status, "log": "***GOT REQUEST TO UPGRADE***\nfetching\n"}
with patch.object(driver, "_get", return_value=st) as get:
progress = driver.get_update_progress()
get.assert_called_once_with("/api/core/firmware/upgradestatus")
assert progress == {"state": state, "log": "***GOT REQUEST TO UPGRADE***\nfetching\n"}
def test_the_packages_go_with_the_upgrade(self, driver):
"""netOrk named the packages it upgrades; ``apply_updates`` dropped them."""
with patch.object(driver, "_post", return_value={"status": "ok"}) as post:
result = driver.apply_updates(["opnsense", "openssl"])
post.assert_called_once_with(
"/api/core/firmware/upgrade", {"upgrade_packages": ["opnsense", "openssl"]}
)
assert result["success"] is True
def test_no_packages_is_the_whole_upgrade(self, driver):
with patch.object(driver, "_post", return_value={"status": "ok"}) as post:
driver.apply_updates([])
post.assert_called_once_with("/api/core/firmware/upgrade")
ROWS = [
{"uuid": "u1", "enabled": "1", "type": "forward", "domain": "lab.example", "server": "10.0.0.53", "port": "53"},
{"uuid": "u2", "enabled": "0", "type": "forward", "domain": "old.example", "server": "10.0.0.9", "port": ""},
{"uuid": "u3", "enabled": "1", "type": "dot", "domain": "", "server": "9.9.9.9", "port": "853"},
]
class TestDnsForwards:
def _post(self, calls):
def post(path, data=None):
calls.append((path, data))
if path.endswith("searchForward"):
return {"rows": ROWS}
return {"result": "saved", "status": "ok"}
return post
def test_the_forwards_without_dns_over_tls(self, driver):
calls = []
with patch.object(driver, "_post", side_effect=self._post(calls)):
forwards = driver.get_dns_forwards()
assert forwards == [
{"domain": "lab.example", "server": "10.0.0.53", "port": 53, "enabled": True},
{"domain": "old.example", "server": "10.0.0.9", "port": 53, "enabled": False},
]
def test_a_new_domain_is_added_and_applied(self, driver):
calls = []
with patch.object(driver, "_post", side_effect=self._post(calls)):
driver.set_dns_forward("new.example.", "10.0.0.53")
paths = [p for p, _ in calls]
assert paths[-2:] == ["/api/unbound/settings/addForward", "/api/unbound/service/reconfigure"]
added = calls[-2][1]["dot"]
assert added == {"enabled": "1", "type": "forward", "domain": "new.example", "server": "10.0.0.53", "port": "53"}
def test_a_known_domain_is_changed_not_doubled(self, driver):
calls = []
with patch.object(driver, "_post", side_effect=self._post(calls)):
driver.set_dns_forward("lab.example", "10.0.0.54", port=5353)
paths = [p for p, _ in calls]
assert "/api/unbound/settings/setForward/u1" in paths
assert "/api/unbound/settings/addForward" not in paths
def test_a_refusal_is_raised(self, driver):
def post(path, data=None):
if path.endswith("searchForward"):
return {"rows": []}
return {"result": "failed", "validations": {"dot.server": "invalid"}}
with patch.object(driver, "_post", side_effect=post), pytest.raises(RuntimeError, match="invalid"):
driver.set_dns_forward("x.example", "not-an-ip")
def test_removing_deletes_every_forward_of_the_domain(self, driver):
calls = []
with patch.object(driver, "_post", side_effect=self._post(calls)):
driver.remove_dns_forward("lab.example.")
paths = [p for p, _ in calls]
assert paths[-2:] == ["/api/unbound/settings/delForward/u1", "/api/unbound/service/reconfigure"]
def test_removing_an_unknown_domain_changes_nothing(self, driver):
calls = []
with patch.object(driver, "_post", side_effect=self._post(calls)):
driver.remove_dns_forward("nothing.example")
assert [p for p, _ in calls] == ["/api/unbound/settings/searchForward"]
OVERVIEW = {"response": {"routerId": "10.255.0.1", "areas": {"0.0.0.0": {}, "0.0.0.1": {}}}}
INTERFACES = {
"response": {
"interfaces": {
"wg0": {
"ipAddress": "10.255.0.1",
"ipAddressPrefixlen": 24,
"area": "0.0.0.0",
"networkType": "POINTOPOINT",
"authentication": "MessageDigest",
"nbrCount": 2,
"nbrAdjacentCount": 1,
"cost": 10,
}
}
}
}
class TestOspf:
def test_frr_in_neutral_terms(self, driver):
def get(path):
return OVERVIEW if path.endswith("ospfoverview") else INTERFACES
with patch.object(driver, "_get", side_effect=get):
ospf = driver.get_ospf()
assert ospf == {
"router_id": "10.255.0.1",
"areas": ["0.0.0.0", "0.0.0.1"],
"interfaces": {
"wg0": {
"address": "10.255.0.1",
"prefix_length": 24,
"area": "0.0.0.0",
"network_type": "POINTOPOINT",
"authentication": "MessageDigest",
"neighbors": 2,
"neighbors_adjacent": 1,
"cost": 10,
}
},
}
def test_no_ospf_running(self, driver):
with patch.object(driver, "_get", return_value={"response": {}}):
assert driver.get_ospf() == {"router_id": None, "areas": [], "interfaces": {}}
+177
View File
@@ -0,0 +1,177 @@
"""The SNMP agent, set up through napalm-device-types' ``configure_snmp_agent``.
``fix_snmp`` enabled os-net-snmp with community "public" and opened UDP/161 on
every interface to every source (#13). The template's steps take every value
from the caller: community, contact, location, listen address, and the sources
the firewall lets through -- one pass rule each, and the old any-source rule
goes when sources are given.
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from napalm_opnsense.opnsense import OPNsenseDriver
SETTINGS = {
"community": "c0mm",
"allowed_sources": ["10.7.224.20", "10.7.225.0/24"],
"contact": "ops",
"location": "rack 1",
}
class _Api:
"""A firewall's REST API: answers by path, records every POST."""
def __init__(self, *, installed=True, rules=(), saved="saved"):
self.installed = installed
self.rules = list(rules)
self.saved = saved
self.posts: list[tuple[str, dict | None]] = []
def get(self, path):
if path == "/api/netsnmp/general/get":
return {"general": {"enabled": "0"}} if self.installed else {}
raise AssertionError(path)
def post(self, path, body=None):
self.posts.append((path, body))
if path == "/api/netsnmp/general/set":
return {"result": self.saved}
if path == "/api/firewall/filter/searchRule":
return {"rows": self.rules}
if path.startswith("/api/firewall/filter/addRule"):
return {"result": "saved", "uuid": "new"}
if path.startswith("/api/firewall/filter/delRule/"):
return {"result": "deleted"}
if path in ("/api/firewall/filter/apply", "/api/netsnmp/service/restart"):
return {"status": "ok"}
if path.startswith("/api/core/firmware/install/"):
return {"status": "ok", "msg_uuid": "x"}
raise AssertionError(path)
def paths(self):
return [p for p, _ in self.posts]
@pytest.fixture
def api():
return _Api()
@pytest.fixture
def driver(api, monkeypatch):
d = OPNsenseDriver("10.0.0.1", "key", "secret")
d._session = MagicMock()
d._get = api.get
d._post = api.post
monkeypatch.setattr("time.sleep", lambda s: None)
return d
def _body(api, path):
return next(b for p, b in api.posts if p == path)
class TestTheCallersValues:
def test_community_contact_location_and_listen(self, driver, api):
result = driver.configure_snmp_agent({**SETTINGS, "listen": "10.0.0.1"})
assert result["success"] is True
general = _body(api, "/api/netsnmp/general/set")["general"]
assert general["enabled"] == "1"
assert (general["community"], general["contact"], general["location"]) == ("c0mm", "ops", "rack 1")
assert general["listen"] == {"10.0.0.1": {"selected": 1}}
def test_nothing_of_an_application_is_built_in(self, driver, api):
driver.configure_snmp_agent({"community": "c0mm", "allowed_sources": []})
sent = repr(api.posts)
assert "public" not in sent and "netork" not in sent.lower()
def test_a_dns_name_is_not_a_listen_address(self, api, monkeypatch):
d = OPNsenseDriver("gw-01.lan", "key", "secret")
d._session, d._get, d._post = MagicMock(), api.get, api.post
monkeypatch.setattr("time.sleep", lambda s: None)
d.configure_snmp_agent({"community": "c0mm", "allowed_sources": []})
assert _body(api, "/api/netsnmp/general/set")["general"]["listen"] == {}
def test_a_rejected_configuration_fails_the_fix(self, driver, api):
api.saved = "failed"
result = driver.configure_snmp_agent(SETTINGS)
assert result["success"] is False
assert "/api/netsnmp/service/restart" not in api.paths()
class TestTheFirewall:
def test_one_pass_rule_per_allowed_source(self, driver, api):
driver.configure_snmp_agent(SETTINGS)
rules = [b["rule"] for p, b in api.posts if p == "/api/firewall/filter/addRule"]
assert [r["source_net"] for r in rules] == ["10.7.224.20/32", "10.7.225.0/24"]
assert all(r["destination_port"] == "161" and r["protocol"] == "udp" for r in rules)
assert all("netork" not in r["descr"].lower() for r in rules)
assert "/api/firewall/filter/apply" in api.paths()
def test_the_old_any_source_rule_goes(self, api, driver):
api.rules = [{"uuid": "old-1", "description": "[netork] Allow SNMP", "source_net": "any"}]
driver.configure_snmp_agent(SETTINGS)
assert "/api/firewall/filter/delRule/old-1" in api.paths()
def test_rules_of_an_earlier_run_are_replaced_not_doubled(self, api, driver):
api.rules = [{"uuid": "r1", "description": "napalm-opnsense: SNMP from 10.7.224.20/32"}]
driver.configure_snmp_agent(SETTINGS)
assert "/api/firewall/filter/delRule/r1" in api.paths()
assert sum(p == "/api/firewall/filter/addRule" for p in api.paths()) == 2
def test_an_operators_rule_is_left_alone(self, api, driver):
api.rules = [{"uuid": "mine", "description": "SNMP from the monitoring VLAN"}]
driver.configure_snmp_agent(SETTINGS)
assert not any(p.startswith("/api/firewall/filter/delRule/") for p in api.paths())
def test_no_sources_leave_the_firewall_alone(self, driver, api):
result = driver.configure_snmp_agent({"community": "c0mm", "allowed_sources": []})
assert result["success"] is True
assert not any(p.startswith("/api/firewall/") for p in api.paths())
class TestInstallAndStart:
def test_an_installed_plugin_is_not_reinstalled(self, driver, api):
driver.configure_snmp_agent(SETTINGS)
assert not any(p.startswith("/api/core/firmware/install/") for p in api.paths())
assert api.paths()[-1] == "/api/netsnmp/service/restart"
def test_a_missing_plugin_is_installed_first(self, api, driver):
api.installed = False
driver.configure_snmp_agent(SETTINGS)
assert api.paths()[0] == "/api/core/firmware/install/os-net-snmp"
def test_the_old_action_is_gone():
assert not hasattr(OPNsenseDriver, "run_device_action")
assert not hasattr(OPNsenseDriver, "_action_fix_snmp")
@pytest.mark.parametrize("community", ["two words", 'q"uote', ""])
def test_a_community_that_is_not_one_word_is_refused(driver, api, community):
result = driver.configure_snmp_agent({"community": community, "allowed_sources": []})
assert result["success"] is False
assert "/api/netsnmp/general/set" not in api.paths()
+23
View File
@@ -123,3 +123,26 @@ class TestHostStatus:
def test_no_pending_reboot(self, driver): def test_no_pending_reboot(self, driver):
with patch.object(driver, "_get", return_value=_status()): with patch.object(driver, "_get", return_value=_status()):
assert driver.get_host_status()["reboot_required"] is False 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()
def test_no_warnings_of_its_own():
"""The pending updates are get_available_updates; the caller names
updates_available from them (netOrk #1111)."""
from napalm_opnsense.opnsense import OPNsenseDriver
assert "get_device_warnings" not in vars(OPNsenseDriver)
+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()