CI / test (3.10) (push) Successful in 29s
CI / test (3.11) (push) Successful in 30s
CI / test (3.12) (push) Successful in 29s
CI / test (3.10) (pull_request) Successful in 29s
CI / test (3.11) (pull_request) Successful in 46s
CI / test (3.12) (pull_request) Successful in 42s
first_boot_pending() is true while /firstboot exists on FreeBSD. A FreeBSD cloud image upgrades its base system on its first boot, starts sshd only after that and restarts right away, and /etc/rc removes /firstboot just before that restart. netOrk waits for this before it sets up a new VM (NetOrk/netork#795). OpenBSD has no such marker to ask yet, so it returns False there.
129 lines
5.4 KiB
Python
129 lines
5.4 KiB
Python
"""FreeBSD: kenv for the hardware, freebsd-version for base and kernel."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from napalm_device_types import FingerprintRule
|
|
|
|
from napalm_bsd import parse
|
|
from napalm_bsd.base import BASE_SYSTEM, BsdDriver
|
|
|
|
|
|
class FreeBSDDriver(BsdDriver):
|
|
"""NAPALM driver for FreeBSD hosts, over SSH."""
|
|
|
|
TYPE_LABEL = "FreeBSD"
|
|
VENDOR = "FreeBSD"
|
|
DRIVER_NAME = "freebsd"
|
|
SNMP_FINGERPRINT = [FingerprintRule("freebsd", weight=5.0)]
|
|
# OpenSSH in FreeBSD's base names it in its banner ("OpenSSH_9.9 FreeBSD-20250219").
|
|
SSH_FINGERPRINT = [FingerprintRule("freebsd", weight=3.0)]
|
|
|
|
HOSTNAME_COMMAND = "sysctl -n kern.hostname"
|
|
# Userland and running kernel; both carry the patch level (15.1-RELEASE-p4).
|
|
OS_VERSION_COMMAND = "freebsd-version -u"
|
|
KERNEL_COMMAND = "freebsd-version -r"
|
|
# kenv takes one variable per call, and a missing one fails the call.
|
|
PLATFORM_COMMAND = (
|
|
"for k in maker product serial; do "
|
|
'printf "%s=%s\\n" "$k" "$(kenv -q smbios.system.$k)"; done'
|
|
)
|
|
# -W: FreeBSD cuts long destinations to the column width otherwise.
|
|
ROUTES_COMMAND = "netstat -rnW"
|
|
# sockstat names the process of every socket it can see; root sees them all.
|
|
LISTENING_COMMAND = "sockstat -46lq -P tcp,udp"
|
|
# /etc/rc removes it at the end of the first boot.
|
|
FIRST_BOOT_MARKER = "/firstboot"
|
|
|
|
def _parse_listening(self, output: str, *, attributed: bool) -> list[dict]:
|
|
return parse.sockstat(output)
|
|
|
|
PKG_QUERY = "pkg query '%n\t%v\t%R\t%sb\t%c'"
|
|
# Bootstraps pkg on a classic system that never had it, instead of asking.
|
|
INSTALL_COMMAND = "env ASSUME_ALWAYS_YES=yes pkg install -y {name}"
|
|
UNINSTALL_COMMAND = "pkg delete -y {name}"
|
|
# Root reads every daemon's pidfile; one-shot scripts have no status.
|
|
SERVICE_STATUS_COMMAND = (
|
|
"for s in $(service -e); do n=${s##*/}; "
|
|
'printf "%s\\t%s\\n" "$n" "$(service $n status 2>&1 | head -1)"; done'
|
|
)
|
|
SERVICE_ACTION_COMMAND = "service {name} {action}"
|
|
SNMPD_CONF = "/usr/local/etc/snmp/snmpd.conf"
|
|
# The rc script drops to the snmpd user, so the file stays readable (644).
|
|
SNMPD_START = "sysrc snmpd_enable=YES && service snmpd restart"
|
|
|
|
def _parse_services(self, output: str) -> list[dict]:
|
|
return parse.service_status(output)
|
|
|
|
def _has_pkg(self) -> bool:
|
|
"""pkg is bootstrapped; a hand-installed classic system may have only the stub.
|
|
|
|
Only the stub's "not installed" means no pkg; any other failure (a
|
|
database pkg cannot read) raises rather than read as "no packages".
|
|
"""
|
|
result = self.run_command("pkg -N")
|
|
if result.exit_code == 0:
|
|
return True
|
|
message = (result.stderr or result.stdout).strip()
|
|
if "is not installed" in message:
|
|
return False
|
|
raise RuntimeError(f"pkg -N: {message or f'exit {result.exit_code}'}")
|
|
|
|
def get_packages(self) -> list[dict]:
|
|
return parse.pkg_query(self._out(self.PKG_QUERY)) if self._has_pkg() else []
|
|
|
|
def get_available_updates(self) -> list[dict]:
|
|
"""Package updates from ``pkg upgrade -n``, and fetched base-system updates.
|
|
|
|
``security`` comes from VuXML (``pkg audit``): True for a package it
|
|
lists as vulnerable, False for one it does not, None when the audit
|
|
could not run. On pkgbase the base system is packages from the
|
|
FreeBSD-base repository; a classic base reports one entry (#799).
|
|
"""
|
|
updates: list[dict] = []
|
|
if self._has_pkg():
|
|
upgrade = self._read("pkg upgrade -n", privileged=True, timeout=300)
|
|
updates = parse.pkg_upgrades(upgrade.stdout)
|
|
audit = self.run_command("pkg audit -Fq", privileged=True, timeout=120)
|
|
vulnerable = parse.pkg_audit(audit.stdout)
|
|
# 1 is "vulnerable packages found" and any error alike; only a list
|
|
# of packages makes it a verdict.
|
|
known = audit.exit_code == 0 or (audit.exit_code == 1 and bool(vulnerable))
|
|
for update in updates:
|
|
update["security"] = (update["name"] in vulnerable) if known else None
|
|
if "FreeBSD-base" in self._out("pkg repos -l").split():
|
|
return updates
|
|
return updates + self._base_updates()
|
|
|
|
def _base_updates(self) -> list[dict]:
|
|
"""A classic base system's updates, as freebsd-update fetched them.
|
|
|
|
``updatesready`` exits 0 when fetched updates wait to be installed and
|
|
2 when there are none; it does not fetch, which ``freebsd-update cron``
|
|
does daily where it is enabled.
|
|
"""
|
|
ready = self._read(
|
|
"freebsd-update --not-running-from-cron updatesready",
|
|
privileged=True,
|
|
timeout=60,
|
|
ok=(0, 2),
|
|
)
|
|
if ready.exit_code == 2:
|
|
return []
|
|
return [
|
|
{
|
|
"name": BASE_SYSTEM,
|
|
"current_version": self._out("freebsd-version -u"),
|
|
"new_version": "fetched by freebsd-update",
|
|
"origin": "freebsd-update",
|
|
"security": None,
|
|
}
|
|
]
|
|
|
|
def _os_version(self) -> str:
|
|
version = super()._os_version()
|
|
return f"FreeBSD {version}" if version else ""
|
|
|
|
def _hardware(self) -> tuple[str, str, str]:
|
|
smbios = self._platform()
|
|
return smbios.get("maker", ""), smbios.get("product", ""), smbios.get("serial", "")
|