Author SHA1 Message Date
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
16 changed files with 2734 additions and 25 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 "")
)
+161 -19
View File
@@ -38,6 +38,7 @@ from __future__ import annotations
import difflib import difflib
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 +53,40 @@ _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
from napalm_device_types.models import (
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.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, FirewallDriver):
"""NAPALM driver for OPNsense (read-only, REST API).""" """NAPALM driver for OPNsense (read-only, REST API)."""
VENDOR = "OPNsense" VENDOR = "OPNsense"
@@ -170,6 +197,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 +235,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 +273,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 +323,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 +362,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 +377,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 +905,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 +1858,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 +2173,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."""
@@ -2871,8 +3004,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
+124
View File
@@ -0,0 +1,124 @@
"""The firewall's CARP addresses, read from its interface overview (netOrk #1121).
``/api/interfaces/overview/export`` lists every address of an interface; one bound
to a CARP VHID carries ``vhid`` and this node's CARP ``status``. That covers the
CARP address itself and an IP alias bound to its VHID alike -- both float with the
VHID's MASTER. An address without a VHID stays on this node and is no virtual IP.
``interfaces/vip_settings/search_item`` would list the same addresses, but with
each VHID's CARP password in it. The overview has none, so a secret never reaches
the caller.
What a state means -- MASTER holds, BACKUP stands by -- is CARP protocol
knowledge, so it lives here with the driver that speaks CARP
(:func:`carp_virtual_ip`), not in napalm-device-types, which carries only the
shape of the reading.
"""
from __future__ import annotations
import ipaddress
from typing import Any, Optional
from napalm_device_types.models import VirtualIpDict
#: CARP's state of a VHID on one member -> (running, holds).
_CARP_STATES: dict[str, tuple[bool, bool]] = {
"MASTER": (True, True),
"BACKUP": (True, False),
"INIT": (False, False),
"DISABLED": (False, False),
}
def _address(text: str) -> Optional[tuple[str, Optional[int]]]:
"""``"10.0.0.1/24"`` -> ``("10.0.0.1", 24)``; None for what is no address."""
host, slash, prefix = text.partition("/")
try:
address = ipaddress.ip_address(host)
except ValueError:
return None
if not slash:
return str(address), None
if not prefix.isdigit() or int(prefix) > address.max_prefixlen:
return None
return str(address), int(prefix)
def carp_virtual_ip(
*,
address: str,
prefix_length: Optional[int],
vhid: object,
state: Optional[str],
instance: str,
interface: Optional[str],
) -> Optional[VirtualIpDict]:
"""One address bound to a CARP VHID, as a virtual IP; None for what is no address.
Who holds it comes from CARP's *state* on this member, not from its
interfaces: MASTER holds it, BACKUP stands by, INIT and DISABLED neither run
nor hold. A state this does not know says nothing (``running`` and ``holds``
None), and the caller falls back on the interfaces. *address* may carry its
prefix (``"10.0.0.1/24"``), which then wins over *prefix_length*.
"""
parsed = _address(address.strip()) if address else None
if parsed is None:
return None
normalised, written = parsed
prefix = written if written is not None else prefix_length
if prefix is not None and not 0 <= prefix <= ipaddress.ip_address(normalised).max_prefixlen:
prefix = None
vhid_text = str(vhid).strip() if vhid is not None else ""
running, holds = _CARP_STATES.get((state or "").strip().upper(), (None, None))
return {
"mechanism": "carp",
"instance": instance or f"vhid {vhid_text}",
"address": normalised,
"prefix_length": prefix,
"interface": interface,
"group_key": f"vhid:{vhid_text}" if vhid_text.isdigit() else None,
"running": running,
"holds": holds,
}
def _interfaces(overview: Any) -> list[Any]:
"""The interface list, whether the answer is the list or wraps it."""
if isinstance(overview, dict):
return overview.get("interfaces") or []
if isinstance(overview, list):
return overview
raise ValueError(f"unexpected interface overview: {type(overview).__name__}")
def carp_addresses(overview: Any) -> list[VirtualIpDict]:
"""Every address bound to a CARP VHID in *overview*, sorted by address.
:raises ValueError: when the answer is neither the interface list nor wraps it.
"""
entries: list[VirtualIpDict] = []
for interface in _interfaces(overview):
if not isinstance(interface, dict):
continue
identifier = interface.get("identifier") or ""
for family in ("ipv4", "ipv6"):
for address in interface.get(family) or []:
if not isinstance(address, dict) or not address.get("vhid"):
continue
entry = carp_virtual_ip(
address=str(address.get("ipaddr") or ""),
prefix_length=None,
vhid=address.get("vhid"),
state=address.get("status"),
instance=interface.get("description") or identifier,
interface=interface.get("device") or identifier or None,
)
if entry is not None:
entries.append(entry)
return sorted(entries, key=_order)
def _order(entry: VirtualIpDict) -> tuple[int, int]:
address = ipaddress.ip_address(entry["address"])
return address.version, int(address)
+1 -1
View File
@@ -24,7 +24,7 @@ classifiers = [
] ]
dependencies = [ dependencies = [
"napalm>=4.0.0", "napalm>=4.0.0",
"napalm_device_types>=0.2.0", "napalm_device_types>=4.11.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")
+15
View File
@@ -123,3 +123,18 @@ 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()
+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()