Merge pull request 'feat: read the sockets of a host without ss from netstat, and procd's services' (#15) from feat/listening-netstat into main

This commit was merged in pull request #15.
This commit is contained in:
2026-10-07 05:21:31 +00:00
4 changed files with 211 additions and 29 deletions
+3 -1
View File
@@ -92,7 +92,9 @@ from `/proc/<pid>/cgroup`, in one round trip. A driver supplies
`_run_listening_sockets_command(command, privileged=)`; the command arrives as one `sh -c`
argument, so a `sudo -n` prefix covers all of it. Without root `ss` names only the login
user's processes, and the reading says so (`attributed: false`) instead of failing. A host
without `ss` raises `ListeningSocketsUnavailable`.
without `ss` is read with `netstat -lntup` (OpenWrt's busybox, old net-tools); on OpenWrt the
cgroup names the procd service (`/services/<name>/<instance>`). A host with neither raises
`ListeningSocketsUnavailable`.
**Update readers raise when they cannot read.** `get_available_updates` returns an empty
list only when nothing is pending; netOrk keeps "pending since" per package, and an empty
+83 -25
View File
@@ -21,8 +21,13 @@ the reading says it is not ``attributed``.
**No ``-H``.** iproute2 before 4.10 has no option to leave out the header and
fails on it, which would read as nothing listening. The parse skips the header
instead. A host without ``ss`` at all (busybox, QNAP) raises
:class:`ListeningSocketsUnavailable`.
instead.
**Without ``ss``, ``netstat``.** OpenWrt's busybox and old net-tools hosts have
no ``ss``; ``netstat -lntup`` lists the same sockets with ``PID/Program``, and
the cgroups are read for its PIDs alike. On OpenWrt the cgroup names the procd
service (``/services/<name>/<instance>``), a jailed one too, whose PID is not the
one procd reports. A host with neither raises :class:`ListeningSocketsUnavailable`.
"""
from __future__ import annotations
@@ -42,23 +47,37 @@ _RC_RE = re.compile(r"^__SS_RC=(\d+)$")
#: One line, POSIX ``sh``, read-only. The frame markers are printed in two
#: halves so that a transport which echoes the command does not show them early.
#: ``ss`` is in ``/usr/sbin`` on some systems, outside a login user's ``PATH``.
#: ``netstat`` names a socket's process as ``PID/Program``, ``ss`` as ``pid=PID``.
LISTENING_SOCKETS_COMMAND = (
"PATH=$PATH:/usr/sbin:/sbin; "
"printf '%s%s\\n' SOCK_ BEGIN; "
"if command -v ss >/dev/null 2>&1; then "
"s=$(ss -lntup 2>&1); r=$?; echo '[ss]'; printf '%s\\n' \"$s\"; echo \"__SS_RC=$r\"; "
"t=ss; s=$(ss -lntup 2>&1); r=$?; "
"pids=$(printf '%s\\n' \"$s\" | grep -o 'pid=[0-9]*' | cut -d= -f2); "
"elif command -v netstat >/dev/null 2>&1; then "
"t=netstat; s=$(netstat -lntup 2>&1); r=$?; "
"pids=$(printf '%s\\n' \"$s\" | grep -o ' [0-9][0-9]*/' | tr -d ' /'); "
"else t=; fi; "
"if [ -n \"$t\" ]; then "
"echo \"[$t]\"; printf '%s\\n' \"$s\"; echo \"__SS_RC=$r\"; "
"echo '[cgroups]'; "
"for p in $(printf '%s\\n' \"$s\" | grep -o 'pid=[0-9]*' | cut -d= -f2 | sort -u); do "
"for p in $(printf '%s\\n' \"$pids\" | sort -u); do "
"sed \"s|^|$p |\" /proc/$p/cgroup 2>/dev/null; done; "
"else echo '[no-ss]'; fi; "
"printf '%s%s\\n' SOCK_ END"
)
_PROTOCOLS = frozenset({"tcp", "udp"})
#: netstat names the IPv6 sockets of net-tools ``tcp6``/``udp6``; busybox does not.
_NETSTAT_PROTOCOLS = {"tcp": "tcp", "tcp6": "tcp", "udp": "udp", "udp6": "udp"}
#: ``1604/dropbear``; net-tools prints ``700/sshd: /usr/sbin``.
_PROGRAM_RE = re.compile(r"^(\d+)/(\S*)")
#: ``users:(("nginx",pid=901,fd=6),("nginx",pid=900,fd=6))``
_USER_RE = re.compile(r'\("((?:[^"\\]|\\.)*)",pid=(\d+),fd=\d+\)')
#: The service a cgroup path runs in: its deepest ``*.service`` component.
_SERVICE_RE = re.compile(r"/([^/]+)\.service(?=/|$)")
#: OpenWrt's procd: ``/services/<name>/<instance>``.
_PROCD_RE = re.compile(r"^/services/([^/]+)(?:/|$)")
#: A container's cgroup: ``docker-<id>.scope`` (systemd driver), ``/docker/<id>`` (cgroupfs).
_CONTAINER_RE = re.compile(
r"(?:docker|libpod)-([0-9a-f]{64})\.scope|/(?:docker|libpod)/([0-9a-f]{64})(?=/|$)"
@@ -66,7 +85,7 @@ _CONTAINER_RE = re.compile(
class ListeningSocketsUnavailable(NotImplementedError):
"""The host has no ``ss``; there is nothing to read and nothing to retry."""
"""The host has neither ``ss`` nor ``netstat``; nothing to read, nothing to retry."""
def _frame(output: str) -> List[str]:
@@ -117,7 +136,10 @@ def _cgroup_paths(lines: List[str]) -> Dict[int, str]:
def _unit(path: Optional[str]) -> Optional[str]:
services = _SERVICE_RE.findall(path or "")
return services[-1] if services else None
if services:
return str(services[-1])
procd = _PROCD_RE.match(path or "")
return str(procd.group(1)) if procd else None
def _container(path: Optional[str]) -> Optional[str]:
@@ -125,19 +147,17 @@ def _container(path: Optional[str]) -> Optional[str]:
return (match.group(1) or match.group(2)) if match else None
def _socket(line: str, paths: Dict[int, str]) -> Optional[ListeningSocketDict]:
parts = line.split()
if len(parts) < 5 or parts[0] not in _PROTOCOLS:
return None
local = _split_local(parts[4])
if local is None:
return None
def _entry(
proto: str,
local: Tuple[str, Optional[str], int],
process: Optional[str],
pid: Optional[int],
paths: Dict[int, str],
) -> ListeningSocketDict:
address, interface, port = local
users = _USER_RE.findall(line)
process, pid = (users[0][0], int(users[0][1])) if users else (None, None)
path = paths.get(pid) if pid is not None else None
return {
"proto": parts[0],
"proto": proto,
"address": address,
"port": port,
"interface": interface,
@@ -148,29 +168,67 @@ def _socket(line: str, paths: Dict[int, str]) -> Optional[ListeningSocketDict]:
}
def _ss_socket(line: str, paths: Dict[int, str]) -> Optional[ListeningSocketDict]:
parts = line.split()
if len(parts) < 5 or parts[0] not in _PROTOCOLS:
return None
local = _split_local(parts[4])
if local is None:
return None
users = _USER_RE.findall(line)
process, pid = (users[0][0], int(users[0][1])) if users else (None, None)
return _entry(parts[0], local, process, pid, paths)
def _netstat_socket(line: str, paths: Dict[int, str]) -> Optional[ListeningSocketDict]:
"""``Proto Recv-Q Send-Q Local Foreign [State] PID/Program`` -- a UDP line
has no state, and ``-`` is a socket without a process."""
parts = line.split()
proto = _NETSTAT_PROTOCOLS.get(parts[0]) if parts else None
if proto is None or len(parts) < 6:
return None
local = _split_local(parts[3])
if local is None:
return None
process, pid = None, None
for token in parts[5:7]:
program = _PROGRAM_RE.match(token)
if program:
pid, process = int(program.group(1)), program.group(2).rstrip(":") or None
break
return _entry(proto, local, process, pid, paths)
_PARSERS = {"ss": _ss_socket, "netstat": _netstat_socket}
def parse_listening_sockets(output: str) -> List[ListeningSocketDict]:
"""Parse what :data:`LISTENING_SOCKETS_COMMAND` printed, sorted by protocol,
port and address.
:raises ListeningSocketsUnavailable: when the host has no ``ss``.
:raises ValueError: when the output carries no intact report, or ``ss`` failed.
:raises ListeningSocketsUnavailable: when the host has neither ``ss`` nor ``netstat``.
:raises ValueError: when the output carries no intact report, or the tool failed.
"""
sections = _sections(_frame(output))
if _NO_SS in sections:
raise ListeningSocketsUnavailable("the host has no ss")
ss_lines = sections.get("ss", [])
statuses = [m.group(1) for m in map(_RC_RE.match, ss_lines) if m]
raise ListeningSocketsUnavailable("the host has neither ss nor netstat")
tool = "netstat" if "netstat" in sections else "ss"
lines = sections.get(tool, [])
statuses = [m.group(1) for m in map(_RC_RE.match, lines) if m]
if not statuses or statuses[-1] != "0":
detail = " ".join(line for line in ss_lines if not _RC_RE.match(line))[:200]
raise ValueError(f"ss did not list the sockets: {detail or 'no exit status'}")
detail = " ".join(line for line in lines if not _RC_RE.match(line))[:200]
raise ValueError(f"{tool} did not list the sockets: {detail or 'no exit status'}")
paths = _cgroup_paths(sections.get("cgroups", []))
sockets = [s for s in (_socket(line, paths) for line in ss_lines) if s is not None]
parse = _PARSERS[tool]
sockets = [s for s in (parse(line, paths) for line in lines) if s is not None]
return sorted(sockets, key=lambda s: (s["proto"], s["port"], s["address"], s["interface"] or ""))
class ListeningSocketsMixin:
"""Adds :meth:`get_listening_sockets` to a driver that can run a command on a Linux host.
With ``ss``, or ``netstat`` where there is none (OpenWrt's busybox).
The template form (README, "Function classes"): the command and its parse
are the same everywhere, so they are concrete here, and a driver supplies
only :meth:`_run_listening_sockets_command` -- how a command reaches its
@@ -205,7 +263,7 @@ class ListeningSocketsMixin:
],
}
:raises ListeningSocketsUnavailable: if the host has no ``ss``.
:raises ListeningSocketsUnavailable: if the host has neither ``ss`` nor ``netstat``.
:raises ValueError: if neither reading carried an intact report.
"""
command = f"sh -c {quote(LISTENING_SOCKETS_COMMAND)}"
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "napalm-device-types"
version = "2.4.0"
version = "2.5.0"
description = "Abstract device-type base classes for NAPALM drivers"
readme = "README.md"
requires-python = ">=3.10"
+124 -2
View File
@@ -5,10 +5,15 @@ listens on -- ``0.0.0.0:5432`` is, ``127.0.0.1:5432`` is not. Reading that is
the same on every Linux host: ``ss`` for the sockets, ``/proc/<pid>/cgroup``
for the systemd unit or container a process belongs to. So both live here once,
and a driver only carries the command across (#658 in netOrk).
A host without ``ss`` -- OpenWrt's busybox, an old net-tools box -- is read
with ``netstat -lntup`` instead, and on OpenWrt the cgroup names the procd
service (#673 in netOrk).
"""
from __future__ import annotations
import os
import shutil
import subprocess
@@ -145,6 +150,14 @@ class TestCgroups:
assert self._unit_and_container(lines) == ("wg-quick@wg0", None)
def test_a_procd_service_on_openwrt(self):
"""procd puts every instance into /services/<name>/<instance>, a jailed
one too -- its PID is not the one procd reports, its cgroup is."""
assert self._unit_and_container("5 0::/services/dnsmasq/cfg01411c\n") == (
"dnsmasq",
None,
)
def test_a_login_session_is_no_unit(self):
assert self._unit_and_container("5 0::/user.slice/user-1000.slice/session-3.scope\n") == (
None,
@@ -176,7 +189,7 @@ class TestFailures:
with pytest.raises(ValueError):
parse_listening_sockets(_wire()[:-len("SOCK_END\n")])
def test_a_host_without_ss_is_unavailable(self):
def test_a_host_without_ss_or_netstat_is_unavailable(self):
with pytest.raises(ListeningSocketsUnavailable):
parse_listening_sockets("SOCK_BEGIN\n[no-ss]\nSOCK_END\n")
@@ -190,6 +203,115 @@ class TestFailures:
assert not issubclass(ListeningSocketsUnavailable, ValueError)
# busybox netstat on OpenWrt 25.12, addresses replaced by documentation ones.
BUSYBOX = """Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 1604/dropbear
tcp 0 0 0.0.0.0:443 0.0.0.0:* LISTEN 1969/uhttpd
tcp 0 0 192.0.2.15:53 0.0.0.0:* LISTEN 1495/dnsmasq
tcp 0 0 127.0.0.1:53 0.0.0.0:* LISTEN 1495/dnsmasq
tcp 0 0 :::22 :::* LISTEN 1604/dropbear
tcp 0 0 fe80::1:53 :::* LISTEN 1495/dnsmasq
tcp 0 0 ::1:53 :::* LISTEN 1495/dnsmasq
udp 0 0 192.0.2.15:53 0.0.0.0:* 1495/dnsmasq
udp 0 0 0.0.0.0:161 0.0.0.0:* 3173/snmpd
udp 0 0 0.0.0.0:5353 0.0.0.0:* -
"""
PROCD = """1495 0::/services/dnsmasq/cfg01411c
1604 0::/services/dropbear/instance1
1969 0::/services/uhttpd/instance1
3173 0::/services/snmpd/instance1
"""
# net-tools netstat on an old Debian: tcp6/udp6, and a program name with a space.
NET_TOOLS = """Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 700/sshd: /usr/sbin
tcp6 0 0 :::22 :::* LISTEN 700/sshd: /usr/sbin
udp6 0 0 :::5353 :::* -
"""
def _netstat(out: str = BUSYBOX, cgroups: str = PROCD, *, rc: int = 0) -> str:
return f"SOCK_BEGIN\n[netstat]\n{out}__SS_RC={rc}\n[cgroups]\n{cgroups}SOCK_END\n"
class TestNetstat:
"""A host without ss (#673 in netOrk): OpenWrt's busybox, old net-tools."""
def test_a_socket_comes_with_its_process_and_procd_service(self):
sockets = _by_port(parse_listening_sockets(_netstat()))
assert sockets[("tcp", 22, "0.0.0.0")] == {
"proto": "tcp",
"address": "0.0.0.0",
"port": 22,
"interface": None,
"process": "dropbear",
"pid": 1604,
"unit": "dropbear",
"container_id": None,
}
@pytest.mark.parametrize(
"proto, port, address",
[("tcp", 22, "::"), ("tcp", 53, "fe80::1"), ("tcp", 53, "::1"), ("tcp", 53, "192.0.2.15")],
)
def test_every_address_form(self, proto, port, address):
assert (proto, port, address) in _by_port(parse_listening_sockets(_netstat()))
def test_a_udp_socket_has_no_state_column(self):
snmpd = _by_port(parse_listening_sockets(_netstat()))[("udp", 161, "0.0.0.0")]
assert (snmpd["process"], snmpd["pid"], snmpd["unit"]) == ("snmpd", 3173, "snmpd")
def test_a_socket_without_a_process_is_kept(self):
mdns = _by_port(parse_listening_sockets(_netstat()))[("udp", 5353, "0.0.0.0")]
assert (mdns["process"], mdns["pid"], mdns["unit"]) == (None, None, None)
def test_the_headers_are_no_sockets(self):
assert len(parse_listening_sockets(_netstat())) == 10
def test_net_tools_names_ipv6_and_programs_its_own_way(self):
sockets = _by_port(parse_listening_sockets(_netstat(NET_TOOLS, "")))
assert sockets[("tcp", 22, "::")]["process"] == "sshd"
assert sockets[("tcp", 22, "::")]["pid"] == 700
assert ("udp", 5353, "::") in sockets
def test_netstat_failing_raises(self):
with pytest.raises(ValueError):
parse_listening_sockets(_netstat("netstat: invalid option -- 'p'\n", "", rc=1))
class TestTheNetstatFallback:
"""The command itself, on a host where ss is missing and netstat is not."""
@pytest.mark.skipif(
any(os.path.exists(f"{d}/ss") for d in ("/usr/sbin", "/sbin")),
reason="ss sits on the PATH the command always adds",
)
def test_it_reads_netstat_when_there_is_no_ss(self, tmp_path):
for tool in ("grep", "cut", "sort", "sed", "tr", "cat"):
(tmp_path / tool).symlink_to(shutil.which(tool))
stub = tmp_path / "netstat"
stub.write_text(f"#!/bin/sh\ncat <<'EOF'\n{BUSYBOX}EOF\n")
stub.chmod(0o755)
out = subprocess.run(
["/bin/sh", "-c", LISTENING_SOCKETS_COMMAND],
capture_output=True,
text=True,
env={"PATH": str(tmp_path)},
timeout=30,
).stdout
assert "[netstat]" in out
sockets = _by_port(parse_listening_sockets(out))
assert sockets[("tcp", 443, "0.0.0.0")]["process"] == "uhttpd"
assert len(sockets) == 10
class TestTheCommand:
def test_the_frame_is_not_in_the_command_itself(self):
"""An echoing transport prints the command back; the markers must only
@@ -260,7 +382,7 @@ class TestTheTemplate:
assert len(reading["sockets"]) == 9
assert [p for _c, p in driver.calls] == [True, False]
def test_a_host_without_ss_is_not_asked_twice(self):
def test_a_host_without_either_is_not_asked_twice(self):
driver = _Driver("SOCK_BEGIN\n[no-ss]\nSOCK_END\n")
with pytest.raises(ListeningSocketsUnavailable):