Discovery compares drivers by score divided by the most a driver could
score, and that maximum summed every positive rule. Rules that never
match together, such as one vendor's landing page or another's, made a
driver that knows several vendors lose to one that knows none:
napalm-redfish's Supermicro landing page scored 6 of 41 and lost to a
bare "linux" in the same BMC's SNMP sysDescr, 5 of 18.
FingerprintRule gets an optional group. rules_max_weight() sums the
positive rules and counts each group once, with its strongest rule.
Existing rules have no group and count as before.
## feat!: settle the open review questions on roles, diffs, SSH and SNMP
- roles: a driver that sets ROLE itself replaces the role it inherits,
so a Windows driver on OSDriver is "windows", not also "linux".
- os: OSDriver's account listing is get_os_users; NAPALM's get_users has
another shape and a runtime stub, so hasattr could not tell.
- README: NAPALM's own getters must come from the driver or a mixin
listed before the first role base; its stubs win the MRO otherwise.
- firewall/DHCP diffs: an empty key, a key given twice, or one more than
one live entry holds raises ValueError instead of overwriting by guess;
live entries without a key are never matched.
- ssh_server: only host key algorithms the host has a key for count as
staying; when the keys cannot be read, the host key list is not
touched. sshd_config and its copy keep their file mode (GNU and BSD
stat), 644 only for a new file.
- SNMP: 64-bit ifXTable octets and ifHighSpeed, with the 32-bit counters
as fallback for agents without ifXTable.
- pyproject: declare typing-extensions, which models.py imports.
BREAKING CHANGE: OSDriver.get_users is now get_os_users; diffs raise
ValueError on keys that cannot name one entry; plan_ssh_hardening
removes no host key algorithm without host_keys.
## fix: tell "could not read" and "does not install" apart in update and VIP readers
- package_updates: the dnf security reader records dnf's exit status and
raises when it is missing or non-zero, as the apt reader does; a failed
dnf no longer reads as "no security advisories".
- host_status: an enabled dnf-automatic.timer counts as auto updates only
with apply_updates = yes (dnf5's automatic.conf wins over dnf4's);
dnf-automatic-install.timer always does. Unattended-upgrades counts only
while the program is installed, not from a conffile left behind.
- virtual_ips: a host with systemctl but without systemd as PID 1 reads as
"did not look" (None), not as "has none". keepalived blocks whose brace
is on the next line are read.
BREAKING CHANGE: parse_dnf_security expects DNF_SECURITY_COMMAND's
__DNF_RC marker and raises ValueError without it.
A role base for baseboard management controllers (Supermicro, Dell
iDRAC, HPE iLO, Lenovo XCC), reached over Redfish or IPMI, not SSH.
It declares, under TYPE_CHECKING only: get_power_state, set_power_state
(Redfish ResetType actions), get_hardware_inventory (CPUs, DIMMs,
drives, PSUs, NICs, and the server's serial, system UUID and NIC MACs),
get_event_log, get_firmware_inventory and get_management_protocols.
reboot_host restarts the BMC itself (Manager.Reset), never the managed
server, which goes through set_power_state. No HealthMetricsMixin and no
command channel; sensors come through NAPALM's get_environment.
Squashes three changes:
## chore: support Python 3.13 and make mypy --strict pass in CI
Add 3.13 to the CI matrix and classifiers, run mypy in CI, fix the
14 strict errors it reported, and correct the README's minimum Python.
## fix: eight bugs found in a full review, each with a regression test
- channel: run_on_transport lost output sshd sent after the exit status;
it now reads stdout and stderr to EOF before returning.
- ssh_server: a failed way back (restore or reload) raised out of
harden_ssh_server after the host was touched; it is now reported in
the result, with rolled_back False.
- host_status: a newer RPM kernel (RHEL, Fedora) was never seen, because
its release, not its arch, followed the last "-".
- package_updates: an apt line wrapped at a blank lost the blank, and
with it the package.
- kernel: an empty payload (gzip or base64 missing) read as a report.
- listening: "[fe80::1]%eth0:546", as ss prints it, kept its brackets.
- ping_sweep: a reply whose "success" was not a dict aborted the sweep.
- lag: a member of unknown speed (-1) lowered the LAG's speed.
## docs: bring the README and docstrings in line with the code
The firewall worked example showed the runtime stubs the README forbids;
the class table missed PhoneDriver and MediaDriver; ConfigLifecycleMixin
is mixed in by drivers, not by a role base; services, mac_acl and
packages named a class, a key and an exception that do not exist.
ssh-audit names algorithms an SSH server should stop offering. Taking them
out is the same on every OpenSSH host, so it lives here once: a driver
mixes in OpenSshServerMixin and supplies run_command.
harden_ssh_server reads the lists sshd really uses (sshd -T), removes the
requested algorithms and writes the rest to
/etc/ssh/sshd_config.d/00-netork-ssh-hardening.conf -- or, where
sshd_config has no Include of that directory, rewrites sshd_config after
a copy. It never removes the last algorithm of a list, nor, given the
managing client's lists, the last one that client speaks. sshd -t must
accept the result and sshd -T must show it; after the reload the caller's
verify opens a new session, and if that fails the still-open one puts the
previous state back and reloads again.
Bumps to 4.5.0. NetOrk/netork#1027
A Proxmox host's root filesystem stood at 94 % with about 400 MB free for a
week; a kernel upgrade failed on it with "No space left on device" and left
dpkg halfway, and nothing had said the disk was full (NetOrk/netork#862).
HOST_STATUS_COMMAND now also runs POSIX `df -Pk / /boot /var`, without root.
get_host_status() returns `filesystems`: each filesystem once (a directory
that is not a mount of its own is the filesystem it lives on), with its
mountpoint, total, used and available bytes, or None when df said nothing.
`available` is what df says an ordinary user can still write, which on ext4
is less than total minus used. How full is too full is the caller's decision.
Version 4.4.0.
An unattended upgrade cut off while it built an initrd left a kernel package
half-configured on an Ubuntu host. For three months apt refused every upgrade,
while apt.systemd.daily exited quietly and the timers reported success
(NetOrk/netork#738).
HOST_STATUS_COMMAND now also reads, without root:
- dpkg's state the way apt checks it: numbered files left in
/var/lib/dpkg/updates, or a package half-installed, unpacked,
half-configured, waiting for triggers or flagged for reinstallation.
While dpkg, apt, aptitude or unattended-upgrade runs, packages are
halfway on purpose, so the answer is then None.
- /var/lib/apt/periodic/upgrade-stamp, which apt.systemd.daily touches only
after unattended-upgrade succeeded, and the interval of
APT::Periodic::Unattended-Upgrade in apt's units. update-success-stamp is
not read: every apt-get update touches it, whoever runs it.
HostStatusDict gains package_manager_interrupted, interrupted_packages,
auto_updates_last_success and auto_updates_interval, all NotRequired.
A virtual IP sits on whichever member holds it right now; a standby's
address list shows no trace of it. What every member has is the
configuration that declares it, and reading that is the same on every
Linux host -- so VirtualIpsMixin.get_virtual_ips() lives here and a driver
only carries the command across (NetOrk/netork#829).
- vip-manager: every vip-manager.service / vip-manager@*.service unit; the
address from a flag (1.x's -ip=${VIP_IP} resolved through the unit's
environment), the environment, or the --config YAML (5.x).
- keepalived: the virtual_ipaddress(_excluded) entries of every
vrrp_instance, with its interface and virtual_router_id; include is
followed four levels deep, and a pattern that is no plain path glob is
never handed to the root shell.
The same files hold the etcd password and auth_pass, so an awk program
filters on the host and prints allowlisted fields only. None per
mechanism is "did not look", [] a host without it. The command is about
4 kB and goes on an exec channel.
Version 4.2.0.
GUEST_AGENT_PACKAGES / GUEST_AGENT_RUNCMD described one agent per
hypervisor. A hypervisor that provisions FreeBSD and OpenBSD guests needs
a different agent per guest, and netOrk has to know which guests a driver
can provision at all (NetOrk/netork#794). Both now come from one
class-level mapping netOrk reads off the driver class:
GUEST_AGENTS = {guest_os: (packages, runcmd)}
Its keys are the guests the driver provisions, and
create_vm_from_cloud_init(guest_os=...) refuses any other. The base keeps
Linux with qemu-guest-agent.
New module `provisioning`, generic for every hypervisor:
- QEMU_GUEST_AGENTS: the QEMU agent for Linux, FreeBSD and OpenBSD, with
package names and service commands verified on FreeBSD 15.1 and
OpenBSD 7.9 cloud images (NetOrk/netork#793).
- network_config(): cloud-init's network-config v2 with MAC matching,
moved here from napalm-vmware. FreeBSD's nuageinit reads no other
version.
- split_compression(): how a packed image (.xz, .gz, .bz2, .zst) is
unpacked.
BREAKING CHANGE: GUEST_AGENT_PACKAGES and GUEST_AGENT_RUNCMD are gone;
drivers declare GUEST_AGENTS instead.
run_cli() and stream_cli() take privileged=, passed to the driver's
run_command()/open_stream(): an engine that refuses the login user can
be retried as root, the way the driver gains root for any command.
netOrk uses it to retry a refused container start/stop/restart with
sudo (NetOrk/netork#773).
netOrk reached a host's shell through napalm-linux's private `_send`: an
interactive PTY with stdout and stderr merged and no exit code. For Docker
it went further and opened its own paramiko connections around the driver.
This adds the access layer only, no container model (NetOrk/netork#765):
- `channel.py`: `CommandChannelMixin` declares `run_command()` (stdout,
stderr, exit code) and `open_stream()` (a `ByteStream` to a running
command). Declared under TYPE_CHECKING, so hasattr stays truthful.
`run_on_transport()` and `open_stream_on_transport()` implement both on
a paramiko exec channel for any SSH driver; `ParamikoExecStream` drains
stderr on every read so the shared window never stalls.
- `container_engine.py`: `ContainerEngineMixin` with `container_engines()`
and `open_container_engine()`. The returned `ContainerEngineConnection`
opens the engine API over `<binary> system dial-stdio` and runs the CLI
with caller-chosen arguments. The driver decides the binary through
`_container_engine_binary()`; callers never see the path.
- README: why the container model lives in netOrk and not here.
Additive; the Docker*Dict declarations stay until netOrk no longer reads
them. Version 2.6.0.
Refs #17
OpenWrt has no ss: the listening-socket command fell through to [no-ss]
and the reading raised ListeningSocketsUnavailable. Without ss it now runs
netstat -lntup (busybox and net-tools both), and the cgroups are read for
its PIDs alike.
- netstat names a socket's process as PID/Program; a UDP line has no
state column, "-" is a socket without a process, and net-tools prints
tcp6/udp6 and program names with a space ("sshd: /usr/sbin").
- On OpenWrt the cgroup is /services/<name>/<instance>, so the unit is
the procd service -- a jailed one too, whose PID is not the one procd
reports (dnsmasq under ujail).
- A host with neither tool still raises ListeningSocketsUnavailable.
Checked against a real OpenWrt 25.12.2 access point, and that a Linux and
a Proxmox host still read the same sockets with the longer command.
2.5.0. For netOrk#673.
Whether a service is reachable from outside its host is decided by the
address it listens on: 0.0.0.0:5432 is, 127.0.0.1:5432 is not. Reading
that is the same on every Linux host, so the command and its parse live
here once and a driver only carries the command across
(ListeningSocketsMixin, _run_listening_sockets_command).
One framed round trip: ss -lntup for every listening TCP and bound UDP
socket, then /proc/<pid>/cgroup for each process holding one, which names
the systemd service (v2, nested slices, v1's name=systemd hierarchy) or
the container (docker-<id>.scope, /docker/<id>) it runs in.
- Root: only root sees every process. The script goes as one sh -c
argument, so a sudo -n prefix covers all of it; when that brings no
report back the reading runs again unprivileged and says it is not
attributed.
- No -H: iproute2 before 4.10 fails on it, which would read as nothing
listening. The header is skipped instead.
- A host without ss raises ListeningSocketsUnavailable; a report cut short
or a failing ss raises ValueError.
- The reading is raw: docker-proxy shows up as docker.service, loopback as
loopback. What counts as reachable is the consumer's call.
2.4.0. For netOrk#658.
netOrk MVP 5 measures "security updates applied within N days" and starts
patch runs inside agreed windows. That needs three things every Linux driver
reads the same way, so they live here once:
- UpdateDict gains optional `origin` and `security` (None = unknown).
- package_updates: APT_UPGRADABLE_COMMAND and parse_apt_upgradable(). apt's
suites are the origin; a "-security" suite makes it a security update;
several architectures of one package are one entry. The command runs
through a pipe with its exit status printed inside the group: through a
pseudo-terminal apt drew progress and keypad codes, one of which (ESC >)
a screen-scraping read took for a prompt and stopped at. The parser raises
ValueError when apt failed or its status never arrived.
DNF_SECURITY_COMMAND / parse_dnf_security() / nevra_name() for dnf/yum.
- host_status: HOST_STATUS_COMMAND, parse_host_status(), HostStatusMixin
(template form, hook _run_host_status_command). reboot_required from
/var/run/reboot-required, needs-restarting -r, or a newer kernel of the
running flavour (a Raspberry Pi carries two flavours side by side);
auto_updates from APT::Periodic::Unattended-Upgrade with its timer, or
dnf-automatic. HostStatusDict in models.py.
- terminal.strip_terminal_codes(): CSI, OSC and two-character escapes, now
also used by the systemd parser.
- UpdateMixin: contract refresh_available_updates(); get_available_updates'
docstring now states the rule that a reader raises when it cannot read and
never returns [] for "don't know".
Fixtures are real output from Ubuntu 24.04, Debian 13 / OMV, Raspberry Pi OS
and Proxmox VE 9. Version 2.3.0.
Listing a host's services and starting or stopping one is the same on every
host that runs systemd, so the command, its parse, the check of a unit name
and the reading of an action's exit status live here once, and a driver
supplies only the transport (napalm-linux#7, napalm-proxmox#6):
- SYSTEMD_SERVICES_COMMAND: one read-only POSIX sh line. list-unit-files, then
one systemctl show over every loaded service unit (Id, Names, LoadState,
ActiveState, SubState, UnitFileState, MainPID), and is-enabled only for
generated units, whose boot state lives in a SysV script's rc links. Framed;
[no-systemd] when /run/systemd/system is missing. Replaces an is-enabled and
a show per unit: 0.8 s instead of 6 s on a 180-unit Ubuntu host.
- parse_systemd_services(): loaded units except not-found, plus installed unit
files that are not loaded; no templates, no aliases (also not the ones older
systemd lists as "enabled"). enabled = UnitFileState enabled or
enabled-runtime, read from systemctl show and never from list-unit-files'
second column, which has had a preset column after it since systemd 245.
A report whose end is missing raises ValueError, so a list cut short never
reads as services that went away; a host without systemd raises
SystemdUnavailable, a NotImplementedError, so a driver can fall back.
- unit_name() / service_action_command() / parse_action_result(): template
instances, dots, colons and \xHH escapes accepted; a bare template, a leading
"-" and anything a shell reads refused. The action runs as
"timeout 45 systemctl --no-ask-password <action> -- <unit>.service" with its
exit status printed after it; only that status decides, 124 is not called
done, and terminal colour codes are dropped. The marker also keeps the output
from ever being empty, which a transport that retries on an empty answer
would take as a reason to run the action twice.
- SystemdServicesMixin, in the template form: get_services() and
manage_service() are concrete, _run_service_command(command, *, privileged,
timeout) is the driver's hook. Mixed in by the drivers whose host runs
systemd, not by OSDriver.
Version 2.2.0.
A kernel CVE's exploitability often hangs on code that is not there: a module
neither loaded nor shipped, an option the kernel was built without. netOrk's
KB precondition vocabulary asks exactly that (kernel_module, kernel_config).
Reading it is the same on every Linux host, so the command and its parse live
here and a driver supplies only the transport:
- KERNEL_FACTS_COMMAND: one read-only POSIX sh line, no privileges. Release,
/proc/modules, modules.builtin, modules.dep and the build configuration
(/boot/config-* or /proc/config.gz). The report is framed, gzipped and
base64-encoded, so nothing in it can look like a shell prompt to a
screen-scraping transport, and ~300 kB of configuration crosses as a fifth.
- parse_kernel_facts(): a section the command could not print comes back None,
never empty -- "could not read" and "read, and nothing there" must stay apart.
- module_name(): no path, no .ko suffix, "-" folded to "_", as the kernel does.
- KernelFactsMixin, in the template form: get_kernel_facts() is concrete,
_run_kernel_facts_command() is the driver's hook. Mixed in by the drivers
that can, not by OSDriver -- a Windows host is an OS driver too, and
hasattr(driver, "get_kernel_facts") has to stay truthful.
- KernelFactsDict in models.py. Version 2.1.0.
create_vm_from_cloud_init takes cpu_type. Proxmox gives a VM created without
one the kvm64 model, which has no AVX, so MongoDB 5.0 and later do not start
there, and netOrk's graylog role failed on every VM it provisioned
(netork#494). Which model is right depends on the cluster: host cannot
live-migrate between different CPUs, x86-64-v3 does not start on a CPU older
than Haswell. So the caller chooses.
get_vm_cpu_types() is the new, optional listing behind that choice. Each
VMCpuTypeDict names the model, says what it is for, lists the /proc/cpuinfo
flags the guest gets (so a caller can ask "does this give AVX?" without
knowing model names), whether the node at hand can run it, and which one is
the default. A hypervisor whose VMs have no per-VM CPU model (VMware sets CPU
compatibility per cluster) does not implement it and must reject any
cpu_type other than None.
Both declarations sit under TYPE_CHECKING like the rest of the contract, so
hasattr stays a truthful capability probe; the tests read the signature from
the source.
get_port_forwards was declared on ResidentialGatewayDriver alone, as if a
port forward were a home-router feature. A firewall forwards ports just the
same (OPNsense calls it destination NAT), and netOrk asks both: is this host
reachable from the internet, which CVEs are exposed. The declaration moves to
NatVpnMixin, where the two roles already overlap, and PortForwardDict next to
NATTranslationDict.
The contract now says what counts. Destination NAT between internal networks
and rules that only exempt traffic are not port forwards: callers read every
entry as "reachable from outside". "ANY" forwards every protocol and an
external port of 0 every port -- a whole host forwarded is the most exposed
case and must not fall out for lack of a port number.
Declaration only, under TYPE_CHECKING: nothing changes at runtime.
HostRebootMixin declares reboot_host(), mixed into DeviceTypeDriver so
any device may be restartable. netOrk restarted hosts by sending
/sbin/reboot through a driver's private _send_command; a driver talking
to an API had no such method and the reboot was silently skipped.
HypervisorDriver gains GUEST_AGENT_PACKAGES / GUEST_AGENT_RUNCMD, the
agent cloud-init installs so the hypervisor can read a new VM's IP.
The default stays qemu-guest-agent; VMware declares open-vm-tools.
NetworkTargetDict.kind may be "portgroup": a VMware port group fixes its
VLAN like an SDN vnet does, without being one.
VMDict.vmid and VMConfigDict.vmid were int. Proxmox numbers its guests,
but VMware identifies a VM by UUID, which an int cannot hold. The
provisioning dicts already carried vmid as a string; the read side now
matches. Proxmox reports "100".
VMConfigDict gains optional hardware details -- os_name, cpu_type,
sockets, cores_per_socket, firmware, machine and passthrough (PCI/USB,
as VMPassthroughDict) -- so netOrk's VM hardware view can be filled by
any hypervisor instead of reading Proxmox's raw config through the
driver's private API.
Also fixes the README's hypervisor example, which still named the
pre-contract snapshot_create.
BREAKING CHANGE: VMDict.vmid and VMConfigDict.vmid are str.
A role base used to fill its methods with `raise NotImplementedError`. That is
not neutral under multiple inheritance: the placeholder wins the MRO against a
sibling base's working implementation and silently replaces it. Adding one stub
to a base was therefore a breaking change for every driver mixing that base with
another, and it broke three of them — OpenWrt grew seven forwarding methods,
QNAP one, and OpenMediaVault avoided inheriting StorageDriver at all.
Role bases now declare their surface under `if TYPE_CHECKING` and implement
nothing. There is no longer anything to shadow, so a device can finally say what
it is:
class QnapQtsDriver(StorageDriver, HypervisorDriver, LinuxDriver):
The order of those bases is the ranking, read back by roles_of(),
role_keys_of() and primary_role_of() in the new roles module. Nothing restates
it: no precedence table, no attribute to override.
Two consequences, both wanted. `hasattr` is a truthful capability probe again,
because a method exists exactly when a driver provided it. And a method that was
never implemented now raises AttributeError rather than NotImplementedError, so
callers should ask before calling.
Shared behaviour moves out of the roles and into function classes, each holding
it once: PackageManagementMixin (was five byte-identical copies),
HealthMetricsMixin (five), ServiceControlMixin, UpdateMixin, NatVpnMixin,
MacAclMixin, FirewallRuleMixin, InterfaceFilterMixin.
BREAKING CHANGE: methods whose contract genuinely differed were renamed apart —
StorageDriver.get_services -> get_storage_services, the storage and hypervisor
snapshot writers -> create/delete/rollback_{volume,vm}_snapshot,
HypervisorDriver.get_storage -> get_vm_storage_pools, get_snapshots ->
get_vm_snapshots, SwitchDriver.get_dot1x_config -> get_dot1x_ports. Two
duplicate names collapsed onto the one already in use: get_pending_updates ->
get_available_updates and remove_package -> uninstall_package.
Also fixes __doc__ being None on all seven role bases: TYPE_LABEL was assigned
above the triple-quoted string, which made it a bare expression rather than a
docstring.
Adds the generic half of DHCP reservation management: diff_dhcp_reservations
matches desired against live reservations by normalised MAC, and
apply_dhcp_reservationset walks the diff and commits once at the end.
Both are concrete here because neither is vendor-specific — only
get_dhcp_reservations/apply_dhcp_reservation/commit_dhcp_reservations touch
the device (Kea REST on OPNsense, dnsmasq/odhcpd UCI on OpenWrt).
Two deliberate choices:
- The MAC is the matching key, not a description as with firewall rules. A
reservation has a natural identity and this is it. That also means a host
moving to another VLAN is an update of the existing entry rather than a
second one for the same MAC.
- An empty diff skips the commit. Committing reloads the DHCP daemon and
drops in-flight requests, which is too high a price for a no-op run. This
differs from apply_firewall_ruleset, which always commits.
Live reservations with no desired counterpart are never reported for
deletion — a DHCP server routinely carries hand-created entries the caller's
desired set was never meant to describe.
Mixed into FirewallDriver and ResidentialGatewayDriver: both device types
commonly run the DHCP server for their networks.
Sweeping a range is orchestration, not device mechanics: the only
vendor-specific part is executing a single ping, and NAPALM already
standardises that. PingSweepMixin therefore owns the loop, the reply parsing,
the target cap and the progress reporting, and is mixed into DeviceTypeDriver
so any driver implementing ping() becomes a usable sweep source without
writing sweep code of its own.
driver_supports_ping() answers "can this driver ping?" by introspection
instead of a hand-maintained list, with SUPPORTS_PING = False as the opt-out
for a driver that inherits a ping it cannot actually use.
The generic implementation is deliberately sequential — a NAPALM connection is
a single session and not safe to drive from several threads at once. A driver
whose device offers something faster overrides ping_sweep and keeps the return
shape; see napalm-opnsense's batched job API version.
Makes explicit a rule that's been applied ad hoc: matching/comparison/
orchestration logic that's identical across every driver of a device-type
belongs as a concrete method on the abstract base class; only actual
device communication (REST/CLI/payload format) belongs in the concrete
vendor driver as an implementation of an abstract method. Uses the
upcoming FirewallDriver diff/apply mechanism as the worked example.
- Introduce OSDriver abstract base class (os.py) for general-purpose OS
drivers (Linux, BSD, macOS) — registers package management, service
management, users, processes, cron job and the two new OS-specific
extension points
- Add get_docker_info() contract: returns DockerInfoDict covering
containers, images, volumes, networks and outdated image detection
- Add run_device_action() contract: generic extensibility point for
driver-specific one-off administrative actions
- Fix duplicate TypedDicts in models.py: remove early shadow definitions
of UserDict, ProcessDict, CronJobDict, ApplyUpdatesResultDict from the
Common section; keep the more complete definitions in the OS section
- Add Docker TypedDicts: DockerContainerDict, DockerImageDict,
DockerVolumeDict, DockerNetworkDict, DockerInfoDict
- Add DeviceActionResultDict
- Export OSDriver from package __init__; bump version to 0.3.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>