Mechanical: ruff format, and ruff check --fix with the rule set every NAPALM driver uses (E, F, I, UP, B at line length 100, Python 3.10). What ruff cannot fix comes in the next commit.
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
|
|
|
|
_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: dict[str, Any] | None = 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) -> str | None:
|
|
"""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: int | None = 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) -> int | None:
|
|
"""``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: str | None, pid: int | None
|
|
) -> dict[str, Any] | None:
|
|
"""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[dict[str, Any] | None]) -> 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
|