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.
493 lines
17 KiB
Python
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
|