From 2fb12267342a4173a120df065eab25f027f26b2b Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Wed, 7 Oct 2026 07:20:27 +0200 Subject: [PATCH] feat: report what listens, and which OPNsense service it is 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. --- README.md | 1 + napalm_opnsense/listening.py | 128 +++ napalm_opnsense/opnsense.py | 22 + tests/unit/fixtures/socket_statistics.json | 935 +++++++++++++++++++++ tests/unit/test_listening_sockets.py | 147 ++++ 5 files changed, 1233 insertions(+) create mode 100644 napalm_opnsense/listening.py create mode 100644 tests/unit/fixtures/socket_statistics.json create mode 100644 tests/unit/test_listening_sockets.py diff --git a/README.md b/README.md index 43a7809..f4f6c23 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ arguments. | `get_vlans` | ✅ | `GET /api/interfaces/vlan_settings/search_item` | | `get_mac_address_table` | ❌ | Not applicable (firewall, no L2 switching) | | `ping` | ✅ | `POST /api/diagnostics/ping/set` + `start` + `search_jobs` + `stop`/`remove` | +| `get_listening_sockets` | ✅ | `GET /api/diagnostics/interface/get_socket_statistics` + `/api/core/service/search` + `/api/wireguard/service/show` (WireGuard's kernel sockets by listen port) | | `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. diff --git a/napalm_opnsense/listening.py b/napalm_opnsense/listening.py new file mode 100644 index 0000000..da29596 --- /dev/null +++ b/napalm_opnsense/listening.py @@ -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 "") + ) diff --git a/napalm_opnsense/opnsense.py b/napalm_opnsense/opnsense.py index 9213903..d1c70ff 100644 --- a/napalm_opnsense/opnsense.py +++ b/napalm_opnsense/opnsense.py @@ -53,8 +53,10 @@ import requests from requests.exceptions import RequestException from napalm_device_types import FingerprintRule, FirewallDriver +from napalm_device_types.models import ListeningSocketsDict from napalm.base.exceptions import ConnectionException, ConnectionClosedException, MergeConfigException +from napalm_opnsense.listening import parse_socket_statistics, wireguard_listen_ports from napalm_opnsense.ping_mixin import OPNsensePingMixin from napalm_opnsense.port_forwards import alias_index, port_forwards, wan_interfaces @@ -1789,6 +1791,26 @@ class OPNsenseDriver(OPNsensePingMixin, FirewallDriver): }) return sorted(result, key=lambda x: x["name"].lower()) + def get_listening_sockets(self) -> ListeningSocketsDict: + """Every listening TCP and bound UDP socket, with the OPNsense service behind it. + + Read from ``/api/diagnostics/interface/get_socket_statistics``, which + configd collects as root, so every socket names its process where it has + one (``attributed``). Which service: :mod:`napalm_opnsense.listening`. + """ + statistics = self._get("/api/diagnostics/interface/get_socket_statistics") + services = [service["name"] for service in self.get_services()] + try: + wireguard = wireguard_listen_ports(self._get("/api/wireguard/service/show")) + except Exception as exc: + # No WireGuard here: its kernel sockets stay nobody's. + logger.debug("WireGuard listen ports not read: %s", exc) + wireguard = set() + return { + "attributed": True, + "sockets": parse_socket_statistics(statistics, services, wireguard), + } + def manage_service(self, name: str, action: str) -> dict[str, Any]: """Execute a lifecycle action on an OPNsense service. diff --git a/tests/unit/fixtures/socket_statistics.json b/tests/unit/fixtures/socket_statistics.json new file mode 100644 index 0000000..63a92ae --- /dev/null +++ b/tests/unit/fixtures/socket_statistics.json @@ -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" + } + } + } +} diff --git a/tests/unit/test_listening_sockets.py b/tests/unit/test_listening_sockets.py new file mode 100644 index 0000000..4062c8c --- /dev/null +++ b/tests/unit/test_listening_sockets.py @@ -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")