feat: report listening sockets
CI / test (3.10) (push) Successful in 27s
CI / test (3.11) (push) Successful in 27s
CI / test (3.12) (push) Successful in 37s
CI / test (3.10) (pull_request) Successful in 42s
CI / test (3.11) (pull_request) Successful in 42s
CI / test (3.12) (pull_request) Successful in 38s

get_listening_sockets() in the shape of napalm-device-types'
ListeningSocketsMixin, whose ss/cgroup reading is Linux's, and with its
rule: read as root first, and without root when that brings nothing back,
which the reading reports as `attributed: False`.

- FreeBSD: `sockstat -46lq -P tcp,udp`, which sees every socket either way.
- OpenBSD: `fstat -n` as root (the sockets nothing is connected to; port 0
  is unbound), `netstat -an` without root, as fstat shows a user only
  their own processes; those sockets come without a process.
- Addresses as ss prints them: `*` becomes 0.0.0.0 or :: by family, IPv6
  without brackets, a zone (`fe80::1%lo0`) becomes the interface. One entry
  per socket, the first process holding it.

No get_kernel_facts on purpose: KernelFactsMixin reports a Linux kernel's
modules and CONFIG_ options, which a BSD kernel does not have.

Fixtures recorded on the FreeBSD 15.1 and OpenBSD 7.9 VMs with test
listeners on 127.0.0.1 and ::1 (NetOrk/netork#798).
This commit is contained in:
2026-10-08 09:26:21 +02:00
parent 14afd33798
commit 5f53de0c25
15 changed files with 1009 additions and 0 deletions
+32
View File
@@ -42,6 +42,10 @@ class BsdDriver(OSDriver):
#: Prints ``key=value`` lines; see :meth:`_platform`.
PLATFORM_COMMAND = ""
ROUTES_COMMAND = "netstat -rn"
#: Lists listening sockets with their processes; as root it names them all.
LISTENING_COMMAND = ""
#: Lists them without root, if the command above needs it to see them all.
LISTENING_FALLBACK_COMMAND = ""
#: lstart is five words; :func:`parse.processes` reads it as one field.
PS_COMMAND = "ps -axww -o user,pid,ppid,%cpu,%mem,vsz,rss,stat,lstart,command"
@@ -245,6 +249,34 @@ class BsdDriver(OSDriver):
"""Neither BSD ships an LLDP daemon in its base system."""
return {}
# -- listening sockets -------------------------------------------------------
def _parse_listening(self, output: str, *, attributed: bool) -> list[dict[str, Any]]:
"""Parse :attr:`LISTENING_COMMAND` (*attributed*) or its fallback."""
raise NotImplementedError
def get_listening_sockets(self) -> dict[str, Any]:
"""Every listening TCP and bound UDP socket, with the process holding it.
Same shape as napalm-device-types' ``ListeningSocketsMixin`` -- whose
``ss``/cgroup reading is Linux's -- and the same rule: read as root
first, and without root when that brings nothing back, which the
reading then says (``attributed: False``). BSD has no systemd units or
container IDs to name.
"""
privileged = self.run_command(self.LISTENING_COMMAND, privileged=True)
if privileged.exit_code == 0 and privileged.stdout.strip():
return {
"attributed": True,
"sockets": self._parse_listening(privileged.stdout, attributed=True),
}
command = self.LISTENING_FALLBACK_COMMAND or self.LISTENING_COMMAND
plain = self.run_command(command, privileged=False)
return {
"attributed": False,
"sockets": self._parse_listening(plain.stdout, attributed=False),
}
# -- accounts, processes, cron -----------------------------------------------
def get_users(self) -> list[dict[str, Any]]:
+6
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from napalm_device_types import FingerprintRule
from napalm_bsd import parse
from napalm_bsd.base import BsdDriver
@@ -28,6 +29,11 @@ class FreeBSDDriver(BsdDriver):
)
# -W: FreeBSD cuts long destinations to the column width otherwise.
ROUTES_COMMAND = "netstat -rnW"
# sockstat names the process of every socket it can see; root sees them all.
LISTENING_COMMAND = "sockstat -46lq -P tcp,udp"
def _parse_listening(self, output: str, *, attributed: bool) -> list[dict]:
return parse.sockstat(output)
def _os_version(self) -> str:
version = super()._os_version()
+8
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from napalm_device_types import FingerprintRule
from napalm_bsd import parse
from napalm_bsd.base import BsdDriver
@@ -25,6 +26,13 @@ class OpenBSDDriver(BsdDriver):
'printf "%s=%s\\n" "$k" "$(sysctl -n hw.$k 2>/dev/null)"; done'
)
# fstat shows a user only their own processes; netstat shows every socket, unnamed.
LISTENING_COMMAND = "fstat -n"
LISTENING_FALLBACK_COMMAND = "netstat -an -f inet; netstat -an -f inet6"
def _parse_listening(self, output: str, *, attributed: bool) -> list[dict]:
return parse.fstat_sockets(output) if attributed else parse.netstat_listening(output)
def _hardware(self) -> tuple[str, str, str]:
hw = self._platform()
return hw.get("vendor", ""), hw.get("product", ""), hw.get("serialno", "")
+90
View File
@@ -257,3 +257,93 @@ def boottime(text: str) -> Optional[int]:
"""``sysctl -n kern.boottime``: FreeBSD ``{ sec = N, … }``, OpenBSD ``N``."""
match = re.search(r"sec = (\d+)", text) or re.fullmatch(r"\s*(\d+)\s*", text)
return int(match[1]) if match else None
def _socket(
proto: str, local: str, family: str, process: Optional[str], pid: Optional[int]
) -> Optional[dict[str, Any]]:
"""A ListeningSocketDict from a local endpoint: ``*:22``, ``[::1]:25``,
``127.0.0.1:25``, ``[fe80::1%lo0]:25`` (sockstat, fstat) or ``*.22``,
``::1.25``, ``fe80::1%lo0.25`` (netstat)."""
if local.startswith("["):
address, _, port = local[1:].partition("]:")
elif local.count(":") == 1 or local.startswith("*:"):
address, _, port = local.rpartition(":")
else:
address, _, port = local.rpartition(".")
if not port.isdigit() or int(port) == 0:
return None
address, _, zone = address.partition("%")
if address == "*":
address = "0.0.0.0" if family == "4" else "::"
return {
"proto": proto,
"address": address,
"port": int(port),
"interface": zone or None,
"process": process,
"pid": pid,
"unit": None,
"container_id": None,
}
def _unique(sockets: list[Optional[dict[str, Any]]]) -> list[dict[str, Any]]:
"""One entry per socket, the first process holding it, as ss reports it."""
seen: set[tuple] = set()
result = []
for s in sockets:
if s is None:
continue
key = (s["proto"], s["address"], s["port"], s["interface"])
if key not in seen:
seen.add(key)
result.append(s)
return result
def sockstat(text: str) -> list[dict[str, Any]]:
"""FreeBSD ``sockstat -46lq -P tcp,udp``: USER COMMAND PID FD PROTO LOCAL FOREIGN."""
sockets = []
for line in text.splitlines():
words = line.split()
if len(words) < 7 or not words[2].isdigit() or words[4][:3] not in ("tcp", "udp"):
continue
proto, family = words[4][:3], words[4][3:]
sockets.append(_socket(proto, words[5], family, words[1], int(words[2])))
return _unique(sockets)
def fstat_sockets(text: str) -> list[dict[str, Any]]:
"""OpenBSD ``fstat -n``: the internet sockets nothing is connected to.
``USER CMD PID FD internet[6] stream|dgram tcp|udp [0xPCB] LOCAL [ARROW REMOTE]``;
a socket with an arrow is connected, one bound to port 0 is not bound.
"""
sockets = []
for line in text.splitlines():
words = line.split()
if len(words) < 8 or words[4] not in ("internet", "internet6"):
continue
if any(arrow in words for arrow in ("<--", "-->", "<->")):
continue
family = "6" if words[4] == "internet6" else "4"
sockets.append(_socket(words[6], words[-1], family, words[1], int(words[2])))
return _unique(sockets)
def netstat_listening(text: str) -> list[dict[str, Any]]:
"""``netstat -an``: listening TCP and bound UDP sockets, without their process."""
sockets = []
for line in text.splitlines():
words = line.split()
if len(words) < 5 or words[0][:3] not in ("tcp", "udp"):
continue
proto, family = words[0][:3], "6" if words[0].endswith("6") else "4"
local, foreign = words[3], words[4]
if proto == "tcp" and words[-1] != "LISTEN":
continue
if proto == "udp" and foreign != "*.*":
continue
sockets.append(_socket(proto, local, family, None, None))
return _unique(sockets)