Files
napalm-bsd/napalm_bsd/parse.py
T
christianmanivong 5f53de0c25
CI / test (3.10) (push) Successful in 27s
CI / test (3.11) (push) Successful in 27s
CI / test (3.12) (push) Successful in 37s
CI / test (3.10) (pull_request) Successful in 42s
CI / test (3.11) (pull_request) Successful in 42s
CI / test (3.12) (pull_request) Successful in 38s
feat: report listening sockets
get_listening_sockets() in the shape of napalm-device-types'
ListeningSocketsMixin, whose ss/cgroup reading is Linux's, and with its
rule: read as root first, and without root when that brings nothing back,
which the reading reports as `attributed: False`.

- FreeBSD: `sockstat -46lq -P tcp,udp`, which sees every socket either way.
- OpenBSD: `fstat -n` as root (the sockets nothing is connected to; port 0
  is unbound), `netstat -an` without root, as fstat shows a user only
  their own processes; those sockets come without a process.
- Addresses as ss prints them: `*` becomes 0.0.0.0 or :: by family, IPv6
  without brackets, a zone (`fe80::1%lo0`) becomes the interface. One entry
  per socket, the first process holding it.

No get_kernel_facts on purpose: KernelFactsMixin reports a Linux kernel's
modules and CONFIG_ options, which a BSD kernel does not have.

Fixtures recorded on the FreeBSD 15.1 and OpenBSD 7.9 VMs with test
listeners on 127.0.0.1 and ::1 (NetOrk/netork#798).
2026-10-08 09:26:21 +02:00

350 lines
13 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)