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.
This commit is contained in:
Christian Manivong
2026-10-07 07:20:27 +02:00
parent 8ce7ea52e7
commit 2fb1226734
5 changed files with 1233 additions and 0 deletions
+1
View File
@@ -88,6 +88,7 @@ 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) |
| `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.
+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 "")
)
+22
View File
@@ -53,8 +53,10 @@ import requests
from requests.exceptions import RequestException from requests.exceptions import RequestException
from napalm_device_types import FingerprintRule, FirewallDriver from napalm_device_types import FingerprintRule, FirewallDriver
from napalm_device_types.models import ListeningSocketsDict
from napalm.base.exceptions import ConnectionException, ConnectionClosedException, MergeConfigException 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.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
@@ -1789,6 +1791,26 @@ 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 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.
+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"
}
}
}
}
+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")