"""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[^\s:]+): flags=[0-9a-f]+<(?P[^>]*)>(?:.*?\bmtu (?P\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[\d.]+)\) at (?P[0-9a-f:]{17}) on (?P\S+)" r"(?: expires in (?P\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\S+)\s+(?P\d+)\s+(?P\d+)\s+(?P[\d.]+)\s+(?P[\d.]+)\s+" r"(?P\d+)\s+(?P\d+)\s+(?P\S+)\s+" r"(?P\w{3}\s+\w{3}\s+\d+\s+\d\d:\d\d:\d\d\s+\d{4})\s+(?P.*)$" ) 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)