Author SHA1 Message Date
Christian Manivong 79e52f060e feat: read the host's listening sockets through napalm-device-types
LinuxDriver mixes in ListeningSocketsMixin (napalm-device-types 2.4.0) and
supplies its transport: privileged readings go through _run_privileged --
as they are for a root login, through sudo with a password, sudo -n
without one -- and the rest through _send. The command arrives as one
sh -c argument, so sudo covers the whole script; when sudo refuses, the
mixin reads again without it.

OpenMediaVault and QNAP QTS inherit it; a host without ss raises
ListeningSocketsUnavailable.

For netOrk#658.
2026-10-06 18:19:31 +02:00
christianmanivong 3571149639 Merge pull request 'feat: restart the host (reboot_host)' (#13) from feat/reboot-host into master 2026-10-06 11:05:44 +00:00
Christian Manivong 60b56c37e0 feat: restart the host (reboot_host)
netOrk asks a driver for reboot_host before it offers a restart, and the
Linux driver had none, so a Linux host -- and OpenMediaVault and QTS, which
inherit this driver -- could not be restarted from netOrk at all (netOrk
#637). reboot_host runs /sbin/reboot detached and two seconds late through
_run_privileged: the launcher's exit status comes back before the host goes
down, and closing the session cannot take the restart with it. A refusal
(sudo without a password) raises with what the host said.
2026-10-06 13:05:27 +02:00
3 changed files with 138 additions and 2 deletions
+37 -1
View File
@@ -37,6 +37,7 @@ from napalm_device_types import (
FingerprintRule,
HostStatusMixin,
KernelFactsMixin,
ListeningSocketsMixin,
OSDriver,
SystemdServicesMixin,
SystemdUnavailable,
@@ -120,6 +121,11 @@ _REFRESH = {
}
_YUM_SECURITY_COMMAND = "LC_ALL=C yum updateinfo list security -q 2>/dev/null"
#: Restart the host two seconds later, detached from this session: the launcher's
#: exit status comes back before the host goes down, and closing the session
#: cannot take the restart with it.
_REBOOT_DETACHED = "sh -c '(trap \"\" HUP; sleep 2; /sbin/reboot) </dev/null >/dev/null 2>&1 &'"
#: What to do when sudo wants a password netOrk does not have.
_SUDO_PASSWORD_HINT = (
"sudo requires a password on this device but none is configured in netOrk. "
@@ -209,7 +215,9 @@ def _short_image_id(raw: str) -> str:
return raw.strip().removeprefix("sha256:")[:12]
class LinuxDriver(KernelFactsMixin, SystemdServicesMixin, HostStatusMixin, OSDriver):
class LinuxDriver(
KernelFactsMixin, ListeningSocketsMixin, SystemdServicesMixin, HostStatusMixin, OSDriver
):
"""NAPALM driver for generic Linux systems.
Connects via SSH (netmiko ``linux`` device type) and auto-detects the
@@ -389,6 +397,21 @@ class LinuxDriver(KernelFactsMixin, SystemdServicesMixin, HostStatusMixin, OSDri
return self._sudo(command, read_timeout=timeout)
return self._send(f"sudo -n {command}", read_timeout=timeout)
def reboot_host(self) -> None:
"""Restart the host (``HostRebootMixin``); returns once the restart is under way.
:raises RuntimeError: when the host refuses -- sudo without a password,
no ``reboot`` -- or its answer carried no exit status.
"""
output, status = _split_status(
self._run_privileged(f"{_REBOOT_DETACHED}; echo {_RC_MARKER}$?", 30)
)
if status != 0:
reason = output or f"the reboot command exited with status {status}"
if "password is required" in output:
reason = f"{reason}\n{_SUDO_PASSWORD_HINT}"
raise RuntimeError(reason)
def _run_host_status_command(self, command: str) -> str:
"""The transport for ``HostStatusMixin.get_host_status``: read-only, no sudo."""
return self._send(command, read_timeout=60)
@@ -949,6 +972,19 @@ class LinuxDriver(KernelFactsMixin, SystemdServicesMixin, HostStatusMixin, OSDri
"""The transport for ``KernelFactsMixin.get_kernel_facts``: read-only, no sudo."""
return self._send(command, read_timeout=60)
# ------------------------------------------------------------------
# ListeningSocketsMixin – the transport for get_listening_sockets
# ------------------------------------------------------------------
def _run_listening_sockets_command(self, command: str, *, privileged: bool) -> str:
"""The transport for ``ListeningSocketsMixin.get_listening_sockets``.
Read-only either way; root only so that ``ss`` names every process.
"""
if privileged:
return self._run_privileged(command, 60)
return self._send(command, read_timeout=60)
# ------------------------------------------------------------------
# OSDriver – package management
# ------------------------------------------------------------------
+1 -1
View File
@@ -37,7 +37,7 @@ classifiers = [
]
dependencies = [
"napalm>=4.0",
"napalm-device-types>=2.3.0",
"napalm-device-types>=2.4.0",
"netmiko>=4.0.0",
"paramiko>=5.0.0", # CVE-2026-44405
]
+100
View File
@@ -1517,3 +1517,103 @@ class TestTerminalCodes:
driver._device.send_command.side_effect = ["0", "Hit:1 noble InRelease\n\x1b>__NETORK_RC=0"]
assert driver.refresh_available_updates()["success"] is True
class TestRebootHost:
"""``reboot_host`` (napalm-device-types' ``HostRebootMixin``) restarts the host.
Without it netOrk could not restart a Linux host at all: its capability check
looks for ``reboot_host`` and found nothing (netOrk #637). The restart is
detached and a moment late, so the launcher's exit status comes back before
the host goes down, and closing the session cannot take it along.
"""
def test_the_driver_can_restart_its_host(self):
assert callable(getattr(LinuxDriver, "reboot_host", None))
def test_the_restart_is_detached_and_privileged(self, driver):
driver._root = False
driver._device.send_command.return_value = "\n__NETORK_RC=0"
driver.reboot_host()
sent = driver._device.send_command.call_args[0][0]
assert sent.startswith("sudo -n sh -c ")
assert "/sbin/reboot" in sent and "trap" in sent and "&" in sent
assert sent.endswith("echo __NETORK_RC=$?")
def test_a_refusal_is_raised_with_what_the_host_said(self, driver):
driver._root = False
driver._device.send_command.return_value = "sudo: a password is required\n__NETORK_RC=1"
with pytest.raises(RuntimeError, match="password is required"):
driver.reboot_host()
def test_no_exit_status_is_no_success(self, driver):
driver._root = True
driver._device.send_command.return_value = "something else"
with pytest.raises(RuntimeError):
driver.reboot_host()
# ---------------------------------------------------------------------------
# Listening sockets: ss and the processes' cgroups, as root when it can
# ---------------------------------------------------------------------------
_SOCKETS = (
"SOCK_BEGIN\n[ss]\n"
'tcp LISTEN 0 128 0.0.0.0:5432 0.0.0.0:* users:(("postgres",pid=812,fd=6))\n'
"__SS_RC=0\n[cgroups]\n"
"812 0::/system.slice/system-postgresql.slice/postgresql@16-main.service\n"
"SOCK_END\n"
)
class TestGetListeningSockets:
def _sent(self, driver) -> list[str]:
return [c[0][0] for c in driver._device.send_command.call_args_list]
def test_it_reads_with_the_shared_command(self, driver):
from napalm_device_types import ListeningSocketsMixin
assert isinstance(driver, ListeningSocketsMixin)
driver._root = True
driver._device.send_command.return_value = _SOCKETS
reading = driver.get_listening_sockets()
assert reading["attributed"] is True
[socket] = reading["sockets"]
assert (socket["port"], socket["unit"]) == (5432, "postgresql@16-main")
assert self._sent(driver)[0].startswith("sh -c '")
def test_without_a_sudo_password_the_whole_script_runs_under_sudo_n(self, driver):
driver._root = False
driver._device.send_command.return_value = _SOCKETS
driver.get_listening_sockets()
assert self._sent(driver)[0].startswith("sudo -n sh -c '")
def test_with_a_sudo_password_it_goes_through_sudo(self, driver):
driver._root = False
driver._sudo_password = "pw" # noqa: S105
driver._device.send_command.return_value = _SOCKETS
driver.get_listening_sockets()
sent = self._sent(driver)[0]
assert sent.startswith("echo pw | sudo -S")
assert "sh -c '" in sent
def test_when_sudo_refuses_it_reads_what_the_user_may_see(self, driver):
driver._root = False
driver._device.send_command.side_effect = ["sudo: a password is required", _SOCKETS]
reading = driver.get_listening_sockets()
assert reading["attributed"] is False
refused, plain = self._sent(driver)
assert refused.startswith("sudo -n sh -c '")
assert plain.startswith("sh -c '")