Files
napalm-bsd/napalm_bsd/parse.py
T
christianmanivong e8468a292a
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
feat: packages, services and updates
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.
2026-10-08 09:59:04 +02:00

493 lines
17 KiB
Python

"""Parsers for the output of BSD commands. Pure functions, no I/O.
FreeBSD and OpenBSD share most tools; where their output differs (``arp``,
``kern.boottime``, how ``netstat`` abbreviates networks, ``ether`` vs
``lladdr``) one parser reads both.
"""
from __future__ import annotations
import ipaddress
import re
from typing import Any, Optional
_IFACE_HEADER = re.compile(
r"^(?P<name>[^\s:]+): flags=[0-9a-f]+<(?P<flags>[^>]*)>(?:.*?\bmtu (?P<mtu>\d+))?"
)
_SPEED = re.compile(r"\((\d+)(G?)base", re.IGNORECASE)
def _prefix_from_netmask(mask: str) -> int:
"""``0xffffff00`` (as ifconfig prints it) or ``255.255.255.0`` -> 24."""
value = int(mask, 16) if mask.startswith("0x") else int(ipaddress.IPv4Address(mask))
return bin(value).count("1")
def ifconfig(text: str) -> dict[str, dict[str, Any]]:
"""``ifconfig -a`` -> per interface: flags, MTU, MAC, state, speed, addresses."""
interfaces: dict[str, dict[str, Any]] = {}
current: Optional[dict[str, Any]] = None
for line in text.splitlines():
header = _IFACE_HEADER.match(line)
if header:
flags = set(filter(None, header["flags"].split(",")))
current = {
"flags": flags,
"enabled": "UP" in flags,
"up": "UP" in flags and "RUNNING" in flags,
"loopback": "LOOPBACK" in flags,
"mtu": int(header["mtu"] or 0),
"mac": "",
"description": "",
"speed": -1.0,
"ipv4": {},
"ipv6": {},
}
interfaces[header["name"]] = current
continue
if current is None:
continue
words = line.split()
if not words:
continue
key = words[0]
if key in ("ether", "lladdr") and len(words) > 1:
current["mac"] = words[1].lower()
elif key == "inet" and len(words) > 3 and words[2] == "netmask":
current["ipv4"][words[1]] = _prefix_from_netmask(words[3])
elif key == "inet6" and len(words) > 3 and words[2] == "prefixlen":
current["ipv6"][words[1].split("%")[0]] = int(words[3])
elif key == "description:":
current["description"] = line.split("description:", 1)[1].strip()
elif key == "status:":
if line.split("status:", 1)[1].strip() == "no carrier":
current["up"] = False
elif key == "media:":
speed = _SPEED.search(line)
if speed:
current["speed"] = float(int(speed[1]) * (1000 if speed[2] else 1))
return interfaces
def _network(destination: str, family: str) -> Optional[str]:
"""A netstat destination as a network: ``default``, ``10.0.2/24``, ``fe80::%em0/64``."""
if destination == "default":
return "0.0.0.0/0" if family == "ipv4" else "::/0"
address, slash, prefix = destination.partition("/")
address = address.split("%")[0]
if family == "ipv4":
octets = address.split(".")
address = ".".join(octets + ["0"] * (4 - len(octets)))
try:
return str(ipaddress.ip_network(f"{address}/{prefix}" if slash else address, strict=False))
except ValueError:
return None
def routes(text: str) -> list[dict[str, str]]:
"""``netstat -rn`` -> the routes, without host, broadcast and loopback entries.
FreeBSD prints ``Netif``, OpenBSD ``Iface``; the column is found from the
header. A route through a gateway (``G``) has a next hop, any other is
connected; ``S`` marks a static one.
"""
result: list[dict[str, str]] = []
family = ""
iface_column: Optional[int] = None
for line in text.splitlines():
words = line.split()
if not words:
continue
if words[0] in ("Internet:", "Internet6:"):
family = "ipv4" if words[0] == "Internet:" else "ipv6"
continue
if words[0] == "Destination":
names = [w for w in words if w in ("Netif", "Iface")]
iface_column = words.index(names[0]) if names else None
continue
if not family or iface_column is None or len(words) <= iface_column:
continue
destination, gateway, flags, iface = words[0], words[1], words[2], words[iface_column]
if iface.startswith("lo") or ("H" in flags and "G" not in flags) or "b" in flags:
continue
network = _network(destination, family)
if network is None or ipaddress.ip_network(network).is_multicast:
continue
gateway_route = "G" in flags
result.append(
{
"network": network,
"next_hop": gateway if gateway_route else "",
"interface": iface,
"protocol": ("static" if "S" in flags else "dynamic")
if gateway_route
else "connected",
"family": family,
}
)
return result
_DURATION = re.compile(r"(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$")
def duration(text: str) -> float:
"""OpenBSD's ``1h2m3s`` in seconds; -1.0 for anything else (``permanent``)."""
match = _DURATION.match(text)
if not text or not match or not any(match.groups()):
return -1.0
hours, minutes, seconds = (int(g or 0) for g in match.groups())
return float(hours * 3600 + minutes * 60 + seconds)
_FREEBSD_ARP = re.compile(
r"^\S+ \((?P<ip>[\d.]+)\) at (?P<mac>[0-9a-f:]{17}) on (?P<iface>\S+)"
r"(?: expires in (?P<expires>\d+) seconds)?"
)
def arp(text: str) -> list[dict[str, Any]]:
"""``arp -an`` -> ARP entries; FreeBSD prints sentences, OpenBSD a table."""
entries: list[dict[str, Any]] = []
for line in text.splitlines():
sentence = _FREEBSD_ARP.match(line)
if sentence:
expires = sentence["expires"]
entries.append(
{
"interface": sentence["iface"],
"mac": sentence["mac"],
"ip": sentence["ip"],
"age": float(expires) if expires else -1.0,
}
)
continue
words = line.split()
if len(words) >= 4 and re.fullmatch(r"[0-9a-f:]{17}", words[1]):
entries.append(
{"interface": words[2], "mac": words[1], "ip": words[0], "age": duration(words[3])}
)
return entries
def users(passwd: str, group: str) -> list[dict[str, Any]]:
"""``/etc/passwd`` plus the supplementary groups ``/etc/group`` lists."""
memberships: dict[str, list[str]] = {}
for line in group.splitlines():
parts = line.split(":")
if len(parts) < 4 or line.startswith("#"):
continue
for member in filter(None, (m.strip() for m in parts[3].split(","))):
memberships.setdefault(member, []).append(parts[0])
result: list[dict[str, Any]] = []
for line in passwd.splitlines():
parts = line.split(":")
if len(parts) < 7 or line.startswith("#"):
continue
name, _, uid, gid, _, home, shell = parts[:7]
if not (uid.isdigit() and gid.isdigit()):
continue
result.append(
{
"username": name,
"uid": int(uid),
"gid": int(gid),
"home": home,
"shell": shell,
"groups": memberships.get(name, []),
}
)
return result
_PROCESS = re.compile(
r"^(?P<user>\S+)\s+(?P<pid>\d+)\s+(?P<ppid>\d+)\s+(?P<cpu>[\d.]+)\s+(?P<mem>[\d.]+)\s+"
r"(?P<vsz>\d+)\s+(?P<rss>\d+)\s+(?P<stat>\S+)\s+"
r"(?P<started>\w{3}\s+\w{3}\s+\d+\s+\d\d:\d\d:\d\d\s+\d{4})\s+(?P<command>.*)$"
)
def processes(text: str) -> list[dict[str, Any]]:
"""``ps -axww -o user,pid,ppid,%cpu,%mem,vsz,rss,stat,lstart,command``.
``lstart`` is five words; it is matched as one field.
"""
result: list[dict[str, Any]] = []
for line in text.splitlines():
match = _PROCESS.match(line)
if not match:
continue
result.append(
{
"pid": int(match["pid"]),
"ppid": int(match["ppid"]),
"user": match["user"],
"cpu": float(match["cpu"]),
"memory": float(match["mem"]),
"vsz": int(match["vsz"]),
"rss": int(match["rss"]),
"tty": "",
"state": match["stat"][0],
"started": match["started"],
"command": match["command"].strip(),
}
)
return result
def crontab(text: str, *, system: bool = False, user: str = "") -> list[dict[str, str]]:
"""A crontab -> its jobs. A system crontab names the user in the sixth field."""
jobs: list[dict[str, str]] = []
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#") or re.match(r"^[A-Za-z_][A-Za-z0-9_]*\s*=", line):
continue
fields = 1 if line.startswith("@") else 5
parts = line.split(None, fields + (1 if system else 0))
if len(parts) < fields + (2 if system else 1):
continue
schedule = " ".join(parts[:fields])
owner = parts[fields] if system else user
command = parts[-1]
jobs.append({"user": owner, "schedule": schedule, "command": command})
return jobs
def boottime(text: str) -> Optional[int]:
"""``sysctl -n kern.boottime``: FreeBSD ``{ sec = N, … }``, OpenBSD ``N``."""
match = re.search(r"sec = (\d+)", text) or re.fullmatch(r"\s*(\d+)\s*", text)
return int(match[1]) if match else None
def _socket(
proto: str, local: str, family: str, process: Optional[str], pid: Optional[int]
) -> Optional[dict[str, Any]]:
"""A ListeningSocketDict from a local endpoint: ``*:22``, ``[::1]:25``,
``127.0.0.1:25``, ``[fe80::1%lo0]:25`` (sockstat, fstat) or ``*.22``,
``::1.25``, ``fe80::1%lo0.25`` (netstat)."""
if local.startswith("["):
address, _, port = local[1:].partition("]:")
elif local.count(":") == 1 or local.startswith("*:"):
address, _, port = local.rpartition(":")
else:
address, _, port = local.rpartition(".")
if not port.isdigit() or int(port) == 0:
return None
address, _, zone = address.partition("%")
if address == "*":
address = "0.0.0.0" if family == "4" else "::"
return {
"proto": proto,
"address": address,
"port": int(port),
"interface": zone or None,
"process": process,
"pid": pid,
"unit": None,
"container_id": None,
}
def _unique(sockets: list[Optional[dict[str, Any]]]) -> list[dict[str, Any]]:
"""One entry per socket, the first process holding it, as ss reports it."""
seen: set[tuple] = set()
result = []
for s in sockets:
if s is None:
continue
key = (s["proto"], s["address"], s["port"], s["interface"])
if key not in seen:
seen.add(key)
result.append(s)
return result
def sockstat(text: str) -> list[dict[str, Any]]:
"""FreeBSD ``sockstat -46lq -P tcp,udp``: USER COMMAND PID FD PROTO LOCAL FOREIGN."""
sockets = []
for line in text.splitlines():
words = line.split()
if len(words) < 7 or not words[2].isdigit() or words[4][:3] not in ("tcp", "udp"):
continue
proto, family = words[4][:3], words[4][3:]
sockets.append(_socket(proto, words[5], family, words[1], int(words[2])))
return _unique(sockets)
def fstat_sockets(text: str) -> list[dict[str, Any]]:
"""OpenBSD ``fstat -n``: the internet sockets nothing is connected to.
``USER CMD PID FD internet[6] stream|dgram tcp|udp [0xPCB] LOCAL [ARROW REMOTE]``;
a socket with an arrow is connected, one bound to port 0 is not bound.
"""
sockets = []
for line in text.splitlines():
words = line.split()
if len(words) < 8 or words[4] not in ("internet", "internet6"):
continue
if any(arrow in words for arrow in ("<--", "-->", "<->")):
continue
family = "6" if words[4] == "internet6" else "4"
sockets.append(_socket(words[6], words[-1], family, words[1], int(words[2])))
return _unique(sockets)
def netstat_listening(text: str) -> list[dict[str, Any]]:
"""``netstat -an``: listening TCP and bound UDP sockets, without their process."""
sockets = []
for line in text.splitlines():
words = line.split()
if len(words) < 5 or words[0][:3] not in ("tcp", "udp"):
continue
proto, family = words[0][:3], "6" if words[0].endswith("6") else "4"
local, foreign = words[3], words[4]
if proto == "tcp" and words[-1] != "LISTEN":
continue
if proto == "udp" and foreign != "*.*":
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