feat: read the sockets of a host without ss from netstat, and procd's services

OpenWrt has no ss: the listening-socket command fell through to [no-ss]
and the reading raised ListeningSocketsUnavailable. Without ss it now runs
netstat -lntup (busybox and net-tools both), and the cgroups are read for
its PIDs alike.

- netstat names a socket's process as PID/Program; a UDP line has no
  state column, "-" is a socket without a process, and net-tools prints
  tcp6/udp6 and program names with a space ("sshd: /usr/sbin").
- On OpenWrt the cgroup is /services/<name>/<instance>, so the unit is
  the procd service -- a jailed one too, whose PID is not the one procd
  reports (dnsmasq under ujail).
- A host with neither tool still raises ListeningSocketsUnavailable.

Checked against a real OpenWrt 25.12.2 access point, and that a Linux and
a Proxmox host still read the same sockets with the longer command.

2.5.0. For netOrk#673.
This commit is contained in:
2026-10-07 07:20:27 +02:00
parent f833e23422
commit b8b89acee1
4 changed files with 211 additions and 29 deletions
+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):