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
+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 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.