feat: ServicePackagesMixin, which packages a systemd service is (NetOrk/netork#1236)
CI / test (3.10) (push) Failing after 17s
CI / test (3.11) (push) Failing after 14s
CI / test (3.12) (push) Failing after 15s
CI / test (3.13) (push) Failing after 16s
CI / test (3.10) (pull_request) Failing after 17s
CI / test (3.11) (pull_request) Failing after 15s
CI / test (3.12) (pull_request) Failing after 20s
CI / test (3.13) (pull_request) Failing after 26s
CI / test (3.10) (push) Failing after 17s
CI / test (3.11) (push) Failing after 14s
CI / test (3.12) (push) Failing after 15s
CI / test (3.13) (push) Failing after 16s
CI / test (3.10) (pull_request) Failing after 17s
CI / test (3.11) (pull_request) Failing after 15s
CI / test (3.12) (pull_request) Failing after 20s
CI / test (3.13) (pull_request) Failing after 26s
get_service_packages() returns, per systemd service, the installed packages it is made of: the one shipping its unit file and the one shipping the binary its ExecStart runs -- not the libraries that binary links against. One read-only command: systemctl show for every loaded service unit, then one dpkg -S for all paths (each also across the /usr merge), or rpm -qf per path. The report is framed; one cut short raises. A driver supplies _run_service_packages_command, as for KernelFactsMixin. Version 4.20.0.
This commit is contained in:
1 parent
888a11ca6a
commit
cce26eff44
5 files changed
+349
-1
No files matched your search
@@ -52,6 +52,7 @@ instead of being restated on every role that happens to need it:
|
||||
* :class:`~napalm_device_types.packages.PackageManagementMixin`
|
||||
* :class:`~napalm_device_types.ping_sweep.PingSweepMixin`
|
||||
* :class:`~napalm_device_types.service_clusters.ServiceClustersMixin`
|
||||
* :class:`~napalm_device_types.service_packages.ServicePackagesMixin`
|
||||
* :class:`~napalm_device_types.services.ServiceControlMixin`
|
||||
* :class:`~napalm_device_types.systemd.SystemdServicesMixin`
|
||||
* :class:`~napalm_device_types.updates.UpdateMixin`
|
||||
@@ -98,6 +99,11 @@ from napalm_device_types.licenses import LicenseMixin
|
||||
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.service_packages import (
|
||||
SERVICE_PACKAGES_COMMAND,
|
||||
ServicePackagesMixin,
|
||||
parse_service_packages,
|
||||
)
|
||||
from napalm_device_types.lag import add_lag_interfaces
|
||||
from napalm_device_types.listening import (
|
||||
LISTENING_SOCKETS_COMMAND,
|
||||
@@ -265,6 +271,9 @@ __all__ = [
|
||||
"KernelFactsMixin",
|
||||
"KERNEL_FACTS_COMMAND",
|
||||
"parse_kernel_facts",
|
||||
"SERVICE_PACKAGES_COMMAND",
|
||||
"ServicePackagesMixin",
|
||||
"parse_service_packages",
|
||||
"LISTENING_SOCKETS_COMMAND",
|
||||
"ListeningSocketsMixin",
|
||||
"ListeningSocketsUnavailable",
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Which installed packages a systemd service is made of.
|
||||
|
||||
A vulnerability is found on a *package*; a statement about a *service* -- the
|
||||
postgresql unit, nginx -- reaches its findings only once it is known which
|
||||
packages that service is. Here that is the package shipping the unit file and
|
||||
the one shipping the binary its ``ExecStart`` runs: ``nginx-common`` and
|
||||
``nginx-core``, ``postgresql-common`` and ``postgresql-16``. Reading it is the
|
||||
same on every systemd host, so the command and its parse live here once and a
|
||||
driver only carries the command across.
|
||||
|
||||
**Only the unit's own packages.** The libraries its binary links against
|
||||
(``libssl3`` under nginx) are every service's at once; a statement about nginx
|
||||
is not one about libc, and following the dependencies would make it one.
|
||||
|
||||
The command is read-only and needs no privileges. It prints ``systemctl show``
|
||||
for every loaded service unit and then asks the package manager about every
|
||||
path at once: ``dpkg -S`` once for all of them -- one call, since each loads the
|
||||
whole file list, and per path that is minutes on an ordinary host -- or
|
||||
``rpm -qf`` per path. dpkg knows a file by the path its package shipped, so each
|
||||
path is asked across the ``/usr`` merge too. The report is framed; one whose end
|
||||
is missing raises, because a list cut short must never read as services that own
|
||||
nothing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Dict, List, Optional, Set, Tuple, TYPE_CHECKING
|
||||
|
||||
_BEGIN = "SVCPKG_BEGIN"
|
||||
_END = "SVCPKG_END"
|
||||
_SUFFIX = ".service"
|
||||
|
||||
#: A Debian or RPM package name.
|
||||
_PACKAGE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9+._-]*$")
|
||||
#: The binary of an ``ExecStart`` as ``systemctl show`` prints it.
|
||||
_EXEC_PATH = re.compile(r"\bpath=(/[^ ;]+)")
|
||||
|
||||
#: Every unit file and ``ExecStart`` binary in ``$s``, each also across the
|
||||
#: ``/usr`` merge, one per line.
|
||||
_PATHS = (
|
||||
"printf '%s\\n' \"$s\" | sed -n -e 's/^FragmentPath=\\(\\/.*\\)/\\1/p' "
|
||||
"-e 's/^ExecStart=.*path=\\(\\/[^ ;]*\\).*/\\1/p' | sort -u "
|
||||
"| sed -e 'p' -e 's#^/usr/#/#;t' -e 's#^/#/usr/#'"
|
||||
)
|
||||
|
||||
#: 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.
|
||||
SERVICE_PACKAGES_COMMAND = (
|
||||
"s=$(systemctl list-units --type=service --all --no-legend --no-pager --plain 2>/dev/null "
|
||||
"| awk '{print $1}' | tr '\\n' '\\0' | xargs -0 -r systemctl show --no-pager "
|
||||
"-p Id -p FragmentPath -p ExecStart -- 2>/dev/null); "
|
||||
"printf '%s%s\\n' SVCPKG_ BEGIN; echo '[units]'; printf '%s\\n\\n' \"$s\"; "
|
||||
"if command -v dpkg >/dev/null 2>&1; then echo '[dpkg]'; "
|
||||
f"{_PATHS} | tr '\\n' '\\0' | xargs -0 -r dpkg -S 2>/dev/null; "
|
||||
"elif command -v rpm >/dev/null 2>&1; then echo '[rpm]'; "
|
||||
f"{_PATHS} | while IFS= read -r f; do "
|
||||
'printf \'%s\\t%s\\n\' "$f" "$(rpm -qf --qf \'%{NAME} \' "$f" 2>/dev/null)"; done; fi; '
|
||||
"printf '%s%s\\n' SVCPKG_ END"
|
||||
)
|
||||
|
||||
|
||||
def _report(output: str) -> Dict[str, List[str]]:
|
||||
lines = [line.rstrip() for line in output.splitlines()]
|
||||
stripped = [line.strip() for line in lines]
|
||||
try:
|
||||
start = stripped.index(_BEGIN)
|
||||
end = stripped.index(_END, start)
|
||||
except ValueError:
|
||||
raise ValueError("no intact service package report in the output") from None
|
||||
sections: Dict[str, List[str]] = {}
|
||||
current: Optional[List[str]] = None
|
||||
for line in lines[start + 1 : end]:
|
||||
if line in ("[units]", "[dpkg]", "[rpm]"):
|
||||
current = sections.setdefault(line[1:-1], [])
|
||||
elif current is not None:
|
||||
current.append(line)
|
||||
return sections
|
||||
|
||||
|
||||
def _units(lines: List[str]) -> Dict[str, Set[str]]:
|
||||
"""``systemctl show`` blocks -> ``{unit: its unit file and binary}``."""
|
||||
units: Dict[str, Set[str]] = {}
|
||||
unit, paths = "", set()
|
||||
for line in [*lines, ""]:
|
||||
key, _, value = line.partition("=")
|
||||
if not line.strip():
|
||||
if unit:
|
||||
units[unit] = paths
|
||||
unit, paths = "", set()
|
||||
elif key == "Id" and value.endswith(_SUFFIX):
|
||||
unit = value[: -len(_SUFFIX)]
|
||||
elif key == "FragmentPath" and value.startswith("/"):
|
||||
paths.add(value)
|
||||
elif key == "ExecStart":
|
||||
match = _EXEC_PATH.search(value)
|
||||
if match:
|
||||
paths.add(match.group(1))
|
||||
return units
|
||||
|
||||
|
||||
def _names(raw: str) -> Set[str]:
|
||||
"""``libfoo1:amd64, foo-bin`` -> ``{"libfoo1", "foo-bin"}``; nothing else counts."""
|
||||
found = set()
|
||||
for name in raw.replace(",", " ").split():
|
||||
name = name.split(":", 1)[0]
|
||||
if _PACKAGE.match(name):
|
||||
found.add(name)
|
||||
return found
|
||||
|
||||
|
||||
def _owners(sections: Dict[str, List[str]]) -> Dict[str, Set[str]]:
|
||||
"""``{path: the packages owning it}`` from dpkg's or rpm's answer."""
|
||||
owners: Dict[str, Set[str]] = {}
|
||||
for line in sections.get("dpkg", []):
|
||||
# "pkg[, pkg2][:arch]: /path"; "diversion by x from: /path" is no owner.
|
||||
if line.startswith("diversion by") or ": /" not in line:
|
||||
continue
|
||||
names, _, path = line.partition(": /")
|
||||
owners.setdefault("/" + path.strip(), set()).update(_names(names))
|
||||
for line in sections.get("rpm", []):
|
||||
path, _, names = line.partition("\t")
|
||||
if " is not owned" not in names:
|
||||
owners.setdefault(path.strip(), set()).update(_names(names))
|
||||
return owners
|
||||
|
||||
|
||||
def _across_usr_merge(path: str) -> Tuple[str, str]:
|
||||
return (path, path[4:] if path.startswith("/usr/") else "/usr" + path)
|
||||
|
||||
|
||||
def parse_service_packages(output: str) -> Dict[str, List[str]]:
|
||||
"""Parse what :data:`SERVICE_PACKAGES_COMMAND` printed.
|
||||
|
||||
``{unit name without ".service": its packages, sorted}``. A unit no package
|
||||
owns -- a unit file an admin wrote -- maps to an empty list: read, and none.
|
||||
|
||||
:raises ValueError: when the output carries no intact report.
|
||||
"""
|
||||
sections = _report(output)
|
||||
owners = _owners(sections)
|
||||
return {
|
||||
unit: sorted(
|
||||
{name for p in paths for alt in _across_usr_merge(p) for name in owners.get(alt, ())}
|
||||
)
|
||||
for unit, paths in _units(sections.get("units", [])).items()
|
||||
}
|
||||
|
||||
|
||||
class ServicePackagesMixin:
|
||||
"""Adds :meth:`get_service_packages` to a driver that can run a command on a systemd host.
|
||||
|
||||
The template form, like :class:`~napalm_device_types.kernel.KernelFactsMixin`:
|
||||
the reading and its parse are concrete here, and a driver supplies only
|
||||
:meth:`_run_service_packages_command` -- how a command reaches its host.
|
||||
"""
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - declared for type checkers only
|
||||
|
||||
def _run_service_packages_command(self, command: str) -> str:
|
||||
"""Run *command* on the host with ``sh`` and return what it printed."""
|
||||
...
|
||||
|
||||
def get_service_packages(self) -> Dict[str, List[str]]:
|
||||
"""
|
||||
Returns, per systemd service, the installed packages it is made of.
|
||||
|
||||
The package that ships its unit file and the one that ships the binary
|
||||
its ``ExecStart`` runs -- not the libraries that binary links against.
|
||||
|
||||
Example::
|
||||
|
||||
{
|
||||
"nginx": ["nginx-common", "nginx-core"],
|
||||
"postgresql@16-main": ["postgresql-16", "postgresql-common"],
|
||||
"local-backup": [],
|
||||
}
|
||||
|
||||
:raises ValueError: if the host's output carried no intact report.
|
||||
"""
|
||||
return parse_service_packages(self._run_service_packages_command(SERVICE_PACKAGES_COMMAND))
|
||||
Reference in new issue
Block a user