feat: packages, services and updates
CI / test (3.10) (push) Successful in 26s
CI / test (3.11) (push) Successful in 24s
CI / test (3.12) (push) Successful in 26s
CI / test (3.10) (pull_request) Successful in 25s
CI / test (3.11) (pull_request) Successful in 24s
CI / test (3.12) (pull_request) Successful in 25s
CI / test (3.10) (push) Successful in 26s
CI / test (3.11) (push) Successful in 24s
CI / test (3.12) (push) Successful in 26s
CI / test (3.10) (pull_request) Successful in 25s
CI / test (3.11) (pull_request) Successful in 24s
CI / test (3.12) (pull_request) Successful in 25s
The write side NetOrk/netork#799 needs, on both drivers. - Packages: get_packages (FreeBSD `pkg query` with the repository as source, nothing on a classic system without pkg; OpenBSD `pkg_info`), install_package / uninstall_package as root (`pkg install`/`pkg delete`, `pkg_add -I`/`pkg_delete`), names checked before anything is sent, a failure raised with what the tool printed. - Updates: get_available_updates. FreeBSD `pkg upgrade -n`, with `security` from VuXML (`pkg audit`: True for a listed package, False for any other, None when the audit could not run); OpenBSD `pkg_add -u -n -v` (no security verdict). Base-system patches are one `base-system` entry, as decided in #799: OpenBSD's `syspatch -c`, and on a classic FreeBSD what `freebsd-update` has fetched; on pkgbase the base system is packages from FreeBSD-base. - Services: get_services lists the enabled ones (FreeBSD `service ... status` as root, as root-only pidfiles hide daemons otherwise, without root when sudo refuses; OpenBSD `rcctl check`), manage_service runs start/stop/restart/enable/disable as root and returns success and output. Fixtures recorded on the FreeBSD 15.1 and OpenBSD 7.9 VMs with genuinely outdated packages (FreeBSD pointed at the latest branch, an OpenBSD package taken back to its release build). Checked live on both, including a service restart and installing and removing a package.
This commit is contained in:
@@ -9,6 +9,7 @@ stdin, as napalm-linux does it.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
from shlex import quote
|
||||
from typing import Any, Optional
|
||||
@@ -27,6 +28,14 @@ from napalm_bsd import parse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: The update-list entry for base-system patches (OpenBSD syspatch, classic
|
||||
#: FreeBSD freebsd-update): one entry, as they are applied together (#799).
|
||||
BASE_SYSTEM = "base-system"
|
||||
|
||||
_SERVICE_ACTIONS = frozenset({"start", "stop", "restart", "enable", "disable"})
|
||||
_SERVICE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
|
||||
_PACKAGE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.+-]*$")
|
||||
|
||||
|
||||
class BsdDriver(OSDriver):
|
||||
"""Base for the BSD drivers; a concrete one names the commands that differ."""
|
||||
@@ -277,6 +286,64 @@ class BsdDriver(OSDriver):
|
||||
"sockets": self._parse_listening(plain.stdout, attributed=False),
|
||||
}
|
||||
|
||||
# -- packages -----------------------------------------------------------------
|
||||
|
||||
#: ``{name}`` is the package (with its version, when one is asked for).
|
||||
INSTALL_COMMAND = ""
|
||||
UNINSTALL_COMMAND = ""
|
||||
|
||||
def _package_command(self, template: str, name: str) -> None:
|
||||
"""Run a package command as root; a failure raises with what it printed."""
|
||||
result = self.run_command(template.format(name=name), privileged=True, timeout=600)
|
||||
if result.exit_code != 0:
|
||||
raise RuntimeError(
|
||||
(result.stderr or result.stdout).strip() or f"{name}: exit {result.exit_code}"
|
||||
)
|
||||
|
||||
def install_package(self, name: str, version: str = "") -> None:
|
||||
if not _PACKAGE_NAME.match(name) or (version and not _PACKAGE_NAME.match(version)):
|
||||
raise ValueError(f"Not a package name: {name!r}")
|
||||
self._package_command(self.INSTALL_COMMAND, f"{name}-{version}" if version else name)
|
||||
|
||||
def uninstall_package(self, name: str) -> None:
|
||||
if not _PACKAGE_NAME.match(name):
|
||||
raise ValueError(f"Not a package name: {name!r}")
|
||||
self._package_command(self.UNINSTALL_COMMAND, name)
|
||||
|
||||
# -- services -----------------------------------------------------------------
|
||||
|
||||
#: Prints ``name<TAB>status`` per enabled service; parsed by :meth:`_parse_services`.
|
||||
SERVICE_STATUS_COMMAND = ""
|
||||
#: ``{name}`` and ``{action}`` (start, stop, restart, enable, disable).
|
||||
SERVICE_ACTION_COMMAND = ""
|
||||
|
||||
def _parse_services(self, output: str) -> list[dict[str, Any]]:
|
||||
raise NotImplementedError
|
||||
|
||||
def get_services(self) -> list[dict[str, Any]]:
|
||||
"""The enabled services, whether each runs, and its PID where known.
|
||||
|
||||
Read as root where the status needs it, and without root otherwise.
|
||||
"""
|
||||
result = self.run_command(self.SERVICE_STATUS_COMMAND, privileged=True, timeout=120)
|
||||
if result.exit_code != 0 or not result.stdout.strip(): # no root: sudo refused
|
||||
result = self.run_command(self.SERVICE_STATUS_COMMAND, timeout=120)
|
||||
return self._parse_services(result.stdout)
|
||||
|
||||
def manage_service(self, name: str, action: str) -> dict[str, Any]:
|
||||
"""Applies *action* to the service *name*, as root.
|
||||
|
||||
:returns: ``{"success": bool, "output": str}``
|
||||
:raises ValueError: for an unknown action or an invalid name, before
|
||||
anything is sent.
|
||||
"""
|
||||
if action not in _SERVICE_ACTIONS or not _SERVICE_NAME.match(name):
|
||||
raise ValueError(f"Cannot {action!r} service {name!r}")
|
||||
command = self.SERVICE_ACTION_COMMAND.format(name=name, action=action)
|
||||
result = self.run_command(command, privileged=True, timeout=120)
|
||||
output = "\n".join(filter(None, (result.stdout.strip(), result.stderr.strip())))
|
||||
return {"success": result.exit_code == 0, "output": output}
|
||||
|
||||
# -- accounts, processes, cron -----------------------------------------------
|
||||
|
||||
def get_users(self) -> list[dict[str, Any]]:
|
||||
|
||||
+63
-1
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from napalm_device_types import FingerprintRule
|
||||
|
||||
from napalm_bsd import parse
|
||||
from napalm_bsd.base import BsdDriver
|
||||
from napalm_bsd.base import BASE_SYSTEM, BsdDriver
|
||||
|
||||
|
||||
class FreeBSDDriver(BsdDriver):
|
||||
@@ -35,6 +35,68 @@ class FreeBSDDriver(BsdDriver):
|
||||
def _parse_listening(self, output: str, *, attributed: bool) -> list[dict]:
|
||||
return parse.sockstat(output)
|
||||
|
||||
PKG_QUERY = "pkg query '%n\t%v\t%R\t%sb\t%c'"
|
||||
INSTALL_COMMAND = "pkg install -y {name}"
|
||||
UNINSTALL_COMMAND = "pkg delete -y {name}"
|
||||
# Root reads every daemon's pidfile; one-shot scripts have no status.
|
||||
SERVICE_STATUS_COMMAND = (
|
||||
"for s in $(service -e); do n=${s##*/}; "
|
||||
'printf "%s\\t%s\\n" "$n" "$(service $n status 2>&1 | head -1)"; done'
|
||||
)
|
||||
SERVICE_ACTION_COMMAND = "service {name} {action}"
|
||||
|
||||
def _parse_services(self, output: str) -> list[dict]:
|
||||
return parse.service_status(output)
|
||||
|
||||
def _has_pkg(self) -> bool:
|
||||
"""pkg is bootstrapped; a hand-installed classic system may have only the stub."""
|
||||
return self.run_command("pkg -N").exit_code == 0
|
||||
|
||||
def get_packages(self) -> list[dict]:
|
||||
return parse.pkg_query(self._out(self.PKG_QUERY)) if self._has_pkg() else []
|
||||
|
||||
def get_available_updates(self) -> list[dict]:
|
||||
"""Package updates from ``pkg upgrade -n``, and fetched base-system updates.
|
||||
|
||||
``security`` comes from VuXML (``pkg audit``): True for a package it
|
||||
lists as vulnerable, False for one it does not, None when the audit
|
||||
could not run. On pkgbase the base system is packages from the
|
||||
FreeBSD-base repository; a classic base reports one entry (#799).
|
||||
"""
|
||||
updates: list[dict] = []
|
||||
if self._has_pkg():
|
||||
updates = parse.pkg_upgrades(self._out("pkg upgrade -n", privileged=True, timeout=300))
|
||||
audit = self.run_command("pkg audit -Fq", privileged=True, timeout=120)
|
||||
known = audit.exit_code in (0, 1) # 1: vulnerable packages found
|
||||
vulnerable = parse.pkg_audit(audit.stdout) if known else set()
|
||||
for update in updates:
|
||||
update["security"] = (update["name"] in vulnerable) if known else None
|
||||
if "FreeBSD-base" in self._out("pkg repos -l").split():
|
||||
return updates
|
||||
return updates + self._base_updates()
|
||||
|
||||
def _base_updates(self) -> list[dict]:
|
||||
"""A classic base system's updates, as freebsd-update fetched them.
|
||||
|
||||
``updatesready`` exits 0 when fetched updates wait to be installed and
|
||||
2 when there are none; it does not fetch, which ``freebsd-update cron``
|
||||
does daily where it is enabled.
|
||||
"""
|
||||
ready = self.run_command(
|
||||
"freebsd-update --not-running-from-cron updatesready", privileged=True, timeout=60
|
||||
)
|
||||
if ready.exit_code != 0:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"name": BASE_SYSTEM,
|
||||
"current_version": self._out("freebsd-version -u"),
|
||||
"new_version": "fetched by freebsd-update",
|
||||
"origin": "freebsd-update",
|
||||
"security": None,
|
||||
}
|
||||
]
|
||||
|
||||
def _os_version(self) -> str:
|
||||
version = super()._os_version()
|
||||
return f"FreeBSD {version}" if version else ""
|
||||
|
||||
+51
-1
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from napalm_device_types import FingerprintRule
|
||||
|
||||
from napalm_bsd import parse
|
||||
from napalm_bsd.base import BsdDriver
|
||||
from napalm_bsd.base import BASE_SYSTEM, BsdDriver
|
||||
|
||||
|
||||
class OpenBSDDriver(BsdDriver):
|
||||
@@ -33,6 +33,56 @@ class OpenBSDDriver(BsdDriver):
|
||||
def _parse_listening(self, output: str, *, attributed: bool) -> list[dict]:
|
||||
return parse.fstat_sockets(output) if attributed else parse.netstat_listening(output)
|
||||
|
||||
INSTALL_COMMAND = "pkg_add -I {name}"
|
||||
UNINSTALL_COMMAND = "pkg_delete {name}"
|
||||
# rcctl check needs no root; "rcctl ls started" does.
|
||||
SERVICE_STATUS_COMMAND = (
|
||||
"for s in $(rcctl ls on); do "
|
||||
"if rcctl check $s >/dev/null 2>&1; then r=1; else r=0; fi; "
|
||||
'printf "%s\\t%s\\n" "$s" "$r"; done'
|
||||
)
|
||||
SERVICE_ACTION_COMMAND = "rcctl {action} {name}"
|
||||
|
||||
def _parse_services(self, output: str) -> list[dict]:
|
||||
return parse.rcctl_check(output)
|
||||
|
||||
def get_services(self) -> list[dict]:
|
||||
return self._parse_services(self._out(self.SERVICE_STATUS_COMMAND, timeout=120))
|
||||
|
||||
def get_packages(self) -> list[dict]:
|
||||
return parse.pkg_info(self._out("pkg_info"))
|
||||
|
||||
def get_available_updates(self) -> list[dict]:
|
||||
"""Package updates from ``pkg_add -u -n -v``, and the base system's syspatches.
|
||||
|
||||
syspatch applies its patches together and in order, so they are one
|
||||
entry (#799). Neither tool says which update is a security fix.
|
||||
"""
|
||||
updates: list[dict] = [
|
||||
{**candidate, "origin": None, "security": None}
|
||||
for candidate in parse.pkg_add_candidates(
|
||||
self._out("pkg_add -u -n -v", privileged=True, timeout=300)
|
||||
)
|
||||
]
|
||||
patches = parse.syspatch(self._out("syspatch -c", privileged=True, timeout=120))
|
||||
if patches:
|
||||
installed = parse.syspatch(self._out("syspatch -l", privileged=True))
|
||||
summary = (
|
||||
", ".join(patches)
|
||||
if len(patches) <= 3
|
||||
else f"{len(patches)} patches: {patches[0]} … {patches[-1]}"
|
||||
)
|
||||
updates.append(
|
||||
{
|
||||
"name": BASE_SYSTEM,
|
||||
"current_version": installed[-1] if installed else self._out("uname -r"),
|
||||
"new_version": summary,
|
||||
"origin": "syspatch",
|
||||
"security": None,
|
||||
}
|
||||
)
|
||||
return updates
|
||||
|
||||
def _hardware(self) -> tuple[str, str, str]:
|
||||
hw = self._platform()
|
||||
return hw.get("vendor", ""), hw.get("product", ""), hw.get("serialno", "")
|
||||
|
||||
@@ -347,3 +347,146 @@ def netstat_listening(text: str) -> list[dict[str, Any]]:
|
||||
continue
|
||||
sockets.append(_socket(proto, local, family, None, None))
|
||||
return _unique(sockets)
|
||||
|
||||
|
||||
# -- packages, updates, services ---------------------------------------------------
|
||||
|
||||
_PACKAGE_NAME = re.compile(r"^(?P<name>\S+?)-(?P<version>\d\S*)$")
|
||||
|
||||
|
||||
def split_package(full: str) -> tuple[str, str]:
|
||||
"""An OpenBSD package name: ``bash-completion-2.17.0`` -> (``bash-completion``, ``2.17.0``).
|
||||
|
||||
The version starts at the first ``-`` followed by a digit.
|
||||
"""
|
||||
match = _PACKAGE_NAME.match(full)
|
||||
return (match["name"], match["version"]) if match else (full, "")
|
||||
|
||||
|
||||
def pkg_query(text: str) -> list[dict[str, Any]]:
|
||||
"""FreeBSD ``pkg query '%n\\t%v\\t%R\\t%sb\\t%c'``; the source is the repository."""
|
||||
packages = []
|
||||
for line in text.splitlines():
|
||||
parts = line.split("\t")
|
||||
if len(parts) < 5:
|
||||
continue
|
||||
name, version, repo, size, comment = parts[:5]
|
||||
packages.append(
|
||||
{
|
||||
"name": name,
|
||||
"version": version,
|
||||
"installed": True,
|
||||
"description": comment,
|
||||
"size": int(size) if size.isdigit() else 0,
|
||||
"source": repo,
|
||||
}
|
||||
)
|
||||
return packages
|
||||
|
||||
|
||||
def pkg_info(text: str) -> list[dict[str, Any]]:
|
||||
"""OpenBSD ``pkg_info``: ``name-version comment`` per installed package."""
|
||||
packages = []
|
||||
for line in text.splitlines():
|
||||
full, _, comment = line.partition(" ")
|
||||
if not full:
|
||||
continue
|
||||
name, version = split_package(full)
|
||||
packages.append(
|
||||
{
|
||||
"name": name,
|
||||
"version": version,
|
||||
"installed": True,
|
||||
"description": comment.strip(),
|
||||
"size": 0,
|
||||
"source": "pkg_add",
|
||||
}
|
||||
)
|
||||
return packages
|
||||
|
||||
|
||||
_PKG_UPGRADE = re.compile(
|
||||
r"^\s+(?P<name>\S+): (?P<old>\S+) -> (?P<new>\S+)(?: \[(?P<repo>[^\]]+)\])?"
|
||||
)
|
||||
|
||||
|
||||
def pkg_upgrades(text: str) -> list[dict[str, Any]]:
|
||||
"""FreeBSD ``pkg upgrade -n``: the packages it would upgrade, with their repository."""
|
||||
updates = []
|
||||
for line in text.splitlines():
|
||||
match = _PKG_UPGRADE.match(line)
|
||||
if match:
|
||||
updates.append(
|
||||
{
|
||||
"name": match["name"],
|
||||
"current_version": match["old"],
|
||||
"new_version": match["new"],
|
||||
"origin": match["repo"],
|
||||
}
|
||||
)
|
||||
return updates
|
||||
|
||||
|
||||
def pkg_audit(text: str) -> set[str]:
|
||||
"""FreeBSD ``pkg audit -q``: the names of the vulnerable packages (VuXML)."""
|
||||
names = set()
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if line:
|
||||
names.add(line.rpartition("-")[0] or line)
|
||||
return names
|
||||
|
||||
|
||||
_CANDIDATE = re.compile(r"^Update candidates: (?P<old>\S+) -> (?P<new>\S+)$")
|
||||
|
||||
|
||||
def pkg_add_candidates(text: str) -> list[dict[str, Any]]:
|
||||
"""OpenBSD ``pkg_add -u -n -v``: the candidates whose version changes."""
|
||||
updates: list[dict[str, Any]] = []
|
||||
seen = set()
|
||||
for line in text.splitlines():
|
||||
match = _CANDIDATE.match(line.strip())
|
||||
if not match or match["old"] == match["new"] or match["old"] in seen:
|
||||
continue
|
||||
seen.add(match["old"])
|
||||
name, old = split_package(match["old"])
|
||||
_, new = split_package(match["new"])
|
||||
updates.append({"name": name, "current_version": old, "new_version": new})
|
||||
return updates
|
||||
|
||||
|
||||
def syspatch(text: str) -> list[str]:
|
||||
"""OpenBSD ``syspatch -c`` / ``-l``: one patch name per line."""
|
||||
return [line.strip() for line in text.splitlines() if re.match(r"^\d{3}_\S+$", line.strip())]
|
||||
|
||||
|
||||
_RUNNING = re.compile(r"is running as pid (\d+)")
|
||||
|
||||
|
||||
def service_status(text: str) -> list[dict[str, Any]]:
|
||||
"""FreeBSD: ``name<TAB>first line of 'service name status'`` per enabled service."""
|
||||
services = []
|
||||
for line in text.splitlines():
|
||||
name, _, status = line.partition("\t")
|
||||
if not name:
|
||||
continue
|
||||
running = _RUNNING.search(status)
|
||||
services.append(
|
||||
{
|
||||
"name": name,
|
||||
"running": bool(running),
|
||||
"enabled": True,
|
||||
"pid": int(running[1]) if running else 0,
|
||||
}
|
||||
)
|
||||
return services
|
||||
|
||||
|
||||
def rcctl_check(text: str) -> list[dict[str, Any]]:
|
||||
"""OpenBSD: ``name<TAB>1|0`` per enabled service, from ``rcctl check``."""
|
||||
services = []
|
||||
for line in text.splitlines():
|
||||
name, _, ok = line.partition("\t")
|
||||
if name:
|
||||
services.append({"name": name, "running": ok.strip() == "1", "enabled": True, "pid": 0})
|
||||
return services
|
||||
|
||||
Reference in New Issue
Block a user