Commit Graph
90 Commits
Author SHA1 Message Date
christianmanivong 90367c1370 feat: an HA pair's DHCP peers and config sync as facts (4.11.0)
CI / test (3.10) (push) Successful in 26s
CI / test (3.11) (push) Successful in 24s
CI / test (3.12) (push) Successful in 27s
CI / test (3.13) (push) Successful in 26s
CI / test (3.10) (pull_request) Successful in 26s
CI / test (3.11) (pull_request) Successful in 24s
CI / test (3.12) (pull_request) Successful in 27s
CI / test (3.13) (pull_request) Successful in 25s
get_dhcp_ha (declared on DhcpServerMixin, optional) is what a DHCP
server's own HA configuration says: on or off, this server's peer name,
each peer's name, role as the server words it, and HA URL.
ConfigSyncMixin.get_config_sync is where a host pushes its configuration
and for which services, by the names get_services reports; sync items
without a service name stay visible under `unmapped`. A driver mixes it
in, no role promises it. Both raise when they could not read.

What the facts mean for a pair -- hot standby, which side to write to --
is the caller's (NetOrk/netork#1125).
2026-10-16 15:18:25 +02:00
christianmanivong 2739f10ecd Merge pull request 'fix: keep CARP state knowledge out of the base package (4.10.1)' (#39) from fix/carp-helper-to-driver into main
CI / test (3.10) (push) Successful in 26s
CI / test (3.11) (push) Successful in 24s
CI / test (3.12) (push) Successful in 25s
CI / test (3.13) (push) Successful in 25s
2026-10-16 09:19:00 +00:00
christianmanivong 0330f17a67 fix: keep CARP state knowledge out of the base package (4.10.1)
CI / test (3.10) (push) Successful in 25s
CI / test (3.11) (push) Successful in 24s
CI / test (3.12) (push) Successful in 26s
CI / test (3.13) (push) Successful in 25s
CI / test (3.10) (pull_request) Successful in 26s
CI / test (3.11) (pull_request) Successful in 24s
CI / test (3.12) (pull_request) Successful in 26s
CI / test (3.13) (pull_request) Successful in 26s
carp_virtual_ip() and its state table are CARP protocol knowledge; under the
agreed structure (PR #37, docs/STRUCTURE.md) the base package carries only
contracts, data shapes and I/O-free reconcilers. They move to the driver that
speaks CARP (napalm-opnsense). The contract stays: VirtualIpsDict.carp and
VirtualIpDict.holds.

Nothing used the helper yet; it was released in 4.10.0 an hour ago.

netOrk #1121.
2026-10-16 11:11:16 +02:00
christianmanivong d3034bc8ea Merge pull request 'feat: CARP addresses as virtual IPs, with their holder in the reading (4.10.0)' (#38) from feat/carp-virtual-ips into main
CI / test (3.10) (push) Successful in 25s
CI / test (3.11) (push) Successful in 24s
CI / test (3.12) (push) Successful in 26s
CI / test (3.13) (push) Successful in 26s
2026-10-16 09:10:36 +00:00
christianmanivong 4f3b88db0e feat: CARP addresses as virtual IPs, with their holder in the reading (4.10.0)
CI / test (3.10) (push) Successful in 27s
CI / test (3.11) (push) Successful in 24s
CI / test (3.12) (push) Successful in 27s
CI / test (3.13) (push) Successful in 25s
CI / test (3.10) (pull_request) Successful in 26s
CI / test (3.11) (pull_request) Successful in 24s
CI / test (3.12) (pull_request) Successful in 26s
CI / test (3.13) (pull_request) Successful in 26s
A firewall pair's CARP addresses are a third virtual IP mechanism. Unlike
vip-manager and keepalived, a CARP reading says who holds the address: a
firewall's interface list need not show it, but CARP's state names the MASTER.

- VirtualIpsDict gains carp (NotRequired), VirtualIpDict gains holds
  (NotRequired; None leaves the holder to the interfaces).
- carp_virtual_ip() shapes one address bound to a VHID: MASTER holds, BACKUP
  stands by, INIT and DISABLED neither run nor hold, an unknown state says
  nothing. The same for OPNsense and pfSense; a driver supplies the fields.

netOrk #1121.
2026-10-16 11:06:43 +02:00
christianmanivong fde6d717a8 feat: SystemIdentityMixin reads a host's SMBIOS identity (4.9.0)
CI / test (3.10) (push) Successful in 27s
CI / test (3.11) (push) Successful in 25s
CI / test (3.12) (push) Successful in 27s
CI / test (3.13) (push) Successful in 27s
What ties a server to its BMC: the BMC reports the server's serial
number and system UUID, the host reads the same values from its DMI
tables. Both drivers that read DMI (napalm-linux, napalm-proxmox) had a
copy of the shell for it, and neither read the board serial.

SystemIdentityMixin.get_system_identity() reads product_uuid,
product_serial and board_serial in one framed command and returns them
with vendor placeholders empty: a Supermicro board's product serial is
0123456789 on every one of them, others read "To be filled by O.E.M."
or "Default string", and many cheap boards share the UUID
03000200-0400-0500-0006-000700080009. is_placeholder_identity() is the
same filter for a BMC driver's side. A driver supplies only
_run_system_identity_command(command, privileged=); the files are
root's.
2026-10-13 12:20:10 +02:00
christianmanivong a1da0337e2 feat: alternative fingerprint rules count once (4.8.0)
CI / test (3.10) (push) Successful in 28s
CI / test (3.11) (push) Successful in 25s
CI / test (3.12) (push) Successful in 27s
CI / test (3.13) (push) Successful in 26s
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.
2026-10-13 10:19:54 +02:00
christianmanivong 7afa590c0c fix: ship py.typed so type checkers read the package's types
CI / test (3.10) (push) Successful in 27s
CI / test (3.11) (push) Successful in 26s
CI / test (3.12) (push) Successful in 26s
CI / test (3.13) (push) Successful in 1m16s
The package declares "Typing :: Typed" but shipped no PEP 561 marker, so
mypy in driver repositories treated it as untyped. Add the marker, include
it as package data, and release 4.7.1.
2026-10-13 07:20:02 +02:00
christianmanivong 155b5bbbe4 feat!: settle the open review questions; release 4.7.0
## 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.
2026-10-13 06:58:40 +02:00
christianmanivong e509445572 feat: add ManagementControllerDriver for server BMCs
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.
2026-10-13 06:47:19 +02:00
christianmanivong dd8977750d chore: Python 3.13, mypy --strict in CI, eight bug fixes, docs
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.
2026-10-13 01:22:22 +02:00
christianmanivong e58b65a3db Merge pull request 'fix: never let root read the SSH configuration from stdin' (#30) from fix/ssh-write-without-sudo-stdin into main
CI / test (3.10) (push) Successful in 25s
CI / test (3.11) (push) Successful in 25s
CI / test (3.12) (push) Successful in 25s
2026-10-11 21:50:08 +00:00
christianmanivong 7ec427c743 fix: never let root read the SSH configuration from stdin
CI / test (3.10) (push) Successful in 23s
CI / test (3.11) (push) Successful in 22s
CI / test (3.12) (push) Successful in 24s
CI / test (3.10) (pull_request) Successful in 23s
CI / test (3.11) (pull_request) Successful in 25s
CI / test (3.12) (pull_request) Successful in 24s
A driver gains root with sudo -S and puts the password in front of stdin.
A sudo that does not prompt -- NOPASSWD, a cached timestamp -- leaves it
there, and the cat under sudo wrote it into the drop-in as its first line.
Seen on the first real run (NetOrk/netork#1027): sshd -t rejected the file
and the rollback removed it, but the password was in a 0644 file for a
moment and in the error the caller showed.

The content now goes without privilege to a mktemp file only the login user
can read; root installs that file (mode 644, root-owned) and removes it.
The fake host in the tests prepends the password to every privileged stdin,
as a non-prompting sudo does.

Bumps to 4.5.1. NetOrk/netork#1027, NetOrk/netork#1029
2026-10-11 23:48:51 +02:00
christianmanivong d43e15e8db Merge pull request 'feat: OpenSshServerMixin takes flagged algorithms out of what sshd offers' (#29) from feat/ssh-algorithm-fix into main
CI / test (3.10) (push) Successful in 23s
CI / test (3.11) (push) Successful in 22s
CI / test (3.12) (push) Successful in 23s
2026-10-11 21:14:06 +00:00
christianmanivong 23c352aa8a feat: OpenSshServerMixin takes flagged algorithms out of what sshd offers
CI / test (3.10) (push) Successful in 23s
CI / test (3.11) (push) Successful in 22s
CI / test (3.12) (push) Successful in 23s
CI / test (3.10) (pull_request) Successful in 23s
CI / test (3.11) (pull_request) Successful in 22s
CI / test (3.12) (pull_request) Successful in 23s
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
2026-10-11 23:12:28 +02:00
christianmanivong a710a85de6 Merge pull request 'feat: report how full /, /boot and /var are in the host status' (#28) from fix/volume-almost-full-862 into main
CI / test (3.10) (push) Successful in 26s
CI / test (3.11) (push) Successful in 29s
CI / test (3.12) (push) Successful in 31s
2026-10-11 08:33:03 +00:00
christianmanivong c947250179 feat: report how full /, /boot and /var are in the host status
CI / test (3.10) (push) Successful in 26s
CI / test (3.11) (push) Successful in 23s
CI / test (3.12) (push) Successful in 23s
CI / test (3.10) (pull_request) Successful in 23s
CI / test (3.11) (pull_request) Successful in 26s
CI / test (3.12) (pull_request) Successful in 25s
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.
2026-10-11 09:06:00 +02:00
christianmanivong ff6dbe0206 Merge pull request 'fix: tell unattended-upgrade from Ubuntu's idle shutdown helper' (#27) from fix/unattended-upgrade-shutdown into main
CI / test (3.10) (push) Successful in 24s
CI / test (3.11) (push) Successful in 26s
CI / test (3.12) (push) Successful in 26s
2026-10-08 15:33:26 +00:00
christianmanivong 7aa731ad34 fix: tell unattended-upgrade from Ubuntu's idle shutdown helper
CI / test (3.10) (push) Successful in 24s
CI / test (3.11) (push) Successful in 23s
CI / test (3.12) (push) Successful in 24s
CI / test (3.10) (pull_request) Successful in 24s
CI / test (3.11) (pull_request) Successful in 22s
CI / test (3.12) (pull_request) Successful in 25s
On every Ubuntu host with unattended-upgrades, get_host_status() read the
package manager as busy, so an interrupted dpkg was never reported there.
Ubuntu keeps unattended-upgrade-shutdown --wait-for-signal running all the
time; its process name is cut to 15 characters, unattended-upgr, the same
as a real run's.

BUSY_CHECK now matches dpkg, apt, apt-get and aptitude by name and
unattended-upgrade by its command line. The tests run it through sh with a
stand-in ps, and check that the command does not match itself: over an exec
channel it is the command line of the shell that runs it.

Closes #26
2026-10-08 17:30:44 +02:00
christianmanivong 72679ff4f8 Merge pull request 'feat: report a dpkg left halfway and when the host's own automatic updates last succeeded' (#25) from feat/dpkg-interrupted into main
CI / test (3.10) (push) Successful in 24s
CI / test (3.11) (push) Successful in 23s
CI / test (3.12) (push) Successful in 24s
2026-10-08 14:12:35 +00:00
christianmanivong 7b44f689bc feat: report a dpkg left halfway and when the host's own automatic updates last succeeded
CI / test (3.10) (push) Successful in 1m59s
CI / test (3.11) (push) Successful in 30s
CI / test (3.12) (push) Successful in 34s
CI / test (3.10) (pull_request) Successful in 39s
CI / test (3.11) (pull_request) Successful in 23s
CI / test (3.12) (pull_request) Successful in 27s
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.
2026-10-08 16:07:55 +02:00
christianmanivong ba2f18ea7d Merge pull request 'feat: read the virtual IPs vip-manager and keepalived declare on a host' (#24) from feature/virtual-ips into main
CI / test (3.10) (push) Successful in 25s
CI / test (3.11) (push) Successful in 26s
CI / test (3.12) (push) Successful in 26s
2026-10-08 10:41:02 +00:00
christianmanivong 37c09d0fa0 feat: read the virtual IPs vip-manager and keepalived declare on a host
CI / test (3.10) (push) Successful in 26s
CI / test (3.11) (push) Successful in 24s
CI / test (3.12) (push) Successful in 26s
CI / test (3.10) (pull_request) Successful in 35s
CI / test (3.11) (pull_request) Successful in 36s
CI / test (3.12) (pull_request) Successful in 39s
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.
2026-10-08 12:37:28 +02:00
christianmanivong 8111c4cde3 Merge pull request 'feat: report a FreeBSD host as needing a reboot when its installed kernel is not the running one' (#23) from feat/freebsd-host-status into main
CI / test (3.10) (push) Successful in 23s
CI / test (3.11) (push) Successful in 22s
CI / test (3.12) (push) Successful in 24s
2026-10-08 10:11:15 +00:00
christianmanivong e1ce72cc5c feat: report a FreeBSD host as needing a reboot when its installed kernel is not the running one
CI / test (3.10) (push) Successful in 25s
CI / test (3.11) (push) Successful in 22s
CI / test (3.12) (push) Successful in 23s
CI / test (3.10) (pull_request) Successful in 24s
CI / test (3.11) (pull_request) Successful in 22s
CI / test (3.12) (pull_request) Successful in 24s
HOST_STATUS_COMMAND also prints freebsd-version -k and -r where
freebsd-version exists. When the two differ, a kernel update was installed
and is not running yet, so the host needs a reboot. When they match, it does
not.
2026-10-08 12:08:21 +02:00
christianmanivong 7ba84acad3 Merge pull request 'refactor!: drop the Docker model' (#21) from chore/drop-legacy-docker into main
CI / test (3.10) (push) Successful in 23s
CI / test (3.11) (push) Successful in 22s
CI / test (3.12) (push) Successful in 24s
2026-10-08 06:52:25 +00:00
christianmanivong 8eb7dcd871 refactor!: drop the Docker model
CI / test (3.10) (push) Successful in 25s
CI / test (3.11) (push) Successful in 22s
CI / test (3.12) (push) Successful in 24s
CI / test (3.10) (pull_request) Successful in 24s
CI / test (3.11) (pull_request) Successful in 22s
CI / test (3.12) (pull_request) Successful in 23s
DockerInfoDict, DockerContainerDict, DockerImageDict, DockerVolumeDict,
DockerNetworkDict and the OSDriver declaration of get_docker_info() go.
They described napalm-linux' old CLI reading, which napalm-linux 0.3.0
dropped. There is no container model here: netOrk reads and handles
containers over the Engine API (NetOrk/netork#765, phase 9); this
package only gives access to the engine.

Version 4.0.0.
2026-10-08 08:48:37 +02:00
christianmanivong 95d9031c8d Merge pull request 'feat!: declare the guest agent per guest OS, and share what provisioning needs' (#20) from feat/guest-os-provisioning into main
CI / test (3.10) (push) Successful in 23s
CI / test (3.11) (push) Successful in 21s
CI / test (3.12) (push) Successful in 23s
2026-10-08 05:57:09 +00:00
christianmanivong 885c7e1f53 feat!: declare the guest agent per guest OS, and share what provisioning needs
CI / test (3.10) (push) Successful in 39s
CI / test (3.11) (push) Successful in 36s
CI / test (3.12) (push) Successful in 39s
CI / test (3.10) (pull_request) Successful in 32s
CI / test (3.11) (pull_request) Successful in 23s
CI / test (3.12) (pull_request) Successful in 24s
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.
2026-10-08 07:53:21 +02:00
christianmanivong eb80d5cb0d Merge pull request 'feat(container-engine): run the engine CLI privileged on request' (#19) from feat/privileged-cli into main
CI / test (3.10) (push) Successful in 23s
CI / test (3.11) (push) Successful in 22s
CI / test (3.12) (push) Successful in 23s
2026-10-07 20:11:13 +00:00
christianmanivong e50e497939 feat(container-engine): run the engine CLI privileged on request
CI / test (3.10) (push) Successful in 23s
CI / test (3.11) (push) Successful in 23s
CI / test (3.12) (push) Successful in 24s
CI / test (3.10) (pull_request) Successful in 23s
CI / test (3.11) (pull_request) Successful in 22s
CI / test (3.12) (pull_request) Successful in 23s
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).
2026-10-07 22:08:37 +02:00
christianmanivong 3aed0b48d7 Merge pull request 'feat: a public command channel and container engine access' (#18) from feat/container-engine-channel into main
CI / test (3.10) (push) Successful in 22s
CI / test (3.11) (push) Successful in 21s
CI / test (3.12) (push) Successful in 23s
2026-10-07 16:01:51 +00:00
christianmanivong 735b683028 feat: a public command channel and container engine access
CI / test (3.10) (push) Successful in 25s
CI / test (3.11) (push) Successful in 22s
CI / test (3.12) (push) Successful in 23s
CI / test (3.10) (pull_request) Successful in 24s
CI / test (3.11) (pull_request) Successful in 22s
CI / test (3.12) (pull_request) Successful in 22s
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
2026-10-07 17:58:57 +02:00
christianmanivong 08ec32e93a Merge pull request 'ci: run the tests and build the package on every push and pull request' (#16) from ci/workflow into main
CI / test (3.10) (push) Successful in 23s
CI / test (3.11) (push) Successful in 22s
CI / test (3.12) (push) Successful in 24s
2026-10-07 06:22:15 +00:00
christianmanivong bd43bd75fa ci: run the tests and build the package on every push and pull request
CI / test (3.10) (push) Successful in 29s
CI / test (3.11) (push) Successful in 28s
CI / test (3.12) (push) Successful in 27s
CI / test (3.10) (pull_request) Successful in 24s
CI / test (3.11) (pull_request) Successful in 25s
CI / test (3.12) (pull_request) Successful in 26s
The same job as napalm-fritzbox and napalm-opnsense: Python 3.10, 3.11 and
3.12, pytest, then wheel and sdist. napalm-device-types comes from
git.netork.io first, because PyPI has an unrelated package of that name.

This is the package the other drivers install from git first; here it
installs itself.
2026-10-07 07:52:44 +02:00
christianmanivong 1112191aec Merge pull request 'feat: read the sockets of a host without ss from netstat, and procd's services' (#15) from feat/listening-netstat into main 2026-10-07 05:21:31 +00:00
christianmanivong b8b89acee1 feat: read the sockets of a host without ss from netstat, and procd's services
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.
2026-10-07 07:20:27 +02:00
christianmanivong f833e23422 Merge pull request 'feat: read what listens on which address, and which service it is, once for every driver' (#7) from feat/listening-sockets into main 2026-10-06 16:19:22 +00:00
christianmanivong b97ec654a0 feat: read what listens on which address, and which service it is, once for every driver
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.
2026-10-06 18:18:56 +02:00
christianmanivong c2d8d4a0d2 Merge pull request 'feat: say where a pending update comes from, whether it is a security fix, and whether the host needs a reboot' (#6) from feat/update-origin-host-status into main 2026-10-05 22:19:25 +00:00
christianmanivong 18676a573f feat: say where a pending update comes from, whether it is a security fix, and whether the host needs a reboot
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.
2026-10-06 00:19:24 +02:00
christianmanivong 4071f35050 Merge pull request 'feat: list systemd services in one round trip and control them, once for every driver' (#5) from feat/systemd-services-mixin into main 2026-10-05 11:11:45 +00:00
christianmanivong 17d8dabb4d feat: list systemd services in one round trip and control them, once for every driver
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.
2026-10-05 13:11:44 +02:00
christianmanivong d55b036a8e Merge pull request 'feat: read what a Linux kernel has built and loaded, once for every driver' (#4) from feat/kernel-facts into main 2026-10-05 04:36:42 +00:00
christianmanivong 536ffcf6e1 feat: read what a Linux kernel has built and loaded, once for every driver
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.
2026-10-05 06:17:30 +02:00
christianmanivong 97e7ede131 Merge pull request 'feat: a new VM's CPU model can be chosen, from a list the hypervisor offers' (#3) from feat/vm-cpu-type into main 2026-10-04 15:42:15 +00:00
christianmanivong b5c40019af feat: a new VM's CPU model can be chosen, from a list the hypervisor offers
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.
2026-10-04 12:02:38 +02:00
christianmanivong 36b7852bce Merge pull request 'feat: port forwards are a firewall reader too, and only the WAN's' (#2) from feature/port-forwards-shared into main 2026-10-03 14:31:01 +00:00
christianmanivong 31949eca0a feat: port forwards are a firewall reader too, and only the WAN's
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.
2026-10-03 16:30:32 +02:00
christianmanivong 7b491164a2 Merge pull request 'feat!: a VM's vmid is a string, and its config can describe its hardware' (#1) from feature/vmid-as-string into main 2026-10-01 18:59:36 +00:00