FreeBSDDriver (`freebsd`) and OpenBSDDriver (`openbsd`) on a shared BsdDriver, for the BSD VMs netOrk provisions and BSD hosts added by hand (NetOrk/netork#798). - SSH over exec channels (napalm-device-types' run_on_transport): no PTY, stdout and stderr apart, real exit codes; run_command()/open_stream() are public. Root through `sudo -S` with the password on stdin, or `sudo -n` without one. - Facts: hostname, release and running kernel (freebsd-version / uname), hardware from SMBIOS (kenv) or hw.* sysctls, uptime from kern.boottime. - Interfaces and addresses (ifconfig -a: ether/lladdr, hex netmasks, scoped IPv6), routes (netstat -rn, OpenBSD's abbreviated networks written out, host/broadcast/loopback entries left out), ARP (FreeBSD's sentences, OpenBSD's table), users and groups, processes (lstart read as one five-word field), cron jobs (system crontab and the login user's). - Parsers are pure functions, tested on output recorded from FreeBSD 15.1 (hand-installed and cloud image) and OpenBSD 7.9 (NetOrk/netork#793), with internal addresses replaced by documentation ranges.
260 lines
9.5 KiB
Python
260 lines
9.5 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
|