Merge pull request 'feat: report what listens, and which procd service it is' (#6) from feat/listening-sockets into master

This commit was merged in pull request #6.
This commit is contained in:
2026-10-07 05:21:32 +00:00
4 changed files with 109 additions and 1 deletions
+1
View File
@@ -64,6 +64,7 @@ device.close()
| `get_environment` | ✅ | CPU from `/proc/stat`, memory from `/proc/meminfo` |
| `get_lldp_neighbors` | ✅ | Requires `lldpd` package installed on device |
| `get_lldp_neighbors_detail` | ✅ | Requires `lldpd` package installed on device |
| `get_listening_sockets` | ✅ | busybox `netstat -lntup`, procd service from the process's cgroup (napalm-device-types `ListeningSocketsMixin`); over an SSH exec channel |
## Configuration management
+18
View File
@@ -27,6 +27,7 @@ from netmiko import ConnectHandler
from netmiko.exceptions import NetmikoTimeoutException, NetmikoAuthenticationException
from napalm_device_types import AccessPointDriver, FingerprintRule
from napalm_device_types.listening import ListeningSocketsMixin
from napalm.base.exceptions import (
ConnectionException,
ConnectionClosedException,
@@ -52,6 +53,7 @@ class OpenWrtDriver(
OpenWrtSystemMixin,
OpenWrtPackageMixin,
OpenWrtRoutingMixin,
ListeningSocketsMixin,
AccessPointDriver,
):
"""NAPALM driver for OpenWrt routers and access-points."""
@@ -161,6 +163,22 @@ class OpenWrtDriver(
except (socket.error, EOFError) as exc:
raise ConnectionClosedException(str(exc)) from exc
def _run_listening_sockets_command(self, command: str, *, privileged: bool) -> str:
"""Run the listening-socket command for ``ListeningSocketsMixin``.
Over an SSH exec channel of its own, not the interactive shell: busybox
ash cuts a typed line at 512 characters, and the command is longer. As
root it names every socket's process; a login other than root has no way
to become root here, so a privileged reading comes back empty and the
mixin reads again without attributing.
"""
if privileged and self.username != "root":
return ""
_stdin, stdout, _stderr = self.device.remote_conn_pre.exec_command(
command, timeout=self.timeout
)
return stdout.read().decode("utf-8", "replace")
@staticmethod
def _parse_openwrt_release(output: str) -> dict[str, str]:
"""Parse ``/etc/openwrt_release`` key=value pairs."""
+1 -1
View File
@@ -26,7 +26,7 @@ classifiers = [
]
dependencies = [
"napalm>=4.0.0",
"napalm_device_types>=0.1.0",
"napalm_device_types>=2.5.0",
"netmiko>=4.0.0",
"paramiko>=5.0.0", # CVE-2026-44405
"netaddr",
+89
View File
@@ -0,0 +1,89 @@
"""What listens on an OpenWrt device, and which procd service it is (netOrk #673).
The command and its parse are napalm-device-types' (``ListeningSocketsMixin``):
OpenWrt has no ``ss``, so the command falls back to busybox ``netstat``, and the
cgroup of each process names its procd service. The driver only carries the
command across -- over an SSH exec channel of its own, because busybox ash cuts
an interactive line at 512 characters and the command is longer.
The netstat output is a real OpenWrt 25.12.2 access point's, with documentation
addresses.
"""
from __future__ import annotations
import io
from unittest.mock import MagicMock, patch
import pytest
from napalm_openwrt.openwrt import OpenWrtDriver
REPORT = """SOCK_BEGIN
[netstat]
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 :::443 :::* LISTEN 1969/uhttpd
udp 0 0 0.0.0.0:161 0.0.0.0:* 3173/snmpd
__SS_RC=0
[cgroups]
1495 0::/services/dnsmasq/cfg01411c
1604 0::/services/dropbear/instance1
1969 0::/services/uhttpd/instance1
3173 0::/services/snmpd/instance1
SOCK_END
"""
def _driver(username: str = "root", report: str = REPORT) -> OpenWrtDriver:
with patch("napalm_openwrt.openwrt.ConnectHandler"):
drv = OpenWrtDriver(hostname="192.0.2.15", username=username, password="")
drv.device = MagicMock()
exec_command = drv.device.remote_conn_pre.exec_command
exec_command.side_effect = lambda *_a, **_k: (None, io.BytesIO(report.encode()), None)
return drv
def test_every_socket_with_its_procd_service():
reading = _driver().get_listening_sockets()
assert reading["attributed"] is True
services = {(s["proto"], s["port"], s["address"]): s["unit"] for s in reading["sockets"]}
assert services == {
("tcp", 22, "0.0.0.0"): "dropbear",
("tcp", 53, "192.0.2.15"): "dnsmasq",
("tcp", 443, "0.0.0.0"): "uhttpd",
("tcp", 443, "::"): "uhttpd",
("udp", 161, "0.0.0.0"): "snmpd",
}
def test_the_command_goes_over_an_exec_channel_not_the_shell():
drv = _driver()
drv.get_listening_sockets()
[call] = drv.device.remote_conn_pre.exec_command.call_args_list
assert call.args[0].startswith("sh -c '")
drv.device.send_command.assert_not_called()
def test_a_login_other_than_root_reads_without_attributing():
drv = _driver(username="admin")
reading = drv.get_listening_sockets()
assert reading["attributed"] is False
assert len(drv.device.remote_conn_pre.exec_command.call_args_list) == 1
def test_a_report_cut_short_raises():
with pytest.raises(ValueError):
_driver(report=REPORT.replace("SOCK_END\n", "")).get_listening_sockets()
def test_netork_finds_it():
"""netOrk asks ``hasattr(driver, "get_listening_sockets")``."""
assert hasattr(OpenWrtDriver, "get_listening_sockets")