feat: read what listens on which address, and which service it is, once for every driver
Whether a service is reachable from outside its host is decided by the address it listens on: 0.0.0.0:5432 is, 127.0.0.1:5432 is not. Reading that is the same on every Linux host, so the command and its parse live here once and a driver only carries the command across (ListeningSocketsMixin, _run_listening_sockets_command). One framed round trip: ss -lntup for every listening TCP and bound UDP socket, then /proc/<pid>/cgroup for each process holding one, which names the systemd service (v2, nested slices, v1's name=systemd hierarchy) or the container (docker-<id>.scope, /docker/<id>) it runs in. - Root: only root sees every process. The script goes as one sh -c argument, so a sudo -n prefix covers all of it; when that brings no report back the reading runs again unprivileged and says it is not attributed. - No -H: iproute2 before 4.10 fails on it, which would read as nothing listening. The header is skipped instead. - A host without ss raises ListeningSocketsUnavailable; a report cut short or a failing ss raises ValueError. - The reading is raw: docker-proxy shows up as docker.service, loopback as loopback. What counts as reachable is the consumer's call. 2.4.0. For netOrk#658.
This commit is contained in:
@@ -42,6 +42,7 @@ instead of being restated on every role that happens to need it:
|
||||
* :class:`~napalm_device_types.host_reboot.HostRebootMixin`
|
||||
* :class:`~napalm_device_types.interface_filter.InterfaceFilterMixin`
|
||||
* :class:`~napalm_device_types.kernel.KernelFactsMixin`
|
||||
* :class:`~napalm_device_types.listening.ListeningSocketsMixin`
|
||||
* :class:`~napalm_device_types.mac_acl.MacAclMixin`
|
||||
* :class:`~napalm_device_types.nat_vpn.NatVpnMixin`
|
||||
* :class:`~napalm_device_types.packages.PackageManagementMixin`
|
||||
@@ -68,6 +69,12 @@ from napalm_device_types.host_reboot import HostRebootMixin
|
||||
from napalm_device_types.interface_filter import InterfaceFilterMixin
|
||||
from napalm_device_types.kernel import KERNEL_FACTS_COMMAND, KernelFactsMixin, parse_kernel_facts
|
||||
from napalm_device_types.lag import add_lag_interfaces
|
||||
from napalm_device_types.listening import (
|
||||
LISTENING_SOCKETS_COMMAND,
|
||||
ListeningSocketsMixin,
|
||||
ListeningSocketsUnavailable,
|
||||
parse_listening_sockets,
|
||||
)
|
||||
from napalm_device_types.mac_acl import MacAclMixin
|
||||
from napalm_device_types.media import MediaDriver
|
||||
from napalm_device_types.nat_vpn import NatVpnMixin
|
||||
@@ -123,6 +130,10 @@ __all__ = [
|
||||
"KernelFactsMixin",
|
||||
"KERNEL_FACTS_COMMAND",
|
||||
"parse_kernel_facts",
|
||||
"LISTENING_SOCKETS_COMMAND",
|
||||
"ListeningSocketsMixin",
|
||||
"ListeningSocketsUnavailable",
|
||||
"parse_listening_sockets",
|
||||
"PhoneDriver",
|
||||
"PingSweepMixin",
|
||||
"PortSpec",
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Listening sockets: what listens on which address, and which service it is.
|
||||
|
||||
Whether a service is reachable from outside its host is decided by what it
|
||||
listens on -- ``0.0.0.0:5432`` is, ``127.0.0.1:5432`` is not -- and that is read
|
||||
the same way on every Linux host. So the command and its parse live here once,
|
||||
and a driver only carries the command across.
|
||||
|
||||
**One round trip.** ``ss -lntup`` lists every listening TCP and bound UDP
|
||||
socket with the processes holding it; for each of those processes,
|
||||
``/proc/<pid>/cgroup`` says which systemd service or container it runs in. The
|
||||
report is framed, and a report whose end is missing raises: a list cut short
|
||||
must never read as sockets that closed.
|
||||
|
||||
**Root, and without it.** Only root sees every process behind a socket.
|
||||
The whole script therefore goes to the host as one ``sh -c`` argument -- a
|
||||
driver that prefixes ``sudo -n`` would otherwise run only its first command as
|
||||
root. When that call brings no report back (no sudo, a wrong password), the
|
||||
command runs again without privilege: the sockets are still worth having, and
|
||||
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`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from shlex import quote
|
||||
from typing import Dict, List, Optional, Tuple, TYPE_CHECKING
|
||||
|
||||
from napalm_device_types.models import ListeningSocketDict, ListeningSocketsDict
|
||||
from napalm_device_types.terminal import strip_terminal_codes
|
||||
|
||||
_BEGIN = "SOCK_BEGIN"
|
||||
_END = "SOCK_END"
|
||||
_NO_SS = "no-ss"
|
||||
_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``.
|
||||
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\"; "
|
||||
"echo '[cgroups]'; "
|
||||
"for p in $(printf '%s\\n' \"$s\" | grep -o 'pid=[0-9]*' | cut -d= -f2 | 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"})
|
||||
#: ``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(?=/|$)")
|
||||
#: 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})(?=/|$)"
|
||||
)
|
||||
|
||||
|
||||
class ListeningSocketsUnavailable(NotImplementedError):
|
||||
"""The host has no ``ss``; there is nothing to read and nothing to retry."""
|
||||
|
||||
|
||||
def _frame(output: str) -> List[str]:
|
||||
lines = [line.strip() for line in strip_terminal_codes(output).splitlines()]
|
||||
try:
|
||||
start = lines.index(_BEGIN)
|
||||
end = lines.index(_END, start)
|
||||
except ValueError:
|
||||
raise ValueError("no intact listening socket report in the output") from None
|
||||
return lines[start + 1 : end]
|
||||
|
||||
|
||||
def _sections(lines: List[str]) -> Dict[str, List[str]]:
|
||||
sections: Dict[str, List[str]] = {}
|
||||
current: List[str] = []
|
||||
for line in lines:
|
||||
if line.startswith("[") and line.endswith("]") and " " not in line:
|
||||
current = sections.setdefault(line[1:-1], [])
|
||||
else:
|
||||
current.append(line)
|
||||
return sections
|
||||
|
||||
|
||||
def _split_local(local: str) -> Optional[Tuple[str, Optional[str], int]]:
|
||||
"""``[fe80::1%eth0]:546`` -> ``("fe80::1", "eth0", 546)``; None if no port."""
|
||||
host, sep, port = local.rpartition(":")
|
||||
if not sep or not port.isdigit():
|
||||
return None
|
||||
if host.startswith("[") and host.endswith("]"):
|
||||
host = host[1:-1]
|
||||
address, _, zone = host.partition("%")
|
||||
return address or "*", zone or None, int(port)
|
||||
|
||||
|
||||
def _cgroup_paths(lines: List[str]) -> Dict[int, str]:
|
||||
"""Each process's cgroup path: the unified hierarchy, or systemd's under v1."""
|
||||
paths: Dict[int, str] = {}
|
||||
for line in lines:
|
||||
pid, _, entry = line.partition(" ")
|
||||
parts = entry.split(":", 2)
|
||||
if not pid.isdigit() or len(parts) != 3:
|
||||
continue
|
||||
hierarchy, controllers, path = parts
|
||||
if (hierarchy == "0" and controllers == "") or controllers == "name=systemd":
|
||||
paths[int(pid)] = path
|
||||
return paths
|
||||
|
||||
|
||||
def _unit(path: Optional[str]) -> Optional[str]:
|
||||
services = _SERVICE_RE.findall(path or "")
|
||||
return services[-1] if services else None
|
||||
|
||||
|
||||
def _container(path: Optional[str]) -> Optional[str]:
|
||||
match = _CONTAINER_RE.search(path or "")
|
||||
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
|
||||
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],
|
||||
"address": address,
|
||||
"port": port,
|
||||
"interface": interface,
|
||||
"process": process,
|
||||
"pid": pid,
|
||||
"unit": _unit(path),
|
||||
"container_id": _container(path),
|
||||
}
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
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]
|
||||
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'}")
|
||||
paths = _cgroup_paths(sections.get("cgroups", []))
|
||||
sockets = [s for s in (_socket(line, paths) for line in ss_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.
|
||||
|
||||
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
|
||||
host, and how it gains root there. Mixed in by the drivers that can, not by
|
||||
:class:`~napalm_device_types.os.OSDriver`: a Windows host is an OS driver
|
||||
too, and ``hasattr(driver, "get_listening_sockets")`` has to stay truthful.
|
||||
"""
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - declared for type checkers only
|
||||
|
||||
def _run_listening_sockets_command(self, command: str, *, privileged: bool) -> str:
|
||||
"""Run *command* on the host and return what it printed; as root
|
||||
when *privileged*. The command is a single ``sh -c`` invocation."""
|
||||
...
|
||||
|
||||
def get_listening_sockets(self) -> ListeningSocketsDict:
|
||||
"""
|
||||
Returns every listening TCP and bound UDP socket, with the process,
|
||||
systemd service and container behind it.
|
||||
|
||||
* attributed (bool) - read as root, so every process is named
|
||||
* sockets (list) - see :class:`~napalm_device_types.models.ListeningSocketDict`
|
||||
|
||||
Example::
|
||||
|
||||
{
|
||||
"attributed": True,
|
||||
"sockets": [
|
||||
{"proto": "tcp", "address": "0.0.0.0", "port": 5432,
|
||||
"interface": None, "process": "postgres", "pid": 812,
|
||||
"unit": "postgresql@16-main", "container_id": None},
|
||||
],
|
||||
}
|
||||
|
||||
:raises ListeningSocketsUnavailable: if the host has no ``ss``.
|
||||
:raises ValueError: if neither reading carried an intact report.
|
||||
"""
|
||||
command = f"sh -c {quote(LISTENING_SOCKETS_COMMAND)}"
|
||||
try:
|
||||
output = self._run_listening_sockets_command(command, privileged=True)
|
||||
return {"attributed": True, "sockets": parse_listening_sockets(output)}
|
||||
except ValueError:
|
||||
pass
|
||||
output = self._run_listening_sockets_command(command, privileged=False)
|
||||
return {"attributed": False, "sockets": parse_listening_sockets(output)}
|
||||
@@ -335,6 +335,37 @@ class KernelFactsDict(TypedDict):
|
||||
config: Optional[Dict[str, str]] # build configuration, set options only; quotes stripped
|
||||
|
||||
|
||||
class ListeningSocketDict(TypedDict):
|
||||
"""A TCP socket that listens, or a UDP socket that is bound, on the host.
|
||||
|
||||
One entry per socket as ``ss`` lists it. ``address`` is printed the way ss
|
||||
prints it, without brackets or zone: ``0.0.0.0`` and ``::`` are every
|
||||
address of their family, ``*`` every address of both. Whether that is
|
||||
reachable from outside the host is the consumer's call.
|
||||
"""
|
||||
|
||||
proto: str # "tcp" or "udp"
|
||||
address: str # "0.0.0.0", "::", "*", "127.0.0.1", "::ffff:127.0.0.1", ...
|
||||
port: int
|
||||
interface: Optional[str] # the %zone a socket is bound to ("lo", "eth0"), if any
|
||||
process: Optional[str] # the first process holding it; None without one or without root
|
||||
pid: Optional[int]
|
||||
unit: Optional[str] # the process's systemd service, without ".service"
|
||||
container_id: Optional[str] # the full container ID, for a container on the host network
|
||||
|
||||
|
||||
class ListeningSocketsDict(TypedDict):
|
||||
"""What ``ListeningSocketsMixin.get_listening_sockets`` read.
|
||||
|
||||
``attributed`` is False when the reading ran without root: ``ss`` then names
|
||||
only the login user's own processes, so a socket without a process means
|
||||
"not told", not "the kernel's".
|
||||
"""
|
||||
|
||||
attributed: bool
|
||||
sockets: List[ListeningSocketDict]
|
||||
|
||||
|
||||
class PortForwardDict(TypedDict):
|
||||
"""A port the WAN side can reach, forwarded to a host inside.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user