diff --git a/README.md b/README.md index d89cbc6..6d41139 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/napalm_openwrt/openwrt.py b/napalm_openwrt/openwrt.py index fe92076..fa11ef1 100644 --- a/napalm_openwrt/openwrt.py +++ b/napalm_openwrt/openwrt.py @@ -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.""" diff --git a/pyproject.toml b/pyproject.toml index 1d8318c..79e52af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/tests/unit/test_listening_sockets.py b/tests/unit/test_listening_sockets.py new file mode 100644 index 0000000..0d0a221 --- /dev/null +++ b/tests/unit/test_listening_sockets.py @@ -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")