Compare commits

...
Author SHA1 Message Date
christianmanivong 22e4377c2b Merge pull request 'chore: adopt the NAPALM driver standard (pilot)' (#52) from chore/driver-standard into main
driver / lint (push) Successful in 11s
driver / test (3.11) (push) Successful in 48s
driver / test (3.13) (push) Successful in 56s
driver / lowest (push) Successful in 36s
driver / security (push) Successful in 55s
driver / build (push) Successful in 26s
driver / publish (push) Skipped
driver / standard (push) Successful in 13s
driver / types (push) Successful in 43s
driver / test (3.10) (push) Successful in 51s
driver / test (3.12) (push) Successful in 49s
CI / driver (push) Successful in 6m28s
2026-10-22 22:08:14 +00:00
christianmanivong 49830671c9 chore(release): 4.21.1
driver / test (3.10) (pull_request) Successful in 3m3s
driver / test (3.11) (pull_request) Successful in 49s
driver / test (3.13) (pull_request) Successful in 4m52s
driver / lowest (pull_request) Successful in 26s
driver / security (pull_request) Successful in 3m10s
driver / build (pull_request) Successful in 4m38s
CI / driver (pull_request) Successful in 21m52s
driver / standard (pull_request) Successful in 36s
driver / lint (pull_request) Successful in 1m12s
driver / types (pull_request) Successful in 1m12s
driver / test (3.12) (pull_request) Successful in 1m53s
driver / publish (pull_request) Skipped
2026-10-22 21:12:28 +02:00
christianmanivong 0475184684 chore: adopt the NAPALM driver standard
driver / test (3.10) (pull_request) Successful in 1m43s
driver / test (3.11) (pull_request) Failing after 9m18s
driver / test (3.13) (pull_request) Successful in 1m4s
driver / lowest (pull_request) Failing after 1m38s
driver / security (pull_request) Failing after 4m33s
driver / build (pull_request) Failing after 1m29s
CI / driver (pull_request) Failing after 24m6s
driver / standard (pull_request) Successful in 54s
driver / lint (pull_request) Successful in 50s
driver / types (pull_request) Failing after 1m17s
driver / test (3.12) (pull_request) Failing after 1m20s
driver / publish (pull_request) Skipped
- CI moves to .gitea/workflows/ci.yml and calls the shared driver workflow
  of NAPALM/driver-standard: conformance, ruff, strict mypy, tests on
  3.10-3.13 and at the lowest declared versions, bandit, pip-audit, build,
  and on a release tag the upload to PyPI.
- tests/quality_baseline.json records today's long and complex functions
  and the one file over 1000 lines; they may shrink, not grow.
- Coverage floor 95 % (measured 95.15 %).
- CHANGELOG.md starts here; project links point at git.netork.io; the
  license is the SPDX string PEP 639 wants.
2026-10-22 19:06:19 +02:00
christianmanivong b7350a9b2c fix: what bandit and the remaining lint rules found
- Long docstring and command lines are wrapped; recorded device output in
  tests/ keeps its lines (E501 is off there).
- host_firewall: the negation token is the named constant _NEGATION. bandit
  read token == "!" as a hardcoded password.
- ssh_host_keys: _known_hosts_file takes the expected key as an argument
  instead of asserting it; both callers had already returned when it is None.
- ssh_server: the staged path is on the device, not a local temp file
  (nosec B108).
- tests: raise ... from exc, zip(strict=True).
2026-10-22 19:05:11 +02:00
christianmanivong 08296d0d2e style: ruff format and the lint rules of the driver standard
Mechanical: ruff format, and ruff check --fix with the rule set every NAPALM
driver uses (E, F, I, UP, B at line length 100). typing.List/Dict/Optional
become builtin generics and X | None, imports are sorted, the coding cookies
are gone. Two tests that read the source text follow the new spelling.

What ruff cannot fix comes in the next commit.
2026-10-22 19:03:21 +02:00
christianmanivong ac8e7c4c11 Merge pull request 'feat: HostFirewallMixin, a Linux host's own netfilter rules (NetOrk/netork#1344)' (#51) from feat/host-firewall-1344 into main
CI / test (3.10) (push) Successful in 26s
CI / test (3.11) (push) Successful in 28s
CI / test (3.12) (push) Successful in 38s
CI / test (3.13) (push) Successful in 25s
2026-10-21 21:26:36 +00:00
christianmanivong f77091c489 feat: HostFirewallMixin, a Linux host's own netfilter rules (NetOrk/netork#1344)
CI / test (3.10) (push) Successful in 40s
CI / test (3.11) (push) Successful in 25s
CI / test (3.13) (push) Successful in 51s
CI / test (3.10) (pull_request) Successful in 24s
CI / test (3.12) (pull_request) Successful in 27s
CI / test (3.13) (pull_request) Successful in 25s
CI / test (3.12) (push) Successful in 25s
CI / test (3.11) (pull_request) Successful in 26s
get_host_firewall() reads `nft -j list ruleset` as root and returns every
chain with its family, table, hook, priority and policy, and every rule as
matches and a verdict. Rules iptables-nft wrote show their extensions in nft's
JSON only as an opaque `xt`, so the tables iptables manages are taken from
iptables-save instead; a legacy iptables is kept beside nft's tables, since
both apply. A condition the parse does not model is an `unknown` match with
its text, never left out. Without root neither tool can be read, and
HostFirewallUnavailable is raised rather than a host without rules.

A driver supplies only _run_host_firewall_command(command, privileged=).
Version 4.21.0.
2026-10-21 23:10:44 +02:00
christianmanivong be3958e561 Merge pull request 'feat: ServicePackagesMixin, which packages a systemd service is (NetOrk/netork#1236)' (#50) from feat/service-packages-1236 into main
CI / test (3.10) (push) Successful in 31s
CI / test (3.11) (push) Successful in 28s
CI / test (3.12) (push) Successful in 36s
CI / test (3.13) (push) Successful in 36s
2026-10-19 16:56:15 +00:00
christianmanivong a21e365c4e fix: annotate the unit path set so mypy accepts service_packages (NetOrk/netork#1236)
CI / test (3.10) (push) Successful in 44s
CI / test (3.11) (push) Successful in 33s
CI / test (3.12) (push) Successful in 32s
CI / test (3.13) (push) Successful in 30s
CI / test (3.10) (pull_request) Successful in 42s
CI / test (3.12) (pull_request) Successful in 38s
CI / test (3.13) (pull_request) Successful in 35s
CI / test (3.11) (pull_request) Successful in 43s
2026-10-19 18:50:41 +02:00
christianmanivong cce26eff44 feat: ServicePackagesMixin, which packages a systemd service is (NetOrk/netork#1236)
CI / test (3.10) (push) Failing after 17s
CI / test (3.11) (push) Failing after 14s
CI / test (3.12) (push) Failing after 15s
CI / test (3.13) (push) Failing after 16s
CI / test (3.10) (pull_request) Failing after 17s
CI / test (3.12) (pull_request) Failing after 20s
CI / test (3.13) (pull_request) Failing after 26s
CI / test (3.11) (pull_request) Failing after 15s
get_service_packages() returns, per systemd service, the installed packages
it is made of: the one shipping its unit file and the one shipping the
binary its ExecStart runs -- not the libraries that binary links against.
One read-only command: systemctl show for every loaded service unit, then
one dpkg -S for all paths (each also across the /usr merge), or rpm -qf per
path. The report is framed; one cut short raises. A driver supplies
_run_service_packages_command, as for KernelFactsMixin.

Version 4.20.0.
2026-10-19 17:38:57 +02:00
christianmanivong 888a11ca6a Merge pull request 'feat: ssh_host_keys.HostKeyCheck, trust on first use (NetOrk/netork#1167)' (#49) from fix/ssh-host-key-tofu-1167 into main
CI / test (3.10) (push) Successful in 35s
CI / test (3.11) (push) Successful in 41s
CI / test (3.12) (push) Successful in 32s
CI / test (3.13) (push) Successful in 29s
2026-10-18 19:23:26 +00:00
christianmanivong 94bf399d34 feat: ssh_host_keys.HostKeyCheck, trust on first use (NetOrk/netork#1167)
CI / test (3.10) (push) Successful in 35s
CI / test (3.11) (push) Successful in 41s
CI / test (3.12) (push) Successful in 38s
CI / test (3.13) (push) Successful in 33s
CI / test (3.10) (pull_request) Successful in 29s
CI / test (3.12) (pull_request) Successful in 33s
CI / test (3.13) (pull_request) Successful in 40s
CI / test (3.11) (pull_request) Successful in 28s
Drivers get the host keys to expect in optional_args["ssh_host_keys"] (by port)
and report what they saw through optional_args["ssh_host_key_observer"].
With a known key, any other key is refused before authentication
(SshHostKeyMismatch); without one, the first key is accepted. One
implementation for paramiko (HostKeyCheck.connect) and netmiko
(HostKeyCheck.netmiko + observe). A caller or driver that ignores both keys
keeps working as before.

Version 4.19.0.
2026-10-18 21:23:25 +02:00
christianmanivong bb2229fd9e Merge pull request 'chore: release the service clusters as 4.18.0' (#48) from chore/4.18.0 into main
CI / test (3.10) (push) Successful in 26s
CI / test (3.11) (push) Successful in 25s
CI / test (3.12) (push) Successful in 26s
CI / test (3.13) (push) Successful in 27s
2026-10-18 13:42:42 +00:00
christianmanivong 680500dd2e chore: release the service clusters as 4.18.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
CI / test (3.10) (pull_request) Successful in 26s
CI / test (3.12) (pull_request) Successful in 27s
CI / test (3.13) (pull_request) Successful in 26s
CI / test (3.11) (pull_request) Successful in 25s
feat/service-clusters (#47) and feat/opnsense-public-contracts (#46) both
called themselves 4.17.0 and were merged one after the other: two trees
under one version. 4.17.0 stays the public contracts (83cd7db, pinned by
netOrk for #1113); the service clusters on top of it are 4.18.0.
2026-10-18 15:38:29 +02:00
christianmanivong a0e70d1686 Merge pull request 'feat: the configuration that says which services run together (4.17.0)' (#47) from feat/service-clusters into main
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 26s
2026-10-18 13:32:33 +00:00
christianmanivong 2bd7bcc77c feat: the configuration that says which services run together (4.17.0)
CI / test (3.10) (push) Successful in 29s
CI / test (3.11) (push) Successful in 24s
CI / test (3.12) (push) Successful in 27s
CI / test (3.13) (push) Successful in 27s
CI / test (3.10) (pull_request) Successful in 26s
CI / test (3.12) (pull_request) Successful in 27s
CI / test (3.13) (pull_request) Successful in 26s
CI / test (3.11) (pull_request) Successful in 25s
ServiceClustersMixin.get_service_clusters() -> ServiceClustersDict
{patroni, adguardhome_sync, bunkerweb}: per mechanism the clusters the
host's configuration says it belongs to, each with its members (address,
port, name, role as the mechanism words it, this_host, config_from) and the
unit or container it was read from (source).

- Patroni: scope, name and restapi.connect_address from its YAML, then the
  members and roles /cluster lists from the DCS.
- adguardhome-sync: ORIGIN_URL and REPLICA*_URL; the origin pushes to each
  replica.
- BunkerWeb: a worker's API_WHITELIST_IP names the manager it admits, a
  manager's BUNKERWEB_INSTANCES its workers.

Two hosts answering at one virtual IP prove nothing; this is the evidence
that they run one service (NetOrk/netork#1125). One generic command for
every Linux host, filtered on the host like the virtual IPs' one:
allowlisted fields only, URLs without user:password@, DCS endpoints stay
there. Root first, then without it; None per mechanism is "did not look".
2026-10-18 15:28:34 +02:00
christianmanivong 83cd7db761 Merge pull request 'feat: public contracts for update progress, DNS forwards and OSPF (4.17.0)' (#46) from feat/opnsense-public-contracts 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 28s
CI / test (3.13) (push) Successful in 28s
2026-10-18 13:06:07 +00:00
Christian Manivong 1dd448e1d3 feat: public contracts for update progress, DNS forwards and OSPF (4.17.0)
CI / test (3.10) (push) Successful in 35s
CI / test (3.11) (push) Successful in 32s
CI / test (3.12) (push) Successful in 30s
CI / test (3.13) (push) Successful in 27s
CI / test (3.10) (pull_request) Successful in 27s
CI / test (3.12) (pull_request) Successful in 26s
CI / test (3.13) (pull_request) Successful in 26s
CI / test (3.11) (pull_request) Successful in 25s
An application followed a firewall's upgrade, edited its resolver's
forwards and read its OSPF state through the driver's private REST
helpers (NetOrk/netork#1113). Declared contracts, neutral shapes:

- UpdateMixin.get_update_progress() -> UpdateProgressDict {state, log}
  for an update apply_updates only starts.
- DnsForwardMixin: get_dns_forwards(), set_dns_forward(domain, server,
  port), remove_dns_forward(domain); the driver applies.
- OspfMixin.get_ospf() -> OspfDict {router_id, areas, interfaces}; the
  routing daemon's format is the driver's to translate.

All declared only.
2026-10-18 15:02:15 +02:00
christianmanivong 753ddc5eda Merge pull request 'feat: the firmware a vendor offers, as a fact (4.16.0)' (#45) from feat/firmware-offer into main
CI / test (3.10) (push) Successful in 26s
CI / test (3.11) (push) Successful in 25s
CI / test (3.12) (push) Successful in 26s
CI / test (3.13) (push) Successful in 26s
2026-10-17 11:04:22 +00:00
christianmanivong 59fb1273e8 feat: the firmware a vendor offers, as a fact (4.16.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 26s
CI / test (3.10) (pull_request) Successful in 25s
CI / test (3.12) (pull_request) Successful in 27s
CI / test (3.13) (pull_request) Successful in 25s
CI / test (3.11) (pull_request) Successful in 24s
FirmwareOfferMixin.get_firmware_offer() -> FirmwareOfferDict
{current_version, latest_version, newer, download_url?}: what runs and
the newest firmware the vendor offers, from its download catalogue or
the device's own update check. Comparing vendor version strings stays
with the driver, so it says whether the offer is newer; whether that is a
warning is the application's call (NetOrk/netork#1111). Declared only.
2026-10-17 13:00:43 +02:00
christianmanivong 4641063db6 Merge pull request 'feat: a missing license can say what the device withholds without it (4.15.0)' (#44) from feat/license-withholds into main
CI / test (3.10) (push) Successful in 29s
CI / test (3.11) (push) Successful in 27s
CI / test (3.12) (push) Successful in 32s
CI / test (3.13) (push) Successful in 37s
2026-10-17 09:41:28 +00:00
christianmanivong 634c142477 test: read the withholds hint the way Python 3.10 reports it
CI / test (3.10) (push) Successful in 35s
CI / test (3.11) (push) Successful in 34s
CI / test (3.12) (push) Successful in 36s
CI / test (3.13) (push) Successful in 34s
CI / test (3.10) (pull_request) Successful in 33s
CI / test (3.12) (pull_request) Successful in 31s
CI / test (3.13) (pull_request) Successful in 32s
CI / test (3.11) (pull_request) Successful in 30s
2026-10-17 11:36:32 +02:00
christianmanivong 2d1c40c87d feat: a missing license can say what the device withholds without it (4.15.0)
CI / test (3.10) (push) Failing after 19s
CI / test (3.11) (push) Successful in 25s
CI / test (3.12) (push) Successful in 29s
CI / test (3.13) (push) Successful in 28s
CI / test (3.10) (pull_request) Failing after 20s
CI / test (3.12) (pull_request) Successful in 30s
CI / test (3.13) (pull_request) Successful in 32s
CI / test (3.11) (pull_request) Successful in 26s
LicenseDict gets withholds: NotRequired[List[str]]: what a device keeps back
for want of a license while the rest of it answers. A Supermicro X10 with
only SFT-OOB-LIC answers Redfish but withholds its drives and firmware
inventory until DCMS is activated. That makes it partly licensed, not
unlicensed, and the caller can tell the two apart (NetOrk/netork#1111).
2026-10-17 11:32:10 +02:00
christianmanivong 87c9b06bf3 Merge pull request 'feat: licenses, a hypervisor's hosts and the package proxy as facts (4.14.0)' (#43) from feat/fact-getters-1111 into main
CI / test (3.10) (push) Successful in 32s
CI / test (3.11) (push) Successful in 26s
CI / test (3.12) (push) Successful in 36s
CI / test (3.13) (push) Successful in 35s
2026-10-17 07:19:33 +00:00
christianmanivong 2805f911c4 feat: licenses, a hypervisor's hosts and the package proxy as facts (4.14.0)
CI / test (3.10) (push) Successful in 32s
CI / test (3.11) (push) Successful in 32s
CI / test (3.12) (push) Successful in 35s
CI / test (3.13) (push) Successful in 35s
CI / test (3.10) (pull_request) Successful in 31s
CI / test (3.12) (pull_request) Successful in 30s
CI / test (3.13) (pull_request) Successful in 29s
CI / test (3.11) (pull_request) Successful in 27s
Drivers report facts and the application decides what is a warning
(docs/STRUCTURE.md, NetOrk/netork#1111). Three facts drivers so far
only reported as warning codes get contracts:

- LicenseMixin.get_licenses(): licenses and subscriptions with their
  status (a Proxmox subscription, a VMware edition, a BMC license a
  feature needs).
- HypervisorDriver.get_hypervisor_hosts(): each host connected, in
  maintenance, with configuration issues, under a central manager.
- PackageProxyMixin.get_package_proxy(): the proxy apt fetches through.

All declared only.
2026-10-17 09:12:20 +02:00
christianmanivong 6551008ad3 Merge pull request 'feat: fix routines as templates with the caller's values; deprecate run_device_action (4.13.0)' (#41) from feat/fix-templates-33 into main
CI / test (3.10) (push) Successful in 29s
CI / test (3.11) (push) Successful in 32s
CI / test (3.12) (push) Successful in 36s
CI / test (3.13) (push) Successful in 34s
2026-10-16 22:43:22 +00:00
christianmanivong 321537ff0a Merge pull request 'feat: SnmpHealthProfile, the OIDs a driver declares; deprecate HealthMetricsMixin (4.12.0)' (#40) from feat/snmp-health-profile-32 into main
CI / test (3.10) (push) Successful in 35s
CI / test (3.11) (push) Successful in 33s
CI / test (3.12) (push) Successful in 34s
CI / test (3.13) (push) Successful in 33s
2026-10-16 22:43:22 +00:00
christianmanivong 9324a5b8f1 feat: fix routines as templates with the caller's values; deprecate run_device_action (4.13.0)
CI / test (3.10) (push) Successful in 34s
CI / test (3.11) (push) Successful in 31s
CI / test (3.12) (push) Successful in 31s
CI / test (3.13) (push) Successful in 27s
CI / test (3.10) (pull_request) Successful in 27s
CI / test (3.12) (pull_request) Successful in 28s
CI / test (3.13) (pull_request) Successful in 27s
CI / test (3.11) (pull_request) Successful in 28s
run_device_action was a string dispatch drivers filled with an
application's remediations, values hard-coded inside (community
"public", the caller's address, a /24 firewall rule).

napalm_device_types.fixes declares them as templates instead. A
template runs declared steps in order and stops at the first failure
(run_fix_steps); the driver implements the steps, or overrides the
whole method where its device takes the change in one call; every
value comes from the caller.

- SnmpAgentMixin.configure_snmp_agent(settings): install, configure,
  firewall, start. The caller verifies with its own SNMP client.
  get_management_peer() (optional) says the address the device sees the
  management connection come from, for choosing allowed_sources.
- EnsurePackageMixin.ensure_package(name): install only what is missing.
- PackageProxyMixin.configure_package_proxy(url): declared.
- ContainerEngineMixin.grant_container_engine_access(user): declared.

None is mixed into a role base, so a capability check stays truthful.

Closes #33
2026-10-16 15:29:42 +02:00
christianmanivong b4c4ec0b0b feat: SnmpHealthProfile, the OIDs a driver declares; deprecate HealthMetricsMixin (4.12.0)
CI / test (3.10) (push) Successful in 28s
CI / test (3.11) (push) Successful in 27s
CI / test (3.12) (push) Successful in 29s
CI / test (3.13) (push) Successful in 30s
CI / test (3.10) (pull_request) Successful in 29s
CI / test (3.12) (pull_request) Successful in 35s
CI / test (3.13) (pull_request) Successful in 33s
CI / test (3.11) (pull_request) Successful in 26s
Drivers no longer collect over SNMP (docs/STRUCTURE.md). A driver whose
device publishes its health values under OIDs of its own declares them
as SNMP_HEALTH_PROFILE: per value either scalars asked in order or a
table folded by first, average or max, optionally with a pattern for
text answers; memory as total with used or free, or a percentage, in
KiB or bytes; and how to read the interfaces. The application polls
and computes.

HealthMetricsMixin.get_health_metrics now warns that it is deprecated;
it goes in 5.0.

Closes #32
2026-10-16 15:29:27 +02:00
christianmanivong 07b50f6a48 Merge pull request 'feat: an HA pair's DHCP peers and config sync as facts (4.11.0)' (#42) from feat/dhcp-ha-config-sync into main
CI / test (3.10) (push) Successful in 26s
CI / test (3.11) (push) Successful in 23s
CI / test (3.12) (push) Successful in 26s
CI / test (3.13) (push) Successful in 26s
2026-10-16 13:22:28 +00:00
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.12) (pull_request) Successful in 27s
CI / test (3.13) (pull_request) Successful in 25s
CI / test (3.11) (pull_request) Successful in 24s
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.12) (pull_request) Successful in 26s
CI / test (3.13) (pull_request) Successful in 26s
CI / test (3.11) (pull_request) Successful in 24s
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.13) (push) Successful in 26s
CI / test (3.12) (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.12) (pull_request) Successful in 26s
CI / test (3.13) (pull_request) Successful in 26s
CI / test (3.11) (pull_request) Successful in 24s
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.13) (push) Successful in 27s
CI / test (3.12) (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
90 changed files with 7191 additions and 897 deletions

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
name: CI
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
jobs:
driver:
uses: NAPALM/driver-standard/.gitea/workflows/driver.yml@main
with:
standard-ref: main
secrets:
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
-45
View File
@@ -1,45 +0,0 @@
name: CI
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
- name: Install package with dev extras
run: |
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
- name: Run unit tests
run: |
python -m pytest -q --tb=short
- name: Build wheel and sdist
run: |
python -m pip install build
python -m build
- name: Upload dist artifacts
# v4 refuses to run on Gitea ("not currently supported on GHES").
uses: actions/upload-artifact@v3
with:
name: dist-${{ matrix.python-version }}
path: dist/*
+21
View File
@@ -0,0 +1,21 @@
# Changelog
All notable changes to napalm-device-types. The format follows
[Keep a Changelog](https://keepachangelog.com/), versions follow
[Semantic Versioning](https://semver.org/). Versions up to 4.21.0 were not
recorded here; their history is the git log.
## [Unreleased]
## [4.21.1] - 2026-10-22
The first release on PyPI since 0.1.0.
### Changed
- The package follows the NAPALM driver standard
(https://git.netork.io/NAPALM/driver-standard): CI on Gitea runs ruff, strict
mypy, the tests on Python 3.10–3.13 and at the lowest declared versions,
bandit, pip-audit and a build check, and a release tag publishes to PyPI.
- Type annotations use builtin generics (`list[str]`, `X | None`) instead of
`typing.List` and `Optional`. They mean the same to a type checker.
- The project links point at git.netork.io instead of GitHub.
+204 -38
View File
@@ -6,14 +6,14 @@ Instead of inheriting directly from `napalm.base.NetworkDriver`, a driver can in
```python
# without napalm-device-types
class OpenWrtDriver(NetworkDriver):
...
class OpenWrtDriver(NetworkDriver): ...
# with napalm-device-types
from napalm_device_types import AccessPointDriver
class OpenWrtDriver(AccessPointDriver):
...
class OpenWrtDriver(AccessPointDriver): ...
```
## Why?
@@ -29,13 +29,24 @@ OpenMediaVault box is a NAS built on Debian. So a driver inherits **one role bas
role its device fills**, and **the order it lists them in is the ranking**:
```python
class QnapQtsDriver(StorageDriver, HypervisorDriver, LinuxDriver):
... # primary_role_of(...) == "storage"
class QnapQtsDriver(
StorageDriver, HypervisorDriver, LinuxDriver
): ... # primary_role_of(...) == "storage"
```
`roles_of(cls)`, `role_keys_of(cls)` and `primary_role_of(cls)` read that back. Nothing
restates the ranking: there is no precedence table and no attribute to override.
A server's baseboard management controller (Supermicro BMC, Dell iDRAC, HPE iLO,
Lenovo XCC) is a role of its own, `ManagementControllerDriver` (`management_controller`),
reached over Redfish or IPMI (`USES_SSH = False`). It is not the server it manages:
`reboot_host` restarts the BMC (Redfish `Manager.Reset`), while `set_power_state`
(`On`, `GracefulShutdown`, `ForceOff`, `GracefulRestart`, `ForceRestart`, `PowerCycle`)
acts on the server. `get_hardware_inventory` carries the server's serial number, system
UUID and NIC MACs -- what ties the BMC to the host it manages. Its sensors come through
NAPALM's `get_environment`, with `cpu` and `memory` empty, since a BMC does not know the
host OS's load; it has no `HealthMetricsMixin` (SNMP) and no command channel.
### Role bases declare; they never implement
**A role base must not contain a single runtime method — not even a
@@ -44,10 +55,12 @@ restates the ranking: there is no precedence table and no attribute to override.
```python
class StorageDriver(DeviceTypeDriver):
"""Contract, not code."""
ROLE: str = "storage"
TYPE_LABEL: str = "Storage"
if TYPE_CHECKING: # nothing exists at runtime
if TYPE_CHECKING: # nothing exists at runtime
def get_disks(self) -> List[PhysicalDiskDict]: ...
```
@@ -64,6 +77,14 @@ Two things follow, and both are improvements:
- **A method that was never implemented raises `AttributeError`, not
`NotImplementedError`.** Ask before calling.
The rule covers this package's methods, not NAPALM's. `NetworkDriver` still carries
a runtime `NotImplementedError` stub for each of its getters (`get_interfaces`,
`get_environment`, `get_users`, …), and every role base inherits it. So a NAPALM getter
must come from the driver class itself or from a mixin listed **before** the first role
base; a mixin listed after it loses the MRO to NAPALM's stub. For the same reason
`hasattr` says nothing about NAPALM's getters, and the OS role's account listing is
`get_os_users`, not `get_users` -- NAPALM's `get_users` has a different shape.
## Function classes: what a device *can do*
Behaviour shared across roles lives in a function class, exactly once, and a role base
@@ -73,12 +94,45 @@ is a thin bundle over them — `PackageManagementMixin`, `HealthMetricsMixin`,
`HostRebootMixin` (`reboot_host`) is mixed into `DeviceTypeDriver` itself, since any
device may be restartable; like the others it only declares.
**Fixes are templates** (`napalm_device_types.fixes`, since 4.13). A change the
application wants made on a device runs as fixed steps the driver implements, with values
the caller supplies: `SnmpAgentMixin.configure_snmp_agent(settings)` (install, configure,
firewall, start), `EnsurePackageMixin.ensure_package(name)`,
`PackageProxyMixin.configure_package_proxy(url)` and
`ContainerEngineMixin.grant_container_engine_access(user)`. A driver whose device takes the
change in one call overrides the template method. A driver mixes in the fixes its device
can take; none is on a role base. They replace `run_device_action`, which is deprecated.
**SNMP is the application's** (`docs/STRUCTURE.md`). `HealthMetricsMixin` is deprecated
since 4.12 and goes in 5.0. A driver does not collect over SNMP; a driver whose device
publishes its health values under OIDs of its own declares them as data:
```python
class MySwitchDriver(SwitchDriver):
SNMP_HEALTH_PROFILE: ClassVar[SnmpHealthProfile] = {
"uptime": {"oids": ["1.3.6.1.2.1.1.3.0"]},
"cpu": {"oids": ["<vendor cpu oid>", "<older firmware's>"]},
"memory": {"total": {"oids": ["<oid>"]}, "free": {"oids": ["<oid>"]}, "unit": "KiB"},
}
```
A value is `oids` (the first that answers wins) or a `table` folded by `first`, `average`
or `max`, with an optional `pattern` for an agent that answers with text. What the profile
does not name, the application reads from the standard UCD-MIB and IF-MIB.
`KernelFactsMixin` (`get_kernel_facts`) is the exception that is mixed in by a driver
rather than by a role base: what a Linux kernel has built and loaded is read the same way
everywhere, so the command and its parse are concrete here and a driver supplies only
`_run_kernel_facts_command`. `OSDriver` does not carry it — a Windows host is an OS driver
too, and `hasattr(driver, "get_kernel_facts")` has to stay truthful.
`ServicePackagesMixin` (`get_service_packages`) is mixed in the same way: which installed
packages each systemd service is made of — the one shipping its unit file and the one
shipping the binary its `ExecStart` runs, by `dpkg -S` or `rpm -qf`, not the libraries
that binary links against. An application needs it to let a statement about a service
(the postgresql unit) reach the findings on its packages. A driver supplies
`_run_service_packages_command`.
`HostStatusMixin` (`get_host_status`) is mixed in the same way: whether a Linux host needs
a reboot to finish an update (`/var/run/reboot-required`, `needs-restarting -r`, or a newer
kernel of the running flavour installed), whether it patches itself (unattended-upgrades,
@@ -99,6 +153,17 @@ without `ss` is read with `netstat -lntup` (OpenWrt's busybox, old net-tools); o
cgroup names the procd service (`/services/<name>/<instance>`). A host with neither raises
`ListeningSocketsUnavailable`.
`HostFirewallMixin` (`get_host_firewall`) reads a Linux host's own netfilter rules the same
way: `nft -j list ruleset`, with the tables iptables-nft manages taken from `iptables-save`
instead, because nft shows their extensions (`-m conntrack`, `-m set`) only as an opaque `xt`.
A legacy iptables is a ruleset of its own and is kept beside nft's. Every chain says its family,
table, hook, priority and policy; every rule is a list of matches (`iifname`, `saddr`, `dport`,
`ct_state`, ...) and a verdict. A condition it does not model is an `unknown` match with its
text, never left out. A driver supplies `_run_host_firewall_command(command, privileged=)`; both
tools need root, so a host where neither could be read raises `HostFirewallUnavailable` rather
than reading as one without rules. Whether the rules keep a service unreachable is the
consumer's call.
`VirtualIpsMixin` (`get_virtual_ips`) reads the addresses a host's HA configuration lets
float between machines: vip-manager (5.x's `--config` YAML, 1.x's `-ip=${VIP_IP}` resolved
through the unit's environment) and keepalived (`virtual_ipaddress` of every
@@ -110,6 +175,49 @@ A mechanism it could not read completely is `None` ("did not look"), a host with
`[]`. The command is about 4 kB, so a driver sends it on an exec channel
(`_run_virtual_ips_command(command, privileged=)`), not typed into a shell.
`ServiceClustersMixin` (`get_service_clusters`) reads the configuration that proves two
hosts run one service together -- not merely that they answer at one address: Patroni
(`scope`, `name` and `restapi.connect_address` from its YAML, then the members and roles
its REST API's `/cluster` lists from the DCS), adguardhome-sync (`ORIGIN_URL` and
`REPLICA*_URL` of its container: the origin pushes to each replica) and BunkerWeb (a
worker's `API_WHITELIST_IP` names the manager it takes its configuration from, a manager's
`BUNKERWEB_INSTANCES` its workers). Each member carries its role as the mechanism words it
and the index of the member it takes its configuration from (`config_from`). The same
configuration holds the superuser password, the sync's logins and BunkerWeb's database URI,
so awk programs filter **on the host**: allowlisted fields only, a URL without its
`user:password@`, and the DCS endpoints stay on the host. Root first (Patroni's YAML is the
postgres user's), then without it. `None` per mechanism is "did not look", `[]` "has none".
The container engine is asked through `_container_engine_binary` where the driver has one.
A driver sends the command on an exec channel (`_run_service_clusters_command(command,
privileged=)`).
`get_dhcp_ha` (declared on `DhcpServerMixin`, optional) is what a DHCP server's own HA
configuration says: whether HA is on, which peer this server is, each peer's name, role --
as the server words it, not a shared vocabulary -- and, where the server has one, the URL
its HA listener answers on. It is configuration, not state: whether the pair is in hot
standby right now is not in it. `ConfigSyncMixin` (`get_config_sync`) is where a host
pushes its configuration (`peer`, None for no one) and for which services, by the names
`get_services` reports; sync items the driver has no service name for stay visible under
`unmapped`. A driver mixes it in -- no role promises it. Both getters raise when they could
not read; neither answers "off" for "could not ask". What the facts mean for a pair (hot
standby, which side to write to) is the caller's to work out.
A firewall's CARP addresses are a third mechanism, `carp`: a firewall driver reads them from
its own API and reports them under the reading's `carp` key (`vip_manager` and `keepalived`
then None). What a CARP state means is protocol knowledge and stays in that driver. The
contract only carries the result: unlike the other two, a CARP entry says who holds the
address (`holds`), because a firewall's interface list need not show it. `holds` None
leaves the holder to the interfaces.
`SystemIdentityMixin` (`get_system_identity`) reads a host's SMBIOS identity -- system UUID,
product serial and board serial from `/sys/class/dmi/id` -- which is what ties a server to
its BMC (`ManagementControllerDriver.get_hardware_inventory` reports the same values from
the other side). Vendor placeholders (`0123456789` on Supermicro boards, `To be filled by
O.E.M.`, `Default string`, all-zero or the common `03000200-…-000700080009` UUID) read as
empty: matching on them would join unrelated machines. `is_placeholder_identity` is the
same filter for a BMC driver's side. `product_uuid` and `product_serial` are root's, so a
driver supplies `_run_system_identity_command(command, privileged=)`.
`OpenSshServerMixin` (`harden_ssh_server`, `get_ssh_server_algorithms`) takes algorithms
out of what an OpenSSH server offers -- the ones ssh-audit flags. It reads the lists sshd
really uses (`sshd -T`), takes out what was asked and writes the rest to
@@ -167,6 +275,27 @@ NAPALM's own getters (`get_facts`, `get_interfaces`, `ping`, `get_config`) are n
wrapped in a template — they belong to NAPALM, and code outside this repo relies on
their contract.
### SSH host keys: trust on first use
A driver that accepts any host key logs in with the device's credentials at whatever
answers on the device's address (NetOrk/netork#1167). The store of trusted keys is the
caller's; `ssh_host_keys.HostKeyCheck` is how every SSH driver verifies against it:
- **In:** `optional_args["ssh_host_keys"]` = `{"22": {"key_type": "ssh-ed25519", "key": "<base64>"}}`,
by port. The driver takes the key for the port it connects to. With one, any other key is
refused *before* authentication and the connection raises `SshHostKeyMismatch` (a
`ConnectionException`). Without one, the first key is accepted.
- **Out:** `optional_args["ssh_host_key_observer"]`, a callable, gets one dict per
connection: `{"host", "port", "seen": {"key_type", "key", "fingerprint"}, "expected", "accepted"}`.
`accepted` is False only for a refused key. A failing observer is logged, never raised.
- **paramiko:** `HostKeyCheck(optional_args, host, port).connect(client, **connect_kwargs)`.
- **netmiko:** `with check.netmiko(self.netmiko_optional_args) as kwargs: ConnectHandler(..., **kwargs)`,
then `check.observe(conn.remote_conn_pre.get_transport())`. The known key reaches netmiko as a
`known_hosts` file that exists only inside the block, with `ssh_strict=True`.
A caller that passes neither key gets first use on every connection, as before; a driver
that ignores both still connects. Old and new versions on either side keep working.
## Design principle: generic vs. device-specific logic
When adding behavior to a device-type base class, split it along one line: **would
@@ -183,22 +312,30 @@ Concretely: matching/comparison/reconciliation algorithms, orchestration flows,
generic data shapes belong here. Only the actual device communication belongs in
`vendor/napalm-<name>`.
**Worked example — firewall rule diff/apply** (`FirewallDriver`):
**Worked example — firewall rule diff/apply** (`FirewallRuleMixin`, in `FirewallDriver`):
```python
class FirewallDriver(DeviceTypeDriver):
# Abstract — every driver implements its own device communication.
def get_firewall_rules(self) -> List[FirewallRuleDict]: raise NotImplementedError
def apply_firewall_rule(self, rule: FirewallRuleDict, *, uuid: Optional[str] = None) -> Dict[str, Any]: raise NotImplementedError
def commit_firewall_rules(self) -> Dict[str, Any]: raise NotImplementedError
class FirewallRuleMixin: # mixed into FirewallDriver
# Declared only — every driver implements its own device communication.
if TYPE_CHECKING:
def get_firewall_rules(self) -> List[FirewallRuleDict]: ...
def apply_firewall_rule(
self, rule: FirewallRuleDict, *, uuid: Optional[str] = None
) -> Dict[str, Any]: ...
def commit_firewall_rules(self) -> Dict[str, Any]: ...
# Concrete — the matching/comparison/orchestration algorithm is identical
# for every firewall vendor, so it lives here once.
def diff_firewall_rules(self, desired: List[FirewallRuleDict]) -> FirewallRuleDiffDict:
... # matches self.get_firewall_rules() against `desired` by description
def diff_firewall_rules(
self, desired: List[FirewallRuleDict]
) -> (
FirewallRuleDiffDict
): ... # matches self.get_firewall_rules() against `desired` by description
def apply_firewall_ruleset(self, desired: List[FirewallRuleDict]):
... # computes the diff, calls apply_firewall_rule() per change, commits
def apply_firewall_ruleset(
self, desired: List[FirewallRuleDict]
): ... # computes the diff, calls apply_firewall_rule() per change, commits
```
The same split applies to `DhcpServerMixin`: `get_dhcp_reservations`/
@@ -242,7 +379,7 @@ detection vs. presentation there.
pip install napalm-device-types
```
Requires Python ≥ 3.9 and NAPALM ≥ 4.0.
Requires Python ≥ 3.10 (tested up to 3.13) and NAPALM ≥ 4.0.
## Available base classes
@@ -255,12 +392,37 @@ Requires Python ≥ 3.9 and NAPALM ≥ 4.0.
| `OSDriver` | General-purpose operating systems | Linux, BSD, macOS |
| `StorageDriver` | Storage appliances & NAS/SAN | TrueNAS, Synology DSM, QNAP QTS |
| `ResidentialGatewayDriver` | Router + firewall + AP in one box | OpenWrt, FritzBox |
| `PhoneDriver` | IP telephony endpoints | Yealink |
| `MediaDriver` | Speakers, streamers, media renderers | Sonos, Chromecast |
| `ManagementControllerDriver` | Server baseboard management controllers | Supermicro BMC, Dell iDRAC, HPE iLO, Lenovo XCC |
Mixins mixed into the classes above rather than used on their own:
`ConfigLifecycleMixin` (config load/compare/commit/rollback), `PingSweepMixin`
(subnet sweeps), and `DhcpServerMixin` (static DHCP reservations — mixed into
`FirewallDriver` and `ResidentialGatewayDriver`, since both commonly run the
DHCP server for their networks).
Mixins that come with the classes above: `PingSweepMixin` (subnet sweeps, in
every driver through `DeviceTypeDriver`) and `DhcpServerMixin` (static DHCP
reservations — mixed into `FirewallDriver` and `ResidentialGatewayDriver`,
since both commonly run the DHCP server for their networks).
`ConfigLifecycleMixin` (config load/compare/commit/rollback) is mixed in by a
driver itself.
### Fingerprints
`DeviceTypeDriver` carries what discovery matches a device against: `PORT_SPECS`,
`SNMP_OBJECT_ID_PREFIX`, `SNMP_FINGERPRINT`, `SSH_FINGERPRINT`, `HTTP_FINGERPRINT` and
`OUI_PREFIXES`. A `FingerprintRule` is a lowercase substring with a weight; `mandatory`
disqualifies the driver when it is missing, `negative` subtracts.
Discovery compares drivers by score divided by the most a driver could score. Rules that
can never match together, such as one vendor's landing page or another's, share a
`group`: only the strongest rule of a group counts towards that maximum
(`rules_max_weight`). Without it, a driver that recognises three vendors' pages would
score a third as well as one that recognises a single page:
```python
HTTP_FINGERPRINT = [
FingerprintRule("aten international", 6.0, group="landing_page"), # Supermicro
FingerprintRule("idrac", 6.0, group="landing_page"),
FingerprintRule("integrated lights-out", 6.0, group="landing_page"), # HPE iLO
]
```
## Usage
@@ -269,8 +431,8 @@ DHCP server for their networks).
```python
from napalm_device_types import AccessPointDriver
class OpenWrtDriver(AccessPointDriver):
class OpenWrtDriver(AccessPointDriver):
def get_wireless_clients(self):
# return List[WirelessClientDict]
...
@@ -285,8 +447,8 @@ class OpenWrtDriver(AccessPointDriver):
```python
from napalm_device_types import SwitchDriver
class CiscoIOSDriver(SwitchDriver):
class CiscoIOSDriver(SwitchDriver):
def get_spanning_tree(self):
# return Dict[str, SpanningTreeDict]
...
@@ -301,8 +463,8 @@ class CiscoIOSDriver(SwitchDriver):
```python
from napalm_device_types import FirewallDriver
class PfSenseDriver(FirewallDriver):
class PfSenseDriver(FirewallDriver):
def get_nat_translations(self):
# return List[NATTranslationDict]
...
@@ -322,14 +484,13 @@ class PfSenseDriver(FirewallDriver):
```python
from napalm_device_types import HypervisorDriver
class ProxmoxDriver(HypervisorDriver):
class ProxmoxDriver(HypervisorDriver):
def get_vms(self):
# return List[VMDict]
...
def create_vm_snapshot(self, name, snapshot, description="", include_memory=False):
...
def create_vm_snapshot(self, name, snapshot, description="", include_memory=False): ...
def get_vm_cpu_types(self):
# optional — return List[VMCpuTypeDict]: the CPU models a new VM may get
@@ -353,8 +514,8 @@ unpacked; both are the same for every hypervisor.
```python
from napalm_device_types import OSDriver
class LinuxDriver(OSDriver):
class LinuxDriver(OSDriver):
def get_packages(self):
# return List[PackageDict]
...
@@ -363,8 +524,8 @@ class LinuxDriver(OSDriver):
# return List[ServiceDict]
...
def get_users(self):
# return List[UserDict]
def get_os_users(self):
# return List[UserDict] -- not NAPALM's get_users, whose shape differs
...
def get_processes(self):
@@ -381,8 +542,8 @@ class LinuxDriver(OSDriver):
```python
from napalm_device_types import StorageDriver
class TrueNASDriver(StorageDriver):
class TrueNASDriver(StorageDriver):
def get_disks(self):
# return List[PhysicalDiskDict]
...
@@ -408,7 +569,7 @@ from napalm_device_types.models import (
## Development
```bash
git clone https://github.com/chrismanivong/napalm-device-types.git
git clone https://git.netork.io/NAPALM/napalm-device-types.git
cd napalm-device-types
pip install -e ".[dev]"
```
@@ -421,10 +582,15 @@ mypy napalm_device_types
## Contributing
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/router-driver`)
3. Implement your changes
4. Open a Pull Request
This package follows the
[NAPALM driver standard](https://git.netork.io/NAPALM/driver-standard/src/branch/main/STANDARDS.md):
test first, ruff and strict mypy, Conventional Commits, one pull request per change
against `main`. CI checks what a tool can check:
```bash
pip install "napalm-standard @ git+https://git.netork.io/NAPALM/driver-standard.git"
napalm-standard check && napalm-standard ratchet
```
## License
+156 -22
View File
@@ -29,6 +29,7 @@ Role bases -- what a device *is*:
* :class:`~napalm_device_types.storage.StorageDriver`
* :class:`~napalm_device_types.phone.PhoneDriver`
* :class:`~napalm_device_types.media.MediaDriver`
* :class:`~napalm_device_types.management_controller.ManagementControllerDriver`
* :class:`~napalm_device_types.residential_gateway.ResidentialGatewayDriver`
Function classes -- what a device *can do*. Shared behaviour lives here once
@@ -36,6 +37,7 @@ instead of being restated on every role that happens to need it:
* :class:`~napalm_device_types.channel.CommandChannelMixin`
* :class:`~napalm_device_types.config_lifecycle.ConfigLifecycleMixin`
* :class:`~napalm_device_types.config_sync.ConfigSyncMixin`
* :class:`~napalm_device_types.container_engine.ContainerEngineMixin`
* :class:`~napalm_device_types.dhcp.DhcpServerMixin`
* :class:`~napalm_device_types.firewall_rules.FirewallRuleMixin`
@@ -45,10 +47,13 @@ instead of being restated on every role that happens to need it:
* :class:`~napalm_device_types.interface_filter.InterfaceFilterMixin`
* :class:`~napalm_device_types.kernel.KernelFactsMixin`
* :class:`~napalm_device_types.listening.ListeningSocketsMixin`
* :class:`~napalm_device_types.host_firewall.HostFirewallMixin`
* :class:`~napalm_device_types.mac_acl.MacAclMixin`
* :class:`~napalm_device_types.nat_vpn.NatVpnMixin`
* :class:`~napalm_device_types.packages.PackageManagementMixin`
* :class:`~napalm_device_types.ping_sweep.PingSweepMixin`
* :class:`~napalm_device_types.service_clusters.ServiceClustersMixin`
* :class:`~napalm_device_types.service_packages.ServicePackagesMixin`
* :class:`~napalm_device_types.services.ServiceControlMixin`
* :class:`~napalm_device_types.systemd.SystemdServicesMixin`
* :class:`~napalm_device_types.updates.UpdateMixin`
@@ -59,8 +64,8 @@ Introspection -- :func:`~napalm_device_types.roles.roles_of`,
:func:`~napalm_device_types.roles.primary_role_of`.
"""
from napalm_device_types.base import DeviceTypeDriver, FingerprintRule, PortSpec
from napalm_device_types.access_point import AccessPointDriver
from napalm_device_types.base import DeviceTypeDriver, FingerprintRule, PortSpec, rules_max_weight
from napalm_device_types.channel import (
ByteStream,
CommandChannelMixin,
@@ -70,21 +75,36 @@ from napalm_device_types.channel import (
run_on_transport,
)
from napalm_device_types.config_lifecycle import ConfigLifecycleMixin
from napalm_device_types.config_sync import ConfigSyncMixin
from napalm_device_types.container_engine import (
ContainerEngineConnection,
ContainerEngineMixin,
ContainerEngineUnavailable,
)
from napalm_device_types.dhcp import DhcpServerMixin, normalize_cidr, normalize_mac
from napalm_device_types.dns_forward import DnsForwardMixin
from napalm_device_types.firewall import FirewallDriver
from napalm_device_types.hypervisor import HypervisorDriver
from napalm_device_types.os import OSDriver
from napalm_device_types.firewall_rules import FirewallRuleMixin
from napalm_device_types.firmware import FirmwareOfferMixin
from napalm_device_types.fixes import (
EnsurePackageMixin,
PackageProxyMixin,
SnmpAgentMixin,
run_fix_steps,
)
from napalm_device_types.health_metrics import HealthMetricsMixin
from napalm_device_types.host_firewall import (
HostFirewallMixin,
HostFirewallUnavailable,
parse_host_firewall,
)
from napalm_device_types.host_reboot import HostRebootMixin
from napalm_device_types.host_status import HOST_STATUS_COMMAND, HostStatusMixin, parse_host_status
from napalm_device_types.hypervisor import HypervisorDriver
from napalm_device_types.interface_filter import InterfaceFilterMixin
from napalm_device_types.kernel import KERNEL_FACTS_COMMAND, KernelFactsMixin, parse_kernel_facts
from napalm_device_types.lag import add_lag_interfaces
from napalm_device_types.licenses import LicenseMixin
from napalm_device_types.listening import (
LISTENING_SOCKETS_COMMAND,
ListeningSocketsMixin,
@@ -92,34 +112,73 @@ from napalm_device_types.listening import (
parse_listening_sockets,
)
from napalm_device_types.mac_acl import MacAclMixin
from napalm_device_types.management_controller import ManagementControllerDriver
from napalm_device_types.media import MediaDriver
from napalm_device_types.models import (
POWER_ACTIONS,
ConfigSyncDict,
DhcpHaDict,
DhcpHaPeerDict,
DnsForwardDict,
EventLogEntryDict,
FirmwareDict,
FirmwareOfferDict,
FixResultDict,
FixStepDict,
FixStepOutcomeDict,
HardwareInventoryDict,
HypervisorHostDict,
LicenseDict,
ManagementProtocolsDict,
OspfDict,
OspfInterfaceDict,
PowerAction,
PowerStateDict,
SnmpAgentSettingsDict,
SnmpHealthProfile,
SnmpInterfacesSpec,
SnmpMemorySpec,
SnmpValueSpec,
UpdateProgressDict,
)
from napalm_device_types.nat_vpn import NatVpnMixin
from napalm_device_types.packages import PackageManagementMixin
from napalm_device_types.phone import PhoneDriver
from napalm_device_types.ping_sweep import PingSweepMixin, driver_supports_ping
from napalm_device_types.roles import primary_role_of, role_keys_of, roles_of
from napalm_device_types.host_status import HOST_STATUS_COMMAND, HostStatusMixin, parse_host_status
from napalm_device_types.os import OSDriver
from napalm_device_types.ospf import OspfMixin
from napalm_device_types.package_updates import (
APT_UPGRADABLE_COMMAND,
DNF_SECURITY_COMMAND,
parse_apt_upgradable,
parse_dnf_security,
)
from napalm_device_types.services import ServiceControlMixin
from napalm_device_types.terminal import strip_terminal_codes
from napalm_device_types.systemd import (
SYSTEMD_SERVICES_COMMAND,
SystemdServicesMixin,
SystemdUnavailable,
parse_systemd_services,
)
from napalm_device_types.updates import UpdateMixin
from napalm_device_types.virtual_ips import (
VIRTUAL_IPS_COMMAND,
VirtualIpsMixin,
parse_virtual_ips,
)
from napalm_device_types.packages import PackageManagementMixin
from napalm_device_types.phone import PhoneDriver
from napalm_device_types.ping_sweep import PingSweepMixin, driver_supports_ping
from napalm_device_types.residential_gateway import ResidentialGatewayDriver
from napalm_device_types.roles import primary_role_of, role_keys_of, roles_of
from napalm_device_types.service_clusters import (
SERVICE_CLUSTERS_COMMAND,
ServiceClustersMixin,
parse_service_clusters,
service_clusters_command,
)
from napalm_device_types.service_packages import (
SERVICE_PACKAGES_COMMAND,
ServicePackagesMixin,
parse_service_packages,
)
from napalm_device_types.services import ServiceControlMixin
from napalm_device_types.ssh_host_keys import (
KNOWN_KEYS_ARG as SSH_HOST_KEYS_ARG,
)
from napalm_device_types.ssh_host_keys import (
OBSERVER_ARG as SSH_HOST_KEY_OBSERVER_ARG,
)
from napalm_device_types.ssh_host_keys import (
HostKeyCheck,
HostKeyObservation,
SshHostKey,
SshHostKeyMismatch,
)
from napalm_device_types.ssh_server import (
SSH_ALGORITHM_CLASSES,
OpenSshServerMixin,
@@ -129,8 +188,50 @@ from napalm_device_types.ssh_server import (
)
from napalm_device_types.storage import StorageDriver
from napalm_device_types.switch import SwitchDriver
from napalm_device_types.system_identity import (
SYSTEM_IDENTITY_COMMAND,
SystemIdentityMixin,
is_placeholder_identity,
parse_system_identity,
)
from napalm_device_types.systemd import (
SYSTEMD_SERVICES_COMMAND,
SystemdServicesMixin,
SystemdUnavailable,
parse_systemd_services,
)
from napalm_device_types.terminal import strip_terminal_codes
from napalm_device_types.updates import UpdateMixin
from napalm_device_types.virtual_ips import (
VIRTUAL_IPS_COMMAND,
VirtualIpsMixin,
parse_virtual_ips,
)
__all__ = [
"HypervisorHostDict",
"DnsForwardDict",
"DnsForwardMixin",
"FirmwareOfferDict",
"OspfDict",
"OspfInterfaceDict",
"OspfMixin",
"UpdateProgressDict",
"FirmwareOfferMixin",
"LicenseDict",
"LicenseMixin",
"EnsurePackageMixin",
"FixResultDict",
"FixStepDict",
"FixStepOutcomeDict",
"PackageProxyMixin",
"SnmpAgentMixin",
"SnmpAgentSettingsDict",
"run_fix_steps",
"SnmpHealthProfile",
"SnmpInterfacesSpec",
"SnmpMemorySpec",
"SnmpValueSpec",
"AccessPointDriver",
"ByteStream",
"CommandChannelMixin",
@@ -142,9 +243,14 @@ __all__ = [
"open_stream_on_transport",
"run_on_transport",
"ConfigLifecycleMixin",
"ConfigSyncDict",
"ConfigSyncMixin",
"DhcpHaDict",
"DhcpHaPeerDict",
"DeviceTypeDriver",
"DhcpServerMixin",
"FingerprintRule",
"rules_max_weight",
"FirewallDriver",
"FirewallRuleMixin",
"HealthMetricsMixin",
@@ -154,6 +260,14 @@ __all__ = [
"HypervisorDriver",
"InterfaceFilterMixin",
"MacAclMixin",
"ManagementControllerDriver",
"POWER_ACTIONS",
"PowerAction",
"PowerStateDict",
"HardwareInventoryDict",
"EventLogEntryDict",
"FirmwareDict",
"ManagementProtocolsDict",
"MediaDriver",
"NatVpnMixin",
"OSDriver",
@@ -167,7 +281,13 @@ __all__ = [
"KernelFactsMixin",
"KERNEL_FACTS_COMMAND",
"parse_kernel_facts",
"SERVICE_PACKAGES_COMMAND",
"ServicePackagesMixin",
"parse_service_packages",
"LISTENING_SOCKETS_COMMAND",
"HostFirewallMixin",
"HostFirewallUnavailable",
"parse_host_firewall",
"ListeningSocketsMixin",
"ListeningSocketsUnavailable",
"parse_listening_sockets",
@@ -185,11 +305,25 @@ __all__ = [
"UpdateMixin",
"VIRTUAL_IPS_COMMAND",
"VirtualIpsMixin",
"SERVICE_CLUSTERS_COMMAND",
"ServiceClustersMixin",
"parse_service_clusters",
"service_clusters_command",
"SystemIdentityMixin",
"SYSTEM_IDENTITY_COMMAND",
"is_placeholder_identity",
"parse_system_identity",
"OpenSshServerMixin",
"SSH_ALGORITHM_CLASSES",
"SshHardeningPlan",
"SshHardeningResult",
"plan_ssh_hardening",
"SSH_HOST_KEYS_ARG",
"SSH_HOST_KEY_OBSERVER_ARG",
"HostKeyCheck",
"HostKeyObservation",
"SshHostKey",
"SshHostKeyMismatch",
"parse_virtual_ips",
"add_lag_interfaces",
"driver_supports_ping",
+47
View File
@@ -0,0 +1,47 @@
"""Keying live and desired entries for a diff, refusing what cannot be matched.
A diff matches a desired entry to a live one by a key (a rule's description, a
reservation's MAC, a subnet's CIDR). An empty key or one held twice cannot
name a single entry, and guessing would overwrite one the caller never meant.
"""
from __future__ import annotations
from collections.abc import Callable, Iterable
from typing import TypeVar
T = TypeVar("T")
def index_live(entries: Iterable[T], key: Callable[[T], str]) -> tuple[dict[str, T], set[str]]:
"""Live entries by key, and the keys more than one of them holds.
Entries without a key are left out: they are not the caller's to match.
"""
by_key: dict[str, T] = {}
shared: set[str] = set()
for entry in entries:
k = key(entry)
if not k:
continue
if k in by_key:
shared.add(k)
by_key[k] = entry
for k in shared:
del by_key[k]
return by_key, shared
def check_desired(entries: list[T], key: Callable[[T], str], shared: set[str], what: str) -> None:
"""``ValueError`` for a desired entry with no key, a key given twice, or a
key that names more than one live entry."""
seen: set[str] = set()
for entry in entries:
k = key(entry)
if not k:
raise ValueError(f"a desired entry has no {what}")
if k in seen:
raise ValueError(f"{what} {k!r} is given twice")
if k in shared:
raise ValueError(f"{what} {k!r} names more than one entry on the device")
seen.add(k)
+103 -53
View File
@@ -1,35 +1,43 @@
"""Shared SNMP metric collection helpers for UCD-MIB and IF-MIB."""
from __future__ import annotations
import asyncio
import re
from typing import Awaitable, Callable, Dict, Optional
from collections.abc import Awaitable, Callable
from typing import Any
# ── UCD-MIB OIDs ──────────────────────────────────────────────────────────────
OID_SYS_UPTIME = "1.3.6.1.2.1.25.1.1.0" # hrSystemUptime (centiseconds)
OID_CPU_IDLE = "1.3.6.1.4.1.2021.11.11.0" # UCD ssCpuIdle (%)
OID_MEM_TOTAL = "1.3.6.1.4.1.2021.4.5.0" # memTotalReal (kB)
OID_MEM_FREE = "1.3.6.1.4.1.2021.4.6.0" # memAvailReal (kB)
OID_MEM_BUFFER = "1.3.6.1.4.1.2021.4.14.0" # memBuffer (kB)
OID_MEM_CACHED = "1.3.6.1.4.1.2021.4.15.0" # memCached (kB)
OID_SWAP_TOTAL = "1.3.6.1.4.1.2021.4.3.0" # memTotalSwap (kB)
OID_SWAP_AVAIL = "1.3.6.1.4.1.2021.4.4.0" # memAvailSwap (kB)
OID_LOAD_1 = "1.3.6.1.4.1.2021.10.1.3.1" # laLoad 1-min
OID_LOAD_5 = "1.3.6.1.4.1.2021.10.1.3.2" # laLoad 5-min
OID_LOAD_15 = "1.3.6.1.4.1.2021.10.1.3.3" # laLoad 15-min
OID_SYS_UPTIME = "1.3.6.1.2.1.25.1.1.0" # hrSystemUptime (centiseconds)
OID_CPU_IDLE = "1.3.6.1.4.1.2021.11.11.0" # UCD ssCpuIdle (%)
OID_MEM_TOTAL = "1.3.6.1.4.1.2021.4.5.0" # memTotalReal (kB)
OID_MEM_FREE = "1.3.6.1.4.1.2021.4.6.0" # memAvailReal (kB)
OID_MEM_BUFFER = "1.3.6.1.4.1.2021.4.14.0" # memBuffer (kB)
OID_MEM_CACHED = "1.3.6.1.4.1.2021.4.15.0" # memCached (kB)
OID_SWAP_TOTAL = "1.3.6.1.4.1.2021.4.3.0" # memTotalSwap (kB)
OID_SWAP_AVAIL = "1.3.6.1.4.1.2021.4.4.0" # memAvailSwap (kB)
OID_LOAD_1 = "1.3.6.1.4.1.2021.10.1.3.1" # laLoad 1-min
OID_LOAD_5 = "1.3.6.1.4.1.2021.10.1.3.2" # laLoad 5-min
OID_LOAD_15 = "1.3.6.1.4.1.2021.10.1.3.3" # laLoad 15-min
# ── IF-MIB OIDs ───────────────────────────────────────────────────────────────
OID_IF_DESCR = "1.3.6.1.2.1.2.2.1.2"
OID_IF_SPEED = "1.3.6.1.2.1.2.2.1.5"
OID_IF_IN_OCT = "1.3.6.1.2.1.2.2.1.10"
OID_IF_DESCR = "1.3.6.1.2.1.2.2.1.2"
OID_IF_SPEED = "1.3.6.1.2.1.2.2.1.5"
OID_IF_IN_OCT = "1.3.6.1.2.1.2.2.1.10"
OID_IF_OUT_OCT = "1.3.6.1.2.1.2.2.1.16"
OID_IF_IN_ERR = "1.3.6.1.2.1.2.2.1.14"
OID_IF_IN_ERR = "1.3.6.1.2.1.2.2.1.14"
OID_IF_OUT_ERR = "1.3.6.1.2.1.2.2.1.20"
# ifXTable: 64-bit octets and Mbps. The 32-bit counters wrap every ~34 s at
# 1 Gbit/s and ifSpeed stops at 4294 Mbps; an agent without ifXTable (SNMPv1)
# falls back to them.
OID_IF_HC_IN_OCT = "1.3.6.1.2.1.31.1.1.1.6"
OID_IF_HC_OUT_OCT = "1.3.6.1.2.1.31.1.1.1.10"
OID_IF_HIGH_SPEED = "1.3.6.1.2.1.31.1.1.1.15"
IF_SKIP_DEFAULT = re.compile(r'^(lo|sit\d|tun\d|docker|veth|br-|virbr)', re.IGNORECASE)
IF_SKIP_DEFAULT = re.compile(r"^(lo|sit\d|tun\d|docker|veth|br-|virbr)", re.IGNORECASE)
SnmpGetFn = Callable[[str], Awaitable[Optional[str]]]
SnmpWalkFn = Callable[[str], Awaitable[Dict[str, str]]]
SnmpGetFn = Callable[[str], Awaitable[str | None]]
SnmpWalkFn = Callable[[str], Awaitable[dict[str, str]]]
def ticks_to_seconds(raw: str | None) -> int | None:
@@ -42,36 +50,43 @@ def ticks_to_seconds(raw: str | None) -> int | None:
def build_if_metrics(
metrics: dict,
descr: Dict[str, str],
speed: Dict[str, str],
in_oct: Dict[str, str],
out_oct: Dict[str, str],
in_err: Dict[str, str],
out_err: Dict[str, str],
metrics: dict[str, Any],
descr: dict[str, str],
speed: dict[str, str],
in_oct: dict[str, str],
out_oct: dict[str, str],
in_err: dict[str, str],
out_err: dict[str, str],
*,
if_skip: re.Pattern | None = None,
if_skip: re.Pattern[str] | None = None,
tx_err_is_drop: bool = False,
hc_in_oct: dict[str, str] | None = None,
hc_out_oct: dict[str, str] | None = None,
high_speed: dict[str, str] | None = None,
) -> None:
if if_skip is None:
if_skip = IF_SKIP_DEFAULT
interfaces: dict = {}
interfaces: dict[str, Any] = {}
for idx, name in descr.items():
if if_skip.match(name):
continue
iface: dict = {"name": name}
iface: dict[str, Any] = {"name": name}
try:
iface["rx_bytes"] = int(in_oct.get(idx, 0) or 0)
iface["tx_bytes"] = int(out_oct.get(idx, 0) or 0)
iface["rx_errors"] = int(in_err.get(idx, 0) or 0)
iface["rx_bytes"] = int((hc_in_oct or {}).get(idx) or in_oct.get(idx) or 0)
iface["tx_bytes"] = int((hc_out_oct or {}).get(idx) or out_oct.get(idx) or 0)
iface["rx_errors"] = int(in_err.get(idx, 0) or 0)
tx_val = int(out_err.get(idx, 0) or 0)
if tx_err_is_drop:
iface["tx_errors"] = 0
iface["tx_drops"] = tx_val
iface["tx_drops"] = tx_val
else:
iface["tx_errors"] = tx_val
high = (high_speed or {}).get(idx)
spd = speed.get(idx)
iface["speed_mbps"] = int(spd) // 1_000_000 if spd and int(spd) > 0 else 0
if high and int(high) > 0:
iface["speed_mbps"] = int(high)
else:
iface["speed_mbps"] = int(spd) // 1_000_000 if spd and int(spd) > 0 else 0
except (ValueError, TypeError):
pass
interfaces[name] = iface
@@ -84,15 +99,35 @@ async def collect_ucd_metrics(
snmp_walk: SnmpWalkFn,
*,
tx_err_is_drop: bool = False,
if_skip: re.Pattern | None = None,
) -> dict:
if_skip: re.Pattern[str] | None = None,
) -> dict[str, Any]:
"""Collect UCD-MIB system metrics + IF-MIB interface counters in parallel."""
(
uptime_raw, cpu_idle_raw,
mem_total_raw, mem_free_raw, mem_buf_raw, mem_cache_raw,
swap_total_raw, swap_avail_raw,
load1_raw, load5_raw, load15_raw,
), (descr, speed, in_oct, out_oct, in_err, out_err) = await asyncio.gather(
(
uptime_raw,
cpu_idle_raw,
mem_total_raw,
mem_free_raw,
mem_buf_raw,
mem_cache_raw,
swap_total_raw,
swap_avail_raw,
load1_raw,
load5_raw,
load15_raw,
),
(
descr,
speed,
in_oct,
out_oct,
in_err,
out_err,
hc_in_oct,
hc_out_oct,
high_speed,
),
) = await asyncio.gather(
asyncio.gather(
snmp_get(OID_SYS_UPTIME),
snmp_get(OID_CPU_IDLE),
@@ -113,10 +148,13 @@ async def collect_ucd_metrics(
snmp_walk(OID_IF_OUT_OCT),
snmp_walk(OID_IF_IN_ERR),
snmp_walk(OID_IF_OUT_ERR),
snmp_walk(OID_IF_HC_IN_OCT),
snmp_walk(OID_IF_HC_OUT_OCT),
snmp_walk(OID_IF_HIGH_SPEED),
),
)
metrics: dict = {}
metrics: dict[str, Any] = {}
secs = ticks_to_seconds(uptime_raw)
if secs is not None:
@@ -131,14 +169,14 @@ async def collect_ucd_metrics(
if mem_total_raw and mem_free_raw:
try:
total_kb = int(mem_total_raw)
free_kb = int(mem_free_raw)
buf_kb = int(mem_buf_raw) if mem_buf_raw else 0
free_kb = int(mem_free_raw)
buf_kb = int(mem_buf_raw) if mem_buf_raw else 0
cache_kb = int(mem_cache_raw) if mem_cache_raw else 0
used_kb = max(0, total_kb - free_kb - buf_kb - cache_kb)
used_kb = max(0, total_kb - free_kb - buf_kb - cache_kb)
metrics["memory_total_bytes"] = total_kb * 1024
metrics["memory_used_bytes"] = used_kb * 1024
metrics["memory_percent"] = round(used_kb / total_kb * 100, 1) if total_kb else 0.0
metrics["memory_free_bytes"] = free_kb * 1024
metrics["memory_used_bytes"] = used_kb * 1024
metrics["memory_percent"] = round(used_kb / total_kb * 100, 1) if total_kb else 0.0
metrics["memory_free_bytes"] = free_kb * 1024
except ValueError:
pass
@@ -146,10 +184,10 @@ async def collect_ucd_metrics(
try:
stotal = int(swap_total_raw)
savail = int(swap_avail_raw)
sused = stotal - savail
sused = stotal - savail
metrics["swap_total_bytes"] = stotal * 1024
metrics["swap_used_bytes"] = sused * 1024
metrics["swap_percent"] = round(sused / stotal * 100, 1) if stotal else 0.0
metrics["swap_used_bytes"] = sused * 1024
metrics["swap_percent"] = round(sused / stotal * 100, 1) if stotal else 0.0
except ValueError:
pass
@@ -160,6 +198,18 @@ async def collect_ucd_metrics(
except ValueError:
pass
build_if_metrics(metrics, descr, speed, in_oct, out_oct, in_err, out_err,
if_skip=if_skip, tx_err_is_drop=tx_err_is_drop)
build_if_metrics(
metrics,
descr,
speed,
in_oct,
out_oct,
in_err,
out_err,
if_skip=if_skip,
tx_err_is_drop=tx_err_is_drop,
hc_in_oct=hc_in_oct,
hc_out_oct=hc_out_oct,
high_speed=high_speed,
)
return metrics
+22 -28
View File
@@ -10,14 +10,12 @@ Usage::
...
"""
from typing import Any, Dict, List, TYPE_CHECKING
from typing import TYPE_CHECKING
from napalm_device_types.base import DeviceTypeDriver
from napalm_device_types.mac_acl import MacAclMixin
from napalm_device_types.updates import UpdateMixin
from napalm_device_types.services import ServiceControlMixin
from napalm_device_types.packages import PackageManagementMixin
from napalm_device_types.health_metrics import HealthMetricsMixin
from napalm_device_types.interface_filter import InterfaceFilterMixin
from napalm_device_types.mac_acl import MacAclMixin
from napalm_device_types.models import (
Dot1XConfigDict,
FastTransitionConfigDict,
@@ -26,9 +24,20 @@ from napalm_device_types.models import (
SSIDBridgeDict,
WirelessConfigDict,
)
from napalm_device_types.packages import PackageManagementMixin
from napalm_device_types.services import ServiceControlMixin
from napalm_device_types.updates import UpdateMixin
class AccessPointDriver(MacAclMixin, UpdateMixin, ServiceControlMixin, PackageManagementMixin, HealthMetricsMixin, InterfaceFilterMixin, DeviceTypeDriver):
class AccessPointDriver(
MacAclMixin,
UpdateMixin,
ServiceControlMixin,
PackageManagementMixin,
HealthMetricsMixin,
InterfaceFilterMixin,
DeviceTypeDriver,
):
"""
Abstract intermediate driver for wireless access points.
@@ -42,11 +51,6 @@ class AccessPointDriver(MacAclMixin, UpdateMixin, ServiceControlMixin, PackageMa
ROLE: str = "access_point"
TYPE_LABEL: str = "Access Point"
# AP-specific methods (get_wireless_clients, get_ssids, get_radio_status,
# get_interfaces, get_vlans, …) are intentionally NOT defined here.
# AccessPointDriver sits AFTER the mixin classes in the MRO of concrete
@@ -86,7 +90,7 @@ class AccessPointDriver(MacAclMixin, UpdateMixin, ServiceControlMixin, PackageMa
"""
...
def get_fast_transition_config(self) -> Dict[str, FastTransitionConfigDict]:
def get_fast_transition_config(self) -> dict[str, FastTransitionConfigDict]:
"""
Returns the 802.11r Fast BSS Transition (FT) configuration per SSID.
@@ -118,7 +122,7 @@ class AccessPointDriver(MacAclMixin, UpdateMixin, ServiceControlMixin, PackageMa
"""
...
def get_mesh_config(self) -> Dict[str, MeshConfigDict]:
def get_mesh_config(self) -> dict[str, MeshConfigDict]:
"""
Returns the 802.11s mesh configuration per mesh interface.
@@ -148,7 +152,7 @@ class AccessPointDriver(MacAclMixin, UpdateMixin, ServiceControlMixin, PackageMa
"""
...
def get_mesh_peers(self) -> List[MeshPeerDict]:
def get_mesh_peers(self) -> list[MeshPeerDict]:
"""
Returns a list of currently active 802.11s mesh peers.
@@ -178,7 +182,7 @@ class AccessPointDriver(MacAclMixin, UpdateMixin, ServiceControlMixin, PackageMa
"""
...
def get_ssid_bridge_config(self) -> Dict[str, SSIDBridgeDict]:
def get_ssid_bridge_config(self) -> dict[str, SSIDBridgeDict]:
"""
Returns the Layer-2 bridging configuration for each SSID, i.e. which
bridge interface and VLAN each SSID is mapped to.
@@ -186,7 +190,8 @@ class AccessPointDriver(MacAclMixin, UpdateMixin, ServiceControlMixin, PackageMa
Keys are SSID names. Each value contains:
* ssid (string) - SSID name (repeated for convenience)
* bridge (string) - bridge interface the VAP is attached to (e.g. ``"br-lan"``, ``"br-guest"``)
* bridge (string) - bridge interface the VAP is attached to
(e.g. ``"br-lan"``, ``"br-guest"``)
* vlan_id (int) - 802.1Q VLAN ID (0 = untagged / no VLAN separation)
* tagged (bool) - whether traffic is 802.1Q-tagged on the uplink port
* client_isolation (bool) - whether clients on this SSID are isolated from each other
@@ -219,9 +224,7 @@ class AccessPointDriver(MacAclMixin, UpdateMixin, ServiceControlMixin, PackageMa
"""
...
def get_dot1x_config(self) -> Dict[str, Dot1XConfigDict]:
def get_dot1x_config(self) -> dict[str, Dot1XConfigDict]:
"""
Returns the 802.1X / WPA-Enterprise (RADIUS) configuration per SSID.
@@ -268,12 +271,3 @@ class AccessPointDriver(MacAclMixin, UpdateMixin, ServiceControlMixin, PackageMa
}
"""
...
+26 -1
View File
@@ -8,6 +8,7 @@ live connection.
from __future__ import annotations
from collections.abc import Iterable
from typing import NamedTuple
from napalm.base import NetworkDriver
@@ -24,12 +25,36 @@ class FingerprintRule(NamedTuple):
weight — evidence added to the driver's score on match
mandatory — pattern absent → driver immediately disqualified
negative — pattern present → weight subtracted instead of added
group — rules sharing a group are alternatives that never match
together (one vendor's landing page *or* another's); only the
strongest of them counts towards the most a driver can score
"""
pattern: str
weight: float = 1.0
mandatory: bool = False
negative: bool = False
group: str | None = None
def rules_max_weight(rules: Iterable[FingerprintRule]) -> float:
"""The most evidence *rules* can add together.
Every positive rule counts, except that a group of alternatives counts once,
with its strongest rule. Discovery divides a driver's score by its maximum to
compare drivers; summing alternatives would make a driver that recognises
three vendors' pages lose to one that recognises none.
"""
total = 0.0
group_best: dict[str, float] = {}
for rule in rules:
if rule.negative or rule.weight <= 0:
continue
if rule.group is None:
total += rule.weight
else:
group_best[rule.group] = max(group_best.get(rule.group, 0.0), rule.weight)
return total + sum(group_best.values())
class PortSpec(NamedTuple):
@@ -49,7 +74,7 @@ class PortSpec(NamedTuple):
mandatory: bool = False
class DeviceTypeDriver(PingSweepMixin, HostRebootMixin, CommandChannelMixin, NetworkDriver):
class DeviceTypeDriver(PingSweepMixin, HostRebootMixin, CommandChannelMixin, NetworkDriver): # type: ignore[misc]
"""Common base for all netOrk device-type drivers.
Sits between napalm.base.NetworkDriver and the type-specific abstract
+23 -15
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""The command channel: a public way to run a command on a host, or open a stream.
A driver already reaches its host, over SSH, a REST API or WinRM. What it did
@@ -26,9 +25,8 @@ stays with the driver.
from __future__ import annotations
import socket
import time
from typing import Any, NamedTuple, Optional, Protocol, TYPE_CHECKING
from typing import TYPE_CHECKING, Any, NamedTuple, Protocol, cast
#: How much of a stream's stderr is kept. Enough to tell "permission denied"
#: from "no such command"; a stream that writes megabytes there keeps its tail.
@@ -56,7 +54,7 @@ class ByteStream(Protocol):
``close_write`` closes the writing side.
"""
def read(self, max_bytes: int, timeout: Optional[float] = None) -> bytes: ...
def read(self, max_bytes: int, timeout: float | None = None) -> bytes: ...
def write(self, data: bytes) -> None: ...
@@ -65,7 +63,7 @@ class ByteStream(Protocol):
def close(self) -> None: ...
@property
def exit_status(self) -> Optional[int]: ...
def exit_status(self) -> int | None: ...
@property
def stderr(self) -> str: ...
@@ -82,7 +80,7 @@ class CommandChannelMixin:
*,
privileged: bool = False,
timeout: float = 60,
stdin: Optional[bytes] = None,
stdin: bytes | None = None,
) -> CommandResult:
"""Run *command* with the host's shell; *stdin*, if given, is written first."""
...
@@ -108,11 +106,11 @@ class ParamikoExecStream:
while self._channel.recv_stderr_ready():
self._stderr = (self._stderr + self._channel.recv_stderr(_READ))[-self._limit :]
def read(self, max_bytes: int, timeout: Optional[float] = None) -> bytes:
def read(self, max_bytes: int, timeout: float | None = None) -> bytes:
self._channel.settimeout(timeout)
try:
return self._channel.recv(max_bytes)
except socket.timeout:
return cast(bytes, self._channel.recv(max_bytes))
except TimeoutError:
raise TimeoutError(f"no data within {timeout}s") from None
finally:
self._drain_stderr()
@@ -127,10 +125,10 @@ class ParamikoExecStream:
self._channel.close()
@property
def exit_status(self) -> Optional[int]:
def exit_status(self) -> int | None:
if not self._channel.exit_status_ready():
return None
return self._channel.recv_exit_status()
return cast(int, self._channel.recv_exit_status())
@property
def stderr(self) -> str:
@@ -138,7 +136,7 @@ class ParamikoExecStream:
return self._stderr.decode("utf-8", "replace")
def _collect(channel: Any, command: str, timeout: float) -> tuple:
def _collect(channel: Any, command: str, timeout: float) -> tuple[bytes, bytes]:
out, err = b"", b""
deadline = time.monotonic() + timeout
while True:
@@ -147,6 +145,14 @@ def _collect(channel: Any, command: str, timeout: float) -> tuple:
elif channel.recv_stderr_ready():
err += channel.recv_stderr(_READ)
elif channel.exit_status_ready():
# sshd may send the exit status before the last output: read both to EOF.
try:
while chunk := channel.recv(_READ):
out += chunk
while chunk := channel.recv_stderr(_READ):
err += chunk
except TimeoutError:
raise TimeoutError(f"{command!r} did not finish within {timeout}s") from None
return out, err
elif time.monotonic() > deadline:
raise TimeoutError(f"{command!r} did not finish within {timeout}s")
@@ -155,7 +161,7 @@ def _collect(channel: Any, command: str, timeout: float) -> tuple:
def run_on_transport(
transport: Any, command: str, *, stdin: Optional[bytes] = None, timeout: float = 60
transport: Any, command: str, *, stdin: bytes | None = None, timeout: float = 60
) -> CommandResult:
"""Run *command* on an exec channel of the paramiko *transport*."""
channel = transport.open_session()
@@ -167,14 +173,16 @@ def run_on_transport(
channel.shutdown_write()
out, err = _collect(channel, command, timeout)
return CommandResult(
out.decode("utf-8", "replace"), err.decode("utf-8", "replace"), channel.recv_exit_status()
out.decode("utf-8", "replace"),
err.decode("utf-8", "replace"),
channel.recv_exit_status(),
)
finally:
channel.close()
def open_stream_on_transport(
transport: Any, command: str, *, stdin_prefix: Optional[bytes] = None
transport: Any, command: str, *, stdin_prefix: bytes | None = None
) -> ParamikoExecStream:
"""Start *command* on an exec channel and return its stream.
+4 -9
View File
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import difflib
from typing import ClassVar, TYPE_CHECKING
from typing import TYPE_CHECKING, ClassVar
from napalm.base.exceptions import (
CommandErrorException,
@@ -41,15 +40,11 @@ class ConfigLifecycleMixin:
if TYPE_CHECKING:
def _get_running_config(self) -> str:
...
def _get_running_config(self) -> str: ...
def commit_config(self, message: str = "", revert_in: int | None = None) -> None:
...
def commit_config(self, message: str = "", revert_in: int | None = None) -> None: ...
def load_merge_candidate(
self, filename: str | None = None, config: str | None = None
) -> None:
def load_merge_candidate(self, filename: str | None = None, config: str | None = None) -> None:
if filename is not None:
try:
with open(filename) as fh:
+34
View File
@@ -0,0 +1,34 @@
"""Configuration sync between the two hosts of an HA pair.
A host that pushes its configuration to a peer (OPNsense's XMLRPC sync, say)
makes the peer's configuration of the synced services a copy of its own: a
change written on the receiving side lasts only until the next push. Who pushes
what is therefore a fact a caller needs before it writes to either host.
Declared under ``if TYPE_CHECKING``: a contract, not a placeholder. A driver
mixes it in; no role promises it.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from napalm_device_types.models import ConfigSyncDict
class ConfigSyncMixin:
"""Where a host pushes its configuration, and for which of its services."""
if TYPE_CHECKING:
def get_config_sync(self) -> ConfigSyncDict:
"""
Returns the peer this host pushes its configuration to (None when it
pushes to no one) and the services whose configuration it pushes, by
the name ``get_services`` reports. Sync items the driver has no
service name for are listed under ``unmapped`` as the host names them.
:raises Exception: When the setting could not be read -- never
``peer=None`` for "could not ask".
"""
...
+25 -13
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Access to a host's container engine, and nothing more.
This package abstracts the connection, the driver builds it, and netOrk does
@@ -29,13 +28,14 @@ stays truthful.
from __future__ import annotations
import shlex
from typing import Any, Dict, List, Optional, Sequence, TYPE_CHECKING
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, cast
from napalm_device_types.channel import ByteStream, CommandResult
from napalm_device_types.models import ContainerEngineDict
from napalm_device_types.models import ContainerEngineDict, FixResultDict
#: Engines this package knows how to reach, and the API each speaks.
ENGINES: Dict[str, str] = {"docker": "docker-engine"}
ENGINES: dict[str, str] = {"docker": "docker-engine"}
_PROBE_TIMEOUT = 15
_CLI_TIMEOUT = 120
@@ -62,13 +62,13 @@ class ContainerEngineConnection:
def open_api(self) -> ByteStream:
"""A stream to the engine's API: HTTP/1.1 over ``system dial-stdio``."""
return self._driver.open_stream(self._command(["system", "dial-stdio"]))
return cast(ByteStream, self._driver.open_stream(self._command(["system", "dial-stdio"])))
def run_cli(
self,
args: Sequence[str],
*,
stdin: Optional[bytes] = None,
stdin: bytes | None = None,
timeout: float = _CLI_TIMEOUT,
privileged: bool = False,
) -> CommandResult:
@@ -77,8 +77,11 @@ class ContainerEngineConnection:
*privileged* runs it as root, the way the driver gains root for any
command: for a call the engine refused to the login user.
"""
return self._driver.run_command(
self._command(args), privileged=privileged, timeout=timeout, stdin=stdin
return cast(
CommandResult,
self._driver.run_command(
self._command(args), privileged=privileged, timeout=timeout, stdin=stdin
),
)
def stream_cli(
@@ -90,8 +93,11 @@ class ContainerEngineConnection:
progress there (``compose up``); *privileged* as for :meth:`run_cli`.
"""
command = self._command(args)
return self._driver.open_stream(
f"{command} 2>&1" if merge_stderr else command, privileged=privileged
return cast(
ByteStream,
self._driver.open_stream(
f"{command} 2>&1" if merge_stderr else command, privileged=privileged
),
)
@@ -106,16 +112,22 @@ class ContainerEngineMixin:
*,
privileged: bool = False,
timeout: float = 60,
stdin: Optional[bytes] = None,
stdin: bytes | None = None,
) -> CommandResult: ...
def open_stream(self, command: str, *, privileged: bool = False) -> ByteStream: ...
def grant_container_engine_access(self, user: str) -> FixResultDict:
"""Let *user* reach the container engine without privilege (the
``docker`` group on most hosts). A fix template: see
:mod:`napalm_device_types.fixes`. Declared only; a driver implements it."""
...
def _container_engine_binary(self, engine: str) -> str:
"""The host command that is *engine*'s CLI. Override where it is not on PATH."""
return engine
def container_engines(self) -> List[ContainerEngineDict]:
def container_engines(self) -> list[ContainerEngineDict]:
"""
Returns the container engines the host offers:
@@ -126,7 +138,7 @@ class ContainerEngineMixin:
user may use it is decided by the first API call, which says
"permission denied" in the stream's stderr.
"""
found: List[ContainerEngineDict] = []
found: list[ContainerEngineDict] = []
for engine, api in ENGINES.items():
probe = shlex.join(["command", "-v", self._container_engine_binary(engine)])
result = self.run_command(probe, timeout=_PROBE_TIMEOUT)
+65 -51
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""DHCP desired-state management, shared by firewalls and gateways.
Covers two independent desired-state sets:
@@ -24,14 +23,17 @@ takes DHCP down for an entire VLAN.
from __future__ import annotations
import re
from typing import Any, Dict, Iterator, List, Optional, TYPE_CHECKING
from collections.abc import Iterator
from typing import TYPE_CHECKING, Any
from napalm_device_types._matching import check_desired, index_live
from napalm_device_types.models import (
DhcpReservationDiffDict,
DhcpHaDict,
DhcpReservationDict,
DhcpReservationDiffDict,
DhcpReservationUpdateDict,
DhcpSubnetDiffDict,
DhcpSubnetDict,
DhcpSubnetDiffDict,
DhcpSubnetUpdateDict,
)
@@ -56,7 +58,7 @@ _DHCP_SUBNET_COMPARE_FIELDS = (
_HEX_ONLY = re.compile(r"[^0-9a-f]")
def normalize_mac(mac: Optional[str]) -> str:
def normalize_mac(mac: str | None) -> str:
"""Reduces a MAC address to lowercase colon-separated form.
Devices report MACs in whatever form their config store happens to use --
@@ -78,7 +80,7 @@ def normalize_mac(mac: Optional[str]) -> str:
return ":".join(hex_digits[i : i + 2] for i in range(0, 12, 2))
def normalize_cidr(cidr: Optional[str]) -> str:
def normalize_cidr(cidr: str | None) -> str:
"""Reduces a subnet CIDR to a canonical string for matching.
Only whitespace and case are normalised -- deliberately not the network
@@ -89,9 +91,7 @@ def normalize_cidr(cidr: Optional[str]) -> str:
return (cidr or "").strip().lower()
def _subnet_field_differs(
field: str, live: DhcpSubnetDict, desired: DhcpSubnetDict
) -> bool:
def _subnet_field_differs(field: str, live: DhcpSubnetDict, desired: DhcpSubnetDict) -> bool:
"""Compares one subnet field, with `option_data` handled specially.
For `option_data` only the options `desired` actually names are compared;
@@ -103,9 +103,7 @@ def _subnet_field_differs(
desired_options = desired.get("option_data") or {}
live_options = live.get("option_data") or {}
return any(
live_options.get(option) != value for option, value in desired_options.items()
)
return any(live_options.get(option) != value for option, value in desired_options.items())
class DhcpServerMixin:
@@ -123,7 +121,7 @@ class DhcpServerMixin:
if TYPE_CHECKING:
def get_dhcp_reservations(self) -> List[DhcpReservationDict]:
def get_dhcp_reservations(self) -> list[DhcpReservationDict]:
"""
Returns all static DHCP reservations currently configured on the
device, across all subnets.
@@ -137,8 +135,8 @@ class DhcpServerMixin:
...
def apply_dhcp_reservation(
self, reservation: DhcpReservationDict, *, uuid: Optional[str] = None
) -> Dict[str, Any]:
self, reservation: DhcpReservationDict, *, uuid: str | None = None
) -> dict[str, Any]:
"""
Creates or updates a single static DHCP reservation on the device.
@@ -155,7 +153,7 @@ class DhcpServerMixin:
"""
...
def get_dhcp_subnets(self) -> List[DhcpSubnetDict]:
def get_dhcp_subnets(self) -> list[DhcpSubnetDict]:
"""
Returns every DHCPv4 subnet the device serves, with its pools and
per-subnet options.
@@ -166,8 +164,8 @@ class DhcpServerMixin:
...
def apply_dhcp_subnet(
self, subnet: DhcpSubnetDict, *, uuid: Optional[str] = None
) -> Dict[str, Any]:
self, subnet: DhcpSubnetDict, *, uuid: str | None = None
) -> dict[str, Any]:
"""
Creates or updates a single DHCPv4 subnet on the device.
@@ -187,7 +185,7 @@ class DhcpServerMixin:
"""
...
def commit_dhcp_subnets(self) -> Dict[str, Any]:
def commit_dhcp_subnets(self) -> dict[str, Any]:
"""
Applies pending subnet changes (e.g. Kea's ``service/reconfigure``).
@@ -202,7 +200,7 @@ class DhcpServerMixin:
"""
...
def commit_dhcp_reservations(self) -> Dict[str, Any]:
def commit_dhcp_reservations(self) -> dict[str, Any]:
"""
Applies pending reservation changes (e.g. Kea's ``service/reconfigure``,
or a dnsmasq reload).
@@ -218,13 +216,25 @@ class DhcpServerMixin:
"""
...
def get_dhcp_ha(self) -> DhcpHaDict:
"""
Returns what the server's own HA configuration declares: whether HA
is switched on, which peer this server is, and each peer with its
role as the server words it.
Optional: a driver without it has no ``get_dhcp_ha`` at all, and a
caller asks ``callable(getattr(driver, "get_dhcp_ha", None))``.
:raises Exception: When the configuration could not be read -- never
``enabled=False`` for "could not ask".
"""
...
# ------------------------------------------------------------------
# Generic, vendor-neutral algorithms.
# ------------------------------------------------------------------
def diff_dhcp_reservations(
self, desired: List[DhcpReservationDict]
) -> DhcpReservationDiffDict:
def diff_dhcp_reservations(self, desired: list[DhcpReservationDict]) -> DhcpReservationDiffDict:
"""
Compares `desired` against the device's current reservations and
returns what would need to change to reach that state.
@@ -238,20 +248,27 @@ class DhcpServerMixin:
those apart from ones simply no longer wanted. Callers wanting
delete/cleanup semantics must implement that themselves, deliberately.
Live reservations without a MAC are never matched. A desired one
without a MAC, a MAC given twice, or one more than one live
reservation holds raise ``ValueError``.
:param desired: The complete desired reservation set.
:returns: ``{"add": [...], "update": [{"uuid", "reservation",
"changed_fields"}, ...]}``.
:raises ValueError: When a desired reservation cannot name a single live one.
"""
live_by_mac: Dict[str, DhcpReservationDict] = {
normalize_mac(reservation.get("mac")): reservation
for reservation in self.get_dhcp_reservations()
}
add: List[DhcpReservationDict] = []
update: List[DhcpReservationUpdateDict] = []
def key(reservation: DhcpReservationDict) -> str:
return normalize_mac(reservation.get("mac"))
live_by_mac, shared = index_live(self.get_dhcp_reservations(), key)
check_desired(desired, key, shared, "MAC")
add: list[DhcpReservationDict] = []
update: list[DhcpReservationUpdateDict] = []
for desired_reservation in desired:
live = live_by_mac.get(normalize_mac(desired_reservation.get("mac")))
live = live_by_mac.get(key(desired_reservation))
if live is None:
add.append(desired_reservation)
continue
@@ -272,9 +289,7 @@ class DhcpServerMixin:
return {"add": add, "update": update}
def apply_dhcp_reservationset(
self, desired: List[DhcpReservationDict]
) -> Iterator[str]:
def apply_dhcp_reservationset(self, desired: list[DhcpReservationDict]) -> Iterator[str]:
"""
Computes the diff against `desired` and applies it, yielding one
human-readable progress line per change, then commits.
@@ -297,8 +312,7 @@ class DhcpServerMixin:
self.apply_dhcp_reservation(entry["reservation"], uuid=entry["uuid"])
fields = ", ".join(entry["changed_fields"])
yield (
f"[update] {entry['reservation']['mac']} -> "
f"{entry['reservation']['ip']} ({fields})"
f"[update] {entry['reservation']['mac']} -> {entry['reservation']['ip']} ({fields})"
)
if not diff["add"] and not diff["update"]:
@@ -306,12 +320,9 @@ class DhcpServerMixin:
return
self.commit_dhcp_reservations()
yield (
f"[commit] applied {len(diff['add'])} add(s), "
f"{len(diff['update'])} update(s)"
)
yield (f"[commit] applied {len(diff['add'])} add(s), {len(diff['update'])} update(s)")
def diff_dhcp_subnets(self, desired: List[DhcpSubnetDict]) -> DhcpSubnetDiffDict:
def diff_dhcp_subnets(self, desired: list[DhcpSubnetDict]) -> DhcpSubnetDiffDict:
"""
Compares `desired` against the device's current subnets and returns
what would need to change to reach that state.
@@ -333,19 +344,25 @@ class DhcpServerMixin:
subnet takes DHCP down for a whole VLAN, and this method cannot tell
"no longer wanted" from "was never netOrk's to describe".
An empty or repeated subnet raises ``ValueError``, as for reservations.
:param desired: The complete desired subnet set.
:returns: ``{"add": [...], "update": [{"uuid", "subnet",
"changed_fields"}, ...]}``.
:raises ValueError: When a desired subnet cannot name a single live one.
"""
live_by_cidr: Dict[str, DhcpSubnetDict] = {
normalize_cidr(live.get("subnet")): live for live in self.get_dhcp_subnets()
}
add: List[DhcpSubnetDict] = []
update: List[DhcpSubnetUpdateDict] = []
def key(subnet: DhcpSubnetDict) -> str:
return normalize_cidr(subnet.get("subnet"))
live_by_cidr, shared = index_live(self.get_dhcp_subnets(), key)
check_desired(desired, key, shared, "subnet")
add: list[DhcpSubnetDict] = []
update: list[DhcpSubnetUpdateDict] = []
for desired_subnet in desired:
live = live_by_cidr.get(normalize_cidr(desired_subnet.get("subnet")))
live = live_by_cidr.get(key(desired_subnet))
if live is None:
add.append(desired_subnet)
continue
@@ -366,7 +383,7 @@ class DhcpServerMixin:
return {"add": add, "update": update}
def apply_dhcp_subnetset(self, desired: List[DhcpSubnetDict]) -> Iterator[str]:
def apply_dhcp_subnetset(self, desired: list[DhcpSubnetDict]) -> Iterator[str]:
"""
Computes the diff against `desired` and applies it, yielding one
human-readable progress line per change, then commits.
@@ -394,7 +411,4 @@ class DhcpServerMixin:
return
self.commit_dhcp_subnets()
yield (
f"[commit] applied {len(diff['add'])} add(s), "
f"{len(diff['update'])} update(s)"
)
yield (f"[commit] applied {len(diff['add'])} add(s), {len(diff['update'])} update(s)")
+34
View File
@@ -0,0 +1,34 @@
"""Domains a device's DNS resolver forwards to another server.
An application that makes the device's resolver ask another server for a zone
-- the authority for an internal domain -- edits these. The driver writes them
in its device's terms and applies them; the forwards are the caller's.
Declared only; a driver whose device resolves DNS implements it.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from napalm_device_types.models import DnsForwardDict
class DnsForwardMixin:
if TYPE_CHECKING: # pragma: no cover - declared for type checkers only
def get_dns_forwards(self) -> list[DnsForwardDict]:
"""Every domain the resolver forwards, enabled or not."""
...
def set_dns_forward(self, domain: str, server: str, port: int = 53) -> None:
"""Forward *domain* to *server*: added, or the existing forward of
*domain* changed. Applied before it returns.
:raises RuntimeError: when the device refuses it.
"""
...
def remove_dns_forward(self, domain: str) -> None:
"""Stop forwarding *domain*; nothing to do when it is not forwarded.
Applied before it returns."""
...
+16 -22
View File
@@ -10,21 +10,28 @@ Usage::
...
"""
from typing import Any, ClassVar, Dict, Iterator, List, Optional, TYPE_CHECKING
from typing import TYPE_CHECKING, Any, ClassVar
from napalm_device_types.base import DeviceTypeDriver
from napalm_device_types.nat_vpn import NatVpnMixin
from napalm_device_types.packages import PackageManagementMixin
from napalm_device_types.health_metrics import HealthMetricsMixin
from napalm_device_types.dhcp import DhcpServerMixin
from napalm_device_types.firewall_rules import FirewallRuleMixin
from napalm_device_types.health_metrics import HealthMetricsMixin
from napalm_device_types.models import (
SecurityZoneDict,
SessionDict,
)
from napalm_device_types.nat_vpn import NatVpnMixin
from napalm_device_types.packages import PackageManagementMixin
class FirewallDriver(NatVpnMixin, PackageManagementMixin, HealthMetricsMixin, FirewallRuleMixin, DhcpServerMixin, DeviceTypeDriver):
class FirewallDriver(
NatVpnMixin,
PackageManagementMixin,
HealthMetricsMixin,
FirewallRuleMixin,
DhcpServerMixin,
DeviceTypeDriver,
):
"""
Abstract intermediate driver for firewall/security devices.
@@ -42,11 +49,9 @@ class FirewallDriver(NatVpnMixin, PackageManagementMixin, HealthMetricsMixin, Fi
# OPNsense reports drops in the out-error counter.
_SNMP_TX_ERR_IS_DROP: ClassVar[bool] = True
if TYPE_CHECKING:
def get_security_zones(self) -> Dict[str, SecurityZoneDict]:
def get_security_zones(self) -> dict[str, SecurityZoneDict]:
"""
Returns the security zone configuration.
@@ -73,7 +78,7 @@ class FirewallDriver(NatVpnMixin, PackageManagementMixin, HealthMetricsMixin, Fi
"""
...
def get_sessions(self) -> List[SessionDict]:
def get_sessions(self) -> list[SessionDict]:
"""
Returns a list of active connection sessions (stateful flows).
@@ -103,8 +108,7 @@ class FirewallDriver(NatVpnMixin, PackageManagementMixin, HealthMetricsMixin, Fi
"""
...
def send_wake_on_lan(self, mac_address: str, interface: str = "") -> Dict[str, Any]:
def send_wake_on_lan(self, mac_address: str, interface: str = "") -> dict[str, Any]:
"""
Sends a Wake-on-LAN "magic packet" to wake a host on the network.
@@ -142,19 +146,9 @@ class FirewallDriver(NatVpnMixin, PackageManagementMixin, HealthMetricsMixin, Fi
"""
...
# ------------------------------------------------------------------
# Firewall rule diff/apply. get_firewall_rules/apply_firewall_rule/
# commit_firewall_rules are abstract (device communication); everything
# else here is a concrete, vendor-neutral algorithm -- see README.md
# "Design principle: generic vs. device-specific logic".
# ------------------------------------------------------------------
+27 -14
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Firewall rule reconciliation: generic here, device communication in the driver.
The split follows the rule in this package's README: matching, diffing and the
@@ -14,8 +13,10 @@ working implementation inherited from elsewhere.
from __future__ import annotations
from typing import Any, Dict, Iterator, List, Optional, TYPE_CHECKING
from collections.abc import Iterator
from typing import TYPE_CHECKING, Any
from napalm_device_types._matching import check_desired, index_live
from napalm_device_types.models import (
FirewallRuleDict,
FirewallRuleDiffDict,
@@ -42,7 +43,7 @@ class FirewallRuleMixin:
if TYPE_CHECKING:
def get_firewall_rules(self) -> List[FirewallRuleDict]:
def get_firewall_rules(self) -> list[FirewallRuleDict]:
"""
Returns all firewall filter rules currently configured on the device.
@@ -54,9 +55,10 @@ class FirewallRuleMixin:
firewall rules.
"""
...
def apply_firewall_rule(
self, rule: FirewallRuleDict, *, uuid: Optional[str] = None
) -> Dict[str, Any]:
self, rule: FirewallRuleDict, *, uuid: str | None = None
) -> dict[str, Any]:
"""
Creates or updates a single firewall filter rule on the device.
@@ -72,7 +74,8 @@ class FirewallRuleMixin:
:returns: A dict with at least ``{"success": bool}``.
"""
...
def commit_firewall_rules(self) -> Dict[str, Any]:
def commit_firewall_rules(self) -> dict[str, Any]:
"""
Applies pending firewall filter rule changes (e.g. reloads pf/pfctl,
or whatever the device's equivalent of "Apply Changes" is).
@@ -87,7 +90,7 @@ class FirewallRuleMixin:
"""
...
def diff_firewall_rules(self, desired: List[FirewallRuleDict]) -> FirewallRuleDiffDict:
def diff_firewall_rules(self, desired: list[FirewallRuleDict]) -> FirewallRuleDiffDict:
"""
Compares `desired` against the device's current rules and returns
what would need to change to reach that state.
@@ -102,19 +105,28 @@ class FirewallRuleMixin:
from ones simply no longer wanted. Callers wanting delete/cleanup
semantics must implement that themselves, deliberately.
Live rules without a description are never matched. A desired rule
without one, two desired rules with the same one, or one whose
description more than one live rule holds raise ``ValueError``: any
guess would overwrite a rule the caller did not mean.
:param desired: The complete desired rule set.
:returns: ``{"add": [...], "update": [{"uuid", "rule",
"changed_fields"}, ...]}``.
:raises ValueError: When a desired rule cannot name a single live one.
"""
live_by_description: Dict[str, FirewallRuleDict] = {
rule["description"]: rule for rule in self.get_firewall_rules()
}
add: List[FirewallRuleDict] = []
update: List[FirewallRuleUpdateDict] = []
def key(rule: FirewallRuleDict) -> str:
return (rule.get("description") or "").strip()
live_by_description, shared = index_live(self.get_firewall_rules(), key)
check_desired(desired, key, shared, "description")
add: list[FirewallRuleDict] = []
update: list[FirewallRuleUpdateDict] = []
for desired_rule in desired:
live_rule = live_by_description.get(desired_rule["description"])
live_rule = live_by_description.get(key(desired_rule))
if live_rule is None:
add.append(desired_rule)
continue
@@ -134,7 +146,8 @@ class FirewallRuleMixin:
)
return {"add": add, "update": update}
def apply_firewall_ruleset(self, desired: List[FirewallRuleDict]) -> Iterator[str]:
def apply_firewall_ruleset(self, desired: list[FirewallRuleDict]) -> Iterator[str]:
"""
Computes the diff against `desired` and applies it, yielding one
human-readable progress line per change, then commits.
+31
View File
@@ -0,0 +1,31 @@
"""The firmware a vendor offers for a device.
A fact for the caller (docs/STRUCTURE.md): a driver may know the newer firmware
from the vendor's download catalogue or the device's own update check; whether
running an older one is a problem is decided above the driver. Comparing the
vendor's version strings is vendor knowledge, so the driver says itself whether
the offer is newer. Declared only; a driver that can tell implements it.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from napalm_device_types.models import FirmwareOfferDict
class FirmwareOfferMixin:
if TYPE_CHECKING: # pragma: no cover - declared for type checkers only
def get_firmware_offer(self) -> FirmwareOfferDict | None:
"""The firmware running and the newest the vendor offers.
None when the driver cannot tell for this device (an unknown model,
a catalogue that did not answer).
Example::
{"current_version": "1.0.2.10", "latest_version": "1.0.2.12",
"newer": True, "download_url": "https://.../GS308E_1.0.2.12.zip"}
"""
...
+158
View File
@@ -0,0 +1,158 @@
"""Fix routines: changes an application wants made on a device, as templates.
A template fixes the order of the steps; the driver knows how each step is done on
its device; the caller supplies every value -- the community, the allowed sources,
the proxy URL, the user. A driver whose device takes the whole change in one call
(a REST API) overrides the template method instead of its steps.
None of these is mixed into a role base. A driver mixes in the fixes its device can
take, so ``callable(getattr(driver, "configure_snmp_agent", None))`` stays a truthful
answer; a driver that inherits one it cannot run sets it to ``None``.
They replace ``run_device_action`` (docs/STRUCTURE.md):
========================== ===================================================
``fix_snmp`` :meth:`SnmpAgentMixin.configure_snmp_agent`
``fix_apt_proxy`` :meth:`PackageProxyMixin.configure_package_proxy`
``fix_docker_permissions`` ``ContainerEngineMixin.grant_container_engine_access``
``install_lldpd`` and co. :meth:`EnsurePackageMixin.ensure_package`
========================== ===================================================
"""
from __future__ import annotations
from collections.abc import Callable, Sequence
from typing import TYPE_CHECKING, Any
from napalm_device_types.models import (
FixResultDict,
FixStepDict,
FixStepOutcomeDict,
PackageDict,
SnmpAgentSettingsDict,
)
#: One step of a fix: its name and the call that does it.
FixStep = tuple[str, Callable[[], FixStepOutcomeDict]]
def run_fix_steps(steps: Sequence[FixStep]) -> FixResultDict:
"""Run *steps* in order and stop at the first that fails.
A step that raises has failed, with the exception's message as its output.
"""
done: list[FixStepDict] = []
for name, step in steps:
try:
outcome = step()
ok, output = bool(outcome["ok"]), str(outcome.get("output", ""))
except Exception as exc: # noqa: BLE001 - any failure of a step ends the fix
ok, output = False, str(exc)
done.append({"step": name, "ok": ok, "output": output})
if not ok:
break
return {
"success": bool(done) and all(s["ok"] for s in done) and len(done) == len(steps),
"steps": done,
"output": "\n".join(f"[{s['step']}] {s['output']}".rstrip() for s in done),
}
class SnmpAgentMixin:
"""Run an SNMP agent on the device that answers the caller.
The steps: install the agent, write its configuration, let the allowed sources
through the host's firewall, start it. Whether it answers is the caller's to ask,
with its own SNMP client.
"""
def configure_snmp_agent(self, settings: SnmpAgentSettingsDict) -> FixResultDict:
"""Install, configure, open and start the agent with *settings*."""
return run_fix_steps(
[
("install", self._install_snmp_agent),
("configure", lambda: self._write_snmp_agent_config(settings)),
("firewall", lambda: self._allow_snmp_sources(settings)),
("start", self._start_snmp_agent),
]
)
if TYPE_CHECKING: # pragma: no cover - declared for type checkers only
def get_management_peer(self) -> str | None:
"""The address the device sees this management connection come from
(for SSH, the client side of ``SSH_CONNECTION``), or None when it cannot
tell. A fact for the caller choosing ``allowed_sources``; optional --
a driver that cannot tell does not implement it."""
...
# The steps a driver implements:
def _install_snmp_agent(self) -> FixStepOutcomeDict: ...
def _write_snmp_agent_config(
self, settings: SnmpAgentSettingsDict
) -> FixStepOutcomeDict: ...
def _allow_snmp_sources(self, settings: SnmpAgentSettingsDict) -> FixStepOutcomeDict:
"""Let ``settings["allowed_sources"]`` reach the agent; ``ok`` with nothing
to do where the host has no firewall of its own."""
...
def _start_snmp_agent(self) -> FixStepOutcomeDict:
"""(Re)start the agent so it reads the new configuration, and enable it at boot."""
...
class EnsurePackageMixin:
"""Make sure a package is installed, installing it only where it is missing.
Works over :class:`~napalm_device_types.packages.PackageManagementMixin`'s
``get_packages`` and ``install_package``, which the driver implements.
"""
def ensure_package(self, name: str) -> FixResultDict:
"""Install *name* unless it is installed already."""
def install() -> FixStepOutcomeDict:
if any(p.get("name") == name and p.get("installed") for p in self.get_packages()):
return {"ok": True, "output": f"{name} is already installed"}
result: Any = self.install_package(name) # type: ignore[func-returns-value]
# The contract says None (and raise on failure); some drivers still
# return {"success", "output"}.
if isinstance(result, dict):
return {"ok": bool(result.get("success")), "output": str(result.get("output", ""))}
return {"ok": True, "output": f"installed {name}"}
return run_fix_steps([("install", install)])
if TYPE_CHECKING: # pragma: no cover
def get_packages(self) -> list[PackageDict]: ...
def install_package(self, name: str, version: str = "") -> None: ...
class PackageProxyMixin:
"""Point the device's package manager at a caching proxy."""
if TYPE_CHECKING: # pragma: no cover
def configure_package_proxy(self, url: str) -> FixResultDict:
"""Have the package manager fetch through *url*; an empty *url* removes it."""
...
def get_package_proxy(self) -> str | None:
"""The proxy the package manager fetches through now; None for none.
A fact: whether it is the right one is the caller's to judge."""
...
__all__ = [
"EnsurePackageMixin",
"FixStep",
"PackageProxyMixin",
"SnmpAgentMixin",
"run_fix_steps",
]
+20 -4
View File
@@ -1,6 +1,10 @@
# -*- coding: utf-8 -*-
"""SNMP health collection, shared by every device type that speaks UCD-MIB.
**Deprecated** (docs/STRUCTURE.md): drivers do not collect over SNMP. The
application polls; a driver whose device publishes values under its own OIDs
declares them as ``SNMP_HEALTH_PROFILE`` (:class:`~napalm_device_types.models.SnmpHealthProfile`).
This mixin goes in 5.0.
The collection itself is generic: CPU, memory, uptime, load and per-interface
counters come from the same standard OIDs on any UCD-MIB/IF-MIB capable device.
Only two details vary by device type, and both are class attributes rather than
@@ -15,7 +19,9 @@ implement ``get_health_metrics`` themselves.
from __future__ import annotations
import re
from typing import Any, Awaitable, Callable, ClassVar, Dict, cast
import warnings
from collections.abc import Awaitable, Callable
from typing import Any, ClassVar, cast
from napalm_device_types._ucd_metrics import IF_SKIP_DEFAULT, collect_ucd_metrics
from napalm_device_types.models import HealthMetricsDict
@@ -37,9 +43,19 @@ class HealthMetricsMixin:
async def get_health_metrics(
cls,
snmp_get: Callable[..., Awaitable[Any]],
snmp_walk: Callable[..., Awaitable[Dict[str, Any]]],
snmp_walk: Callable[..., Awaitable[dict[str, Any]]],
) -> HealthMetricsDict:
"""Collect SNMP health metrics (CPU, memory, uptime, load, interfaces)."""
"""Collect SNMP health metrics (CPU, memory, uptime, load, interfaces).
Deprecated: the application polls; see the module docstring.
"""
warnings.warn(
"HealthMetricsMixin.get_health_metrics is deprecated and goes in 5.0: "
"the application polls over SNMP; a driver declares only its OIDs, "
"as SNMP_HEALTH_PROFILE",
DeprecationWarning,
stacklevel=2,
)
# collect_ucd_metrics is untyped shared plumbing; the shape it returns is
# the contract HealthMetricsDict describes.
return cast(
+770
View File
@@ -0,0 +1,770 @@
"""A Linux host's own firewall: its netfilter rules, read once, in one shape.
Whether a service is reachable from outside its host is decided twice: by the
address it listens on (:mod:`napalm_device_types.listening`), and by the host's
own firewall, which may drop the packets first. Reading the firewall is the
same on every Linux host, so the command and its parse live here, and a driver
only carries the command across.
**What is read.** ``nft -j list ruleset`` is the primary reading: every table,
the host's own ``nftables.service`` ones included. Rules that iptables-nft wrote
are in it too, but their extensions show only as an opaque ``xt`` -- a
``-m conntrack --ctstate`` or ``-m set`` says nothing there. ``iptables-save``
spells them out, so the tables iptables manages are taken from it, and the
others from nft. A *legacy* iptables is a second ruleset beside nft, and both
apply to every packet, so then both are kept whole.
**One shape.** Each chain says its family, table, hook, priority and policy;
each rule a list of matches and a verdict. A match is ``field``, ``op``
(``==``/``!=``) and ``values``. The fields are the few that decide reachability
from outside: ``iifname``, ``oifname``, ``saddr``, ``daddr``, ``l4proto``,
``dport``, ``sport``, ``ct_state``, ``ct_orig_dport`` and ``nfproto``.
Everything else -- an ipset, ``addrtype``, a rate limit, a ``fib`` lookup -- is
``unknown`` with its text, never dropped: a rule that matches on something this
does not model must not read as one that matches everything.
**Root, and only root.** Both tools need ``CAP_NET_ADMIN``; without it they
print an error, not an empty ruleset. So there is no reading without root:
:class:`HostFirewallUnavailable` is raised rather than a host without rules.
Whether a rule set makes a service unreachable is the consumer's call.
"""
from __future__ import annotations
import ipaddress
import json
import re
import shlex
from collections.abc import Iterator
from shlex import quote
from typing import TYPE_CHECKING, Any
from napalm_device_types.models import (
HostFirewallDict,
NetfilterChainDict,
NetfilterMatchDict,
NetfilterRuleDict,
)
from napalm_device_types.terminal import strip_terminal_codes
_BEGIN = "FW_BEGIN"
_END = "FW_END"
_RC_RE = re.compile(r"^__FW_RC=(\d+)$")
#: One line, POSIX ``sh``, read-only. The frame markers are printed in two
#: halves so that a transport which echoes the command does not show them early.
#: Both tools live in ``/usr/sbin`` on most systems, outside a login user's ``PATH``.
HOST_FIREWALL_COMMAND = (
"PATH=$PATH:/usr/sbin:/sbin; "
"printf '%s%s\\n' FW_ BEGIN; "
"if command -v nft >/dev/null 2>&1; then "
"echo '[nft]'; nft -j list ruleset 2>&1; echo \"__FW_RC=$?\"; fi; "
"for t in iptables-save ip6tables-save; do "
"if command -v $t >/dev/null 2>&1; then "
'echo "[$t]"; $t 2>&1; echo "__FW_RC=$?"; fi; done; '
"printf '%s%s\\n' FW_ END"
)
#: ``iptables-save`` reads the IPv4 tables, ``ip6tables-save`` the IPv6 ones.
_IPTABLES_FAMILY = {"iptables-save": "ip", "ip6tables-save": "ip6"}
#: The built-in chains of iptables' tables: ``(type, hook, priority)``, as
#: iptables-nft registers them (and as the legacy tables run in the kernel).
_BUILT_IN: dict[str, dict[str, tuple[str, str, int]]] = {
"raw": {"PREROUTING": ("filter", "prerouting", -300), "OUTPUT": ("filter", "output", -300)},
"mangle": {
"PREROUTING": ("filter", "prerouting", -150),
"INPUT": ("filter", "input", -150),
"FORWARD": ("filter", "forward", -150),
"OUTPUT": ("route", "output", -150),
"POSTROUTING": ("filter", "postrouting", -150),
},
"nat": {
"PREROUTING": ("nat", "prerouting", -100),
"INPUT": ("nat", "input", 100),
"OUTPUT": ("nat", "output", -100),
"POSTROUTING": ("nat", "postrouting", 100),
},
"filter": {
"INPUT": ("filter", "input", 0),
"FORWARD": ("filter", "forward", 0),
"OUTPUT": ("filter", "output", 0),
},
"security": {
"INPUT": ("filter", "input", 50),
"FORWARD": ("filter", "forward", 50),
"OUTPUT": ("filter", "output", 50),
},
}
_TERMINAL = {"ACCEPT": "accept", "DROP": "drop", "REJECT": "reject", "RETURN": "return"}
#: Targets that look at a packet, mark it or log it, and let it go on.
_NON_TERMINAL = frozenset(
{
"LOG",
"NFLOG",
"ULOG",
"TRACE",
"MARK",
"CONNMARK",
"CT",
"NOTRACK",
"TCPMSS",
"CHECKSUM",
"AUDIT",
"SET",
"DSCP",
"TOS",
"TTL",
"HL",
"CLASSIFY",
"CONNSECMARK",
"SECMARK",
"IDLETIMER",
"LED",
"RATEEST",
}
)
class HostFirewallUnavailable(NotImplementedError):
"""Nothing on the host could be read: no ``nft``, no ``iptables-save``, or no root."""
def _frame(output: str) -> list[str]:
lines = [line.rstrip() for line in strip_terminal_codes(output).splitlines()]
stripped = [line.strip() for line in lines]
try:
start = stripped.index(_BEGIN)
end = stripped.index(_END, start)
except ValueError:
raise ValueError("no intact host firewall report in the output") from None
return lines[start + 1 : end]
def _sections(lines: list[str]) -> dict[str, tuple[list[str], int | None]]:
"""Each tool's output and exit status."""
sections: dict[str, tuple[list[str], int | None]] = {}
name: str | None = None
body: list[str] = []
for line in lines:
text = line.strip()
if text in ("[nft]", "[iptables-save]", "[ip6tables-save]"):
name, body = text[1:-1], []
sections[name] = (body, None)
continue
rc = _RC_RE.match(text)
if rc and name is not None:
sections[name] = (body, int(rc.group(1)))
name = None
continue
if name is not None:
body.append(line)
return sections
# ── iptables-save ────────────────────────────────────────────────────────────
#: The token that negates the option after it (``! -s 10.0.0.0/8``).
_NEGATION = "!"
#: Options that start a new part of a rule, outside any ``-m`` module.
_TOP_LEVEL = frozenset(
{
"-m",
"--match",
"-j",
"--jump",
"-g",
"--goto",
"-p",
"--protocol",
"-s",
"--source",
"-d",
"--destination",
"-i",
"--in-interface",
"-o",
"--out-interface",
"-f",
"--fragment",
"-c",
"--set-counters",
}
)
#: Modules whose options this reads; any other module is an ``unknown`` match.
_KNOWN_MODULES = frozenset(
{"tcp", "udp", "sctp", "udplite", "multiport", "state", "conntrack", "comment"}
)
def _match(field: str, negated: bool, values: list[str]) -> NetfilterMatchDict:
return {"field": field, "op": "!=" if negated else "==", "values": values}
def _unknown(text: str) -> NetfilterMatchDict:
return {"field": "unknown", "op": "==", "values": [text]}
def _iface(name: str) -> str:
"""iptables writes a wildcard interface as ``eth+``; nft as ``eth*``."""
return name[:-1] + "*" if name.endswith("+") else name
def _ports(spec: str) -> list[str]:
return [p.replace(":", "-") for p in spec.split(",") if p]
def _states(spec: str) -> list[str]:
return [s.lower() for s in spec.split(",") if s]
class _RuleTokens:
"""The tokens of one ``-A`` line, read front to back."""
def __init__(self, tokens: list[str]):
self.tokens = tokens
self.i = 0
def more(self) -> bool:
return self.i < len(self.tokens)
def peek(self, ahead: int = 0) -> str | None:
j = self.i + ahead
return self.tokens[j] if j < len(self.tokens) else None
def take(self) -> str:
token = self.tokens[self.i]
self.i += 1
return token
def _module_options(module: str, rt: _RuleTokens, matches: list[NetfilterMatchDict]) -> None:
"""Read *module*'s options from *rt* into *matches*; stop at the next top-level option."""
text = ["-m", module]
unknown = module not in _KNOWN_MODULES
while rt.more():
token = rt.peek()
if token in _TOP_LEVEL or (token == _NEGATION and rt.peek(1) in _TOP_LEVEL):
break
negated = False
if token == _NEGATION:
rt.take()
negated = True
text.append(_NEGATION)
option = rt.take()
value = rt.take() if rt.more() and not str(rt.peek()).startswith("-") else ""
text += [option] + ([value] if value else [])
if unknown:
continue
mapped = _module_match(module, option, value, negated)
if mapped is None:
unknown = True
elif mapped is not False:
matches.append(mapped)
if unknown and module != "comment":
matches.append(_unknown(" ".join(text)))
def _module_match(module: str, option: str, value: str, negated: bool) -> Any:
"""The match one module option makes.
False for one that matches nothing new; None if not read.
"""
if module == "comment":
return False
if module in ("tcp", "udp", "sctp", "udplite"):
if option in ("--dport", "--destination-port"):
return _match("dport", negated, _ports(value))
if option in ("--sport", "--source-port"):
return _match("sport", negated, _ports(value))
return None
if module == "multiport":
if option in ("--dports", "--destination-ports"):
return _match("dport", negated, _ports(value))
if option in ("--sports", "--source-ports"):
return _match("sport", negated, _ports(value))
return None
if module == "state" and option == "--state":
return _match("ct_state", negated, _states(value))
if module == "conntrack":
if option == "--ctstate":
return _match("ct_state", negated, _states(value))
if option == "--ctorigdstport":
return _match("ct_orig_dport", negated, _ports(value))
return None
def _target(name: str, chains: set[str]) -> tuple[str, str | None]:
if name in _TERMINAL:
return _TERMINAL[name], None
if name in chains:
return "jump", name
if name in _NON_TERMINAL:
return "continue", name
return "other", name
def _iptables_rule(line: str, chains: set[str]) -> tuple[str, NetfilterRuleDict]:
"""``-A CHAIN ...`` as ``(chain, rule)``."""
tokens = shlex.split(line)
chain = tokens[1]
rt = _RuleTokens(tokens[2:])
matches: list[NetfilterMatchDict] = []
verdict, target = "continue", None
negated = False
simple = {
"-i": "iifname",
"--in-interface": "iifname",
"-o": "oifname",
"--out-interface": "oifname",
"-s": "saddr",
"--source": "saddr",
"-d": "daddr",
"--destination": "daddr",
"-p": "l4proto",
"--protocol": "l4proto",
}
while rt.more():
token = rt.take()
if token == _NEGATION:
negated = True
continue
if token in simple:
field, value = simple[token], rt.take()
if field in ("iifname", "oifname"):
matches.append(_match(field, negated, [_iface(value)]))
elif field == "l4proto":
if value.lower() != "all":
matches.append(_match(field, negated, [value.lower()]))
else:
matches.append(_match(field, negated, value.split(",")))
elif token in ("-m", "--match"):
_module_options(rt.take(), rt, matches)
elif token in ("-j", "--jump"):
verdict, target = _target(rt.take(), chains)
break # the target's own options follow and match nothing
elif token in ("-g", "--goto"):
verdict, target = "goto", rt.take()
break
elif token in ("-c", "--set-counters"):
rt.take(), rt.take()
else:
matches.append(_unknown(token))
negated = False
return chain, {"matches": matches, "verdict": verdict, "target": target, "text": line}
def _iptables_variant(lines: list[str]) -> str | None:
"""``iptables-nft`` or ``iptables-legacy`` from the header; None if nothing was saved."""
for line in lines:
if line.startswith("# Generated by"):
return "iptables-nft" if "(nf_tables)" in line else "iptables-legacy"
return None
def _parse_iptables_save(lines: list[str], family: str) -> list[NetfilterChainDict]:
chains: list[NetfilterChainDict] = []
table: str | None = None
by_name: dict[str, NetfilterChainDict] = {}
for raw in lines:
line = raw.strip()
if line.startswith("*"):
table, by_name = line[1:], {}
elif line.startswith(":") and table is not None:
name, policy = line[1:].split()[:2]
kind, hook, prio = _BUILT_IN.get(table, {}).get(name, (None, None, None))
chain: NetfilterChainDict = {
"family": family,
"table": table,
"name": name,
"type": kind,
"hook": hook,
"priority": prio,
"policy": None if policy == "-" else policy.lower(),
"rules": [],
}
by_name[name] = chain
chains.append(chain)
elif line.startswith("-A ") and table is not None:
name, rule = _iptables_rule(line, set(by_name))
if name in by_name:
by_name[name]["rules"].append(rule)
return chains
# ── nft -j ───────────────────────────────────────────────────────────────────
_VERDICTS = {
"accept": "accept",
"drop": "drop",
"reject": "reject",
"return": "return",
"continue": "continue",
}
#: Statements that act on a packet without deciding its fate.
_NO_OPS = frozenset(
{
"counter",
"log",
"mangle",
"notrack",
"set",
"flow",
"ct helper",
"synproxy",
"tcp option",
"dup",
"fwd",
}
)
#: Statements that pass only some packets on: what follows is not about every packet.
_PARTIAL = frozenset({"limit", "quota", "numgen", "hash", "ct count"})
#: Verdicts that hand the packet elsewhere, or that this does not model.
_OTHER = frozenset({"queue", "dnat", "snat", "masquerade", "redirect", "tproxy", "vmap"})
_TRANSPORTS = frozenset({"tcp", "udp", "sctp", "udplite", "dccp"})
def _nft_scalar(value: Any) -> list[str] | None:
"""A right-hand side as values; None for one this does not model."""
if isinstance(value, bool):
return None
if isinstance(value, (int, str)):
return [str(value)]
if isinstance(value, dict):
if "prefix" in value:
return [f"{value['prefix']['addr']}/{value['prefix']['len']}"]
if "range" in value:
low, high = value["range"]
return [f"{low}-{high}"]
if "elem" in value:
return _nft_scalar(value["elem"].get("val"))
if "set" in value:
return _nft_values(value["set"])
if isinstance(value, list):
return _nft_values(value)
return None
def _nft_values(items: Any) -> list[str] | None:
items = items if isinstance(items, list) else [items]
out: list[str] = []
for item in items:
values = _nft_scalar(item)
if values is None:
return None
out += values
return out
def _left_text(left: Any) -> str:
if isinstance(left, dict):
if "payload" in left:
return (
f"{left['payload'].get('protocol', '')} {left['payload'].get('field', '')}".strip()
)
if "meta" in left:
return f"meta {left['meta'].get('key', '')}"
if "ct" in left:
ct = left["ct"]
return f"ct {ct.get('dir', '')} {ct.get('key', '')}".replace(" ", " ")
key = next(iter(left), "")
return str(key)
return str(left)
def _right_text(right: Any) -> str:
values = _nft_scalar(right)
if values is None:
return json.dumps(right, sort_keys=True)
return values[0] if len(values) == 1 else "{" + ", ".join(values) + "}"
def _nft_field(left: Any) -> tuple[str | None, str | None]:
"""``(field, implied l4proto)`` of a left-hand side; ``(None, None)`` if not modelled."""
if not isinstance(left, dict):
return None, None
if "meta" in left:
key = left["meta"].get("key")
return {
"iifname": "iifname",
"iif": "iifname",
"oifname": "oifname",
"oif": "oifname",
"l4proto": "l4proto",
"nfproto": "nfproto",
}.get(key), None
if "payload" in left:
proto, field = left["payload"].get("protocol"), left["payload"].get("field")
if proto in ("ip", "ip6") and field in ("saddr", "daddr"):
return field, None
if (proto, field) in (("ip", "protocol"), ("ip6", "nexthdr")):
return "l4proto", None
if field in ("dport", "sport") and (proto in _TRANSPORTS or proto == "th"):
return field, proto if proto in _TRANSPORTS else None
return None, None
if "ct" in left:
key, direction = left["ct"].get("key"), left["ct"].get("dir")
if key == "state":
return "ct_state", None
if key == "proto-dst" and direction == "original":
return "ct_orig_dport", None
return None, None
def _nft_match(
match: dict[str, Any], sets: dict[tuple[str, str, str], list[str]], where: tuple[str, str]
) -> list[NetfilterMatchDict]:
left, op, right = match.get("left"), match.get("op"), match.get("right")
field, implied = _nft_field(left)
raw_text = (
f"{_left_text(left)} {'' if op in ('==', 'in') else str(op) + ' '}{_right_text(right)}"
)
if field is None or op not in ("==", "in", "!="):
return [_unknown(raw_text)]
if isinstance(right, str) and right.startswith("@"):
values = sets.get((where[0], where[1], right[1:]))
else:
values = _nft_scalar(right)
if values is None:
return [_unknown(raw_text)]
out: list[NetfilterMatchDict] = []
if implied:
out.append(_match("l4proto", False, [implied]))
if field in ("iifname", "oifname", "l4proto", "ct_state", "nfproto"):
values = [
str(v).lower() if field != "iifname" and field != "oifname" else str(v) for v in values
]
out.append(_match(field, op == "!=", values))
return out
def _nft_rule(
rule: dict[str, Any], sets: dict[tuple[str, str, str], list[str]]
) -> NetfilterRuleDict:
where = (rule.get("family", ""), rule.get("table", ""))
matches: list[NetfilterMatchDict] = []
verdict, target = "continue", None
for statement in rule.get("expr") or []:
if not isinstance(statement, dict) or not statement:
continue
key = next(iter(statement))
body = statement[key]
if key == "match":
matches += _nft_match(body, sets, where)
elif key in _VERDICTS:
verdict = _VERDICTS[key]
break
elif key in ("jump", "goto"):
verdict, target = key, str((body or {}).get("target"))
break
elif key == "xt":
if (body or {}).get("type") == "target":
verdict, target = "other", str(body.get("name"))
break
matches.append(_unknown(f"xt match {(body or {}).get('name')}"))
elif key in _PARTIAL:
matches.append(_unknown(key))
elif key in _OTHER:
verdict, target = "other", key
break
elif key not in _NO_OPS:
matches.append(_unknown(key))
entry: NetfilterRuleDict = {
"matches": matches,
"verdict": verdict,
"target": target,
"text": "",
}
entry["text"] = rule_text(entry)
return entry
def _parse_nft(text: str, skip: set[tuple[str, str]]) -> list[NetfilterChainDict]:
start = text.find("{")
if start < 0:
raise ValueError("nft printed no JSON")
objects = json.loads(text[start:]).get("nftables") or []
sets: dict[tuple[str, str, str], list[str]] = {}
for obj in objects:
if "set" in obj and "elem" in obj["set"]:
values = _nft_values(obj["set"]["elem"])
if values is not None:
s = obj["set"]
sets[(s.get("family", ""), s.get("table", ""), s.get("name", ""))] = values
chains: dict[tuple[str, str, str], NetfilterChainDict] = {}
for obj in objects:
if "chain" not in obj:
continue
c = obj["chain"]
if (c.get("family"), c.get("table")) in skip:
continue
chains[(c["family"], c["table"], c["name"])] = {
"family": c["family"],
"table": c["table"],
"name": c["name"],
"type": c.get("type"),
"hook": c.get("hook"),
"priority": c.get("prio"),
"policy": c.get("policy") if c.get("hook") else None,
"rules": [],
}
for obj in objects:
if "rule" not in obj:
continue
r = obj["rule"]
chain = chains.get((r.get("family"), r.get("table"), r.get("chain")))
if chain is not None:
chain["rules"].append(_nft_rule(r, sets))
return list(chains.values())
# ── text ─────────────────────────────────────────────────────────────────────
def _family_of(values: list[str]) -> str:
try:
return (
"ip6"
if all(ipaddress.ip_network(v, strict=False).version == 6 for v in values)
else "ip"
)
except ValueError:
return "ip"
def _values_text(values: list[str]) -> str:
return values[0] if len(values) == 1 else "{" + ", ".join(values) + "}"
def rule_text(rule: NetfilterRuleDict) -> str:
"""A rule as one nft-style line, from its normalized matches and verdict."""
protos = [
v
for m in rule["matches"]
if m["field"] == "l4proto" and m["op"] == "=="
for v in m["values"]
]
parts: list[str] = []
for m in rule["matches"]:
neg = "!= " if m["op"] == "!=" else ""
field, values = m["field"], m["values"]
if field == "unknown":
parts.append(" ".join(values))
continue
keyword = {
"saddr": f"{_family_of(values)} saddr",
"daddr": f"{_family_of(values)} daddr",
"l4proto": "meta l4proto",
"nfproto": "meta nfproto",
"ct_state": "ct state",
"ct_orig_dport": "ct original proto-dst",
"dport": f"{protos[0] if len(protos) == 1 else 'th'} dport",
"sport": f"{protos[0] if len(protos) == 1 else 'th'} sport",
}.get(field, field)
parts.append(f"{keyword} {neg}{_values_text(values)}")
verdict = rule["verdict"]
if verdict in ("jump", "goto"):
parts.append(f"{verdict} {rule['target']}")
elif verdict == "other":
parts.append(str(rule["target"] or "other"))
elif verdict != "continue":
parts.append(verdict)
return " ".join(parts)
# ── the reading ──────────────────────────────────────────────────────────────
def _iptables_sections(
sections: dict[str, tuple[list[str], int | None]],
) -> Iterator[tuple[str, str, list[str]]]:
"""``(variant, family, lines)`` for each iptables tool that saved something."""
for tool, family in _IPTABLES_FAMILY.items():
lines, rc = sections.get(tool, ([], None))
if rc != 0:
continue
variant = _iptables_variant(lines)
if variant is not None:
yield variant, family, lines
def parse_host_firewall(output: str) -> HostFirewallDict:
"""The framed report of :data:`HOST_FIREWALL_COMMAND` as one ruleset.
:raises ValueError: if the report's frame is not intact.
:raises HostFirewallUnavailable: if neither tool could be read.
"""
sections = _sections(_frame(output))
sources: list[str] = []
chains: list[NetfilterChainDict] = []
covered: set[tuple[str, str]] = set()
for variant, family, lines in _iptables_sections(sections):
saved = _parse_iptables_save(lines, family)
chains += saved
if variant not in sources:
sources.append(variant)
if variant == "iptables-nft":
covered |= {(c["family"], c["table"]) for c in saved}
nft_lines, nft_rc = sections.get("nft", ([], None))
if nft_rc == 0:
try:
nft_chains = _parse_nft("\n".join(nft_lines), covered)
except ValueError:
nft_chains = None
if nft_chains is not None:
sources.insert(0, "nftables")
chains = nft_chains + chains
if not sources:
raise HostFirewallUnavailable("neither nft nor iptables-save could be read (root needed)")
return {"sources": sources, "chains": chains}
class HostFirewallMixin:
"""Adds :meth:`get_host_firewall` to a driver that can run a command on a Linux host.
The template form (README, "Function classes"): the command and its parse
are the same everywhere, so they are concrete here, and a driver supplies
only :meth:`_run_host_firewall_command` -- how a command reaches its host,
and how it gains root there. Mixed in by the drivers that can, so
``hasattr(driver, "get_host_firewall")`` stays truthful.
"""
if TYPE_CHECKING: # pragma: no cover - declared for type checkers only
def _run_host_firewall_command(self, command: str, *, privileged: bool) -> str:
"""Run *command* on the host and return what it printed; as root
when *privileged*. The command is a single ``sh -c`` invocation."""
...
def get_host_firewall(self) -> HostFirewallDict:
"""
Returns the host's netfilter rules: every chain with its hook, priority
and policy, and every rule as matches and a verdict.
* sources (list) - ``nftables``, ``iptables-nft``, ``iptables-legacy``: what was read
* chains (list) - see :class:`~napalm_device_types.models.NetfilterChainDict`
Example::
{
"sources": ["nftables", "iptables-nft"],
"chains": [
{"family": "ip", "table": "filter", "name": "INPUT", "type": "filter",
"hook": "input", "priority": 0, "policy": "accept",
"rules": [
{"matches": [{"field": "iifname", "op": "==", "values": ["ens6"]}],
"verdict": "drop", "target": None,
"text": "-A INPUT -i ens6 -j DROP"},
]},
],
}
:raises HostFirewallUnavailable: if neither tool could be read -- no
``nft``, no ``iptables-save``, or no root to run them.
:raises ValueError: if the report came back cut short.
"""
command = f"sh -c {quote(HOST_FIREWALL_COMMAND)}"
return parse_host_firewall(self._run_host_firewall_command(command, privileged=True))
+70 -30
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Host status: does the host need a reboot, and does it patch itself?
A patch run that installed a new kernel or libc has not closed anything until
@@ -23,8 +22,12 @@ It is ``None`` when none of these could be read, for example in a container
without a ``/lib/modules`` of its own.
**Auto updates** is apt's ``APT::Periodic::Unattended-Upgrade`` (set, not "0",
and ``apt-daily-upgrade.timer`` not disabled) or an enabled dnf-automatic timer.
It is ``None`` on a host with neither apt nor dnf-automatic. When they last ran
``apt-daily-upgrade.timer`` not disabled, and unattended-upgrades installed: a
package removed but not purged leaves its "1" behind) or an enabled
``dnf-automatic-install.timer``. ``dnf-automatic.timer`` installs only with
``apply_updates = yes`` in ``automatic.conf`` (dnf5's in ``dnf5-plugins/``
wins); by default it only downloads. It is ``None`` on a host with neither apt
nor dnf-automatic. When they last ran
successfully is ``/var/lib/apt/periodic/upgrade-stamp``, which
``apt.systemd.daily`` touches only after unattended-upgrade succeeded.
``update-success-stamp`` would say nothing: every ``apt-get update`` touches it,
@@ -55,7 +58,7 @@ too full is not decided here.
from __future__ import annotations
import re
from typing import Dict, List, Optional, Tuple, TYPE_CHECKING
from typing import TYPE_CHECKING
from napalm_device_types.models import (
FilesystemUsageDict,
@@ -68,7 +71,11 @@ _BEGIN = "HSTAT_BEGIN"
_END = "HSTAT_END"
_REBOOT_FILE = "/var/run/reboot-required"
_APT_TIMER = "apt-daily-upgrade.timer"
_DNF_TIMERS = ("dnf-automatic.timer", "dnf-automatic-install.timer")
_DNF_TIMER = "dnf-automatic.timer"
_DNF_INSTALL_TIMER = "dnf-automatic-install.timer"
_DNF_TIMERS = (_DNF_TIMER, _DNF_INSTALL_TIMER)
#: dnf4's, then dnf5's: the last ``apply_updates`` printed is the one that holds.
_DNF_AUTOMATIC_CONF = "/etc/dnf/automatic.conf /etc/dnf/dnf5-plugins/automatic.conf"
_UPGRADE_STAMP = "/var/lib/apt/periodic/upgrade-stamp"
_DPKG_JOURNAL = "/var/lib/dpkg/updates"
#: Where an upgrade writes: the kernel's modules, its initrd, the package files.
@@ -100,6 +107,10 @@ HOST_STATUS_COMMAND = (
"echo '[freebsd-running]'; freebsd-version -r; fi; "
"if command -v apt-config >/dev/null 2>&1; then echo '[apt-config]'; "
"apt-config dump 2>/dev/null | grep '^APT::Periodic::Unattended-Upgrade '; "
# Named through a variable: spelt out, the program name would match BUSY_CHECK
# in this very command line.
'uu=unattended-upgrade; { command -v "$uu" || [ -x "/usr/bin/$uu" ]; } >/dev/null 2>&1 '
"&& echo '[unattended-upgrade]'; "
f"echo '[upgrade-stamp]'; stat -c %Y {_UPGRADE_STAMP} 2>/dev/null; fi; "
"if command -v dpkg-query >/dev/null 2>&1; then "
f"{BUSY_CHECK}"
@@ -109,10 +120,15 @@ HOST_STATUS_COMMAND = (
f"echo '[filesystems]'; df -Pk {_FILESYSTEMS}; "
f"echo '[timers]'; for u in {_APT_TIMER} {' '.join(_DNF_TIMERS)}; do "
'printf \'%s %s\\n\' "$u" "$(systemctl is-enabled "$u" 2>/dev/null)"; done; '
"echo '[dnf-automatic]'; grep -h '^[[:space:]]*apply_updates[[:space:]]*=' "
f"{_DNF_AUTOMATIC_CONF} 2>/dev/null; "
"printf '%s%s\\n' HSTAT_ END; } 2>/dev/null | cat"
)
_PERIODIC = re.compile(r'^APT::Periodic::Unattended-Upgrade\s+"([^"]*)"')
_APPLY_UPDATES = re.compile(r"^apply_updates\s*=\s*(\S*)")
#: What libdnf takes for a true boolean.
_TRUE = frozenset({"1", "yes", "true", "on"})
_OFF_STATES = frozenset({"disabled", "masked"})
_INTERVAL = re.compile(r"(\d+)([smhd]?)")
_UNIT_SECONDS = {"s": 1, "m": 60, "h": 3600, "d": 86400, "": 86400}
@@ -130,15 +146,15 @@ _HALFWAY = {
}
def _sections(output: str) -> Dict[str, List[str]]:
def _sections(output: str) -> dict[str, list[str]]:
lines = [line.strip() for line in strip_terminal_codes(output).splitlines()]
try:
start = lines.index(_BEGIN)
end = lines.index(_END, start)
except ValueError:
raise ValueError("no intact host status report in the output") from None
sections: Dict[str, List[str]] = {}
current: List[str] = []
sections: dict[str, list[str]] = {}
current: list[str] = []
for line in lines[start + 1 : end]:
if line.startswith("[") and line.endswith("]"):
current = sections.setdefault(line[1:-1], [])
@@ -147,29 +163,42 @@ def _sections(output: str) -> Dict[str, List[str]]:
return sections
def _version_key(version: str) -> Tuple[object, ...]:
def _version_key(version: str) -> tuple[object, ...]:
"""Natural order: 6.8.0-142 after 6.8.0-87, 7.0.14 after 7.0.2."""
return tuple(int(part) if part.isdigit() else part for part in re.split(r"(\d+)", version))
def kernel_reboot_pending(running: str, installed: List[str]) -> Optional[str]:
def kernel_reboot_pending(running: str, installed: list[str]) -> str | None:
"""The newest installed kernel of the running flavour, if it is newer than the
running one; otherwise None.
The flavour is what follows the last ``-`` (``generic``, ``amd64``, ``pve``,
``v8``); a kernel of another flavour is never a reason to reboot.
``v8``), or on an RPM kernel (``5.14.0-427.13.1.el9_4.x86_64``) the arch
after the release; a kernel of another flavour is never a reason to reboot.
"""
flavour = running.rsplit("-", 1)[-1]
same = [k for k in installed if k.rsplit("-", 1)[-1] == flavour]
version, flavour = _kernel_flavour(running)
same = [k for k in installed if _kernel_flavour(k)[1] == flavour]
if not same:
return None
newest = max(same, key=lambda k: _version_key(k.rsplit("-", 1)[0]))
if _version_key(newest.rsplit("-", 1)[0]) > _version_key(running.rsplit("-", 1)[0]):
newest = max(same, key=lambda k: _version_key(_kernel_flavour(k)[0]))
if _version_key(_kernel_flavour(newest)[0]) > _version_key(version):
return newest
return None
def _freebsd_reboot(sections: Dict[str, List[str]]) -> Optional[Tuple[bool, Optional[str]]]:
def _kernel_flavour(release: str) -> tuple[str, str]:
"""``6.8.0-87-generic`` -> ``("6.8.0-87", "generic")``,
``5.14.0-427.13.1.el9_4.x86_64`` -> ``("5.14.0-427.13.1.el9_4", "x86_64")``."""
base, sep, tail = release.rpartition("-")
if not sep:
return release, ""
if tail[:1].isdigit() and "." in tail:
rel, _, arch = tail.rpartition(".")
return f"{base}-{rel}", arch
return base, tail
def _freebsd_reboot(sections: dict[str, list[str]]) -> tuple[bool, str | None] | None:
"""FreeBSD's answer from freebsd-version, or None on any other system."""
installed = (sections.get("freebsd-kernel") or [""])[0]
running = (sections.get("freebsd-running") or [""])[0]
@@ -180,7 +209,7 @@ def _freebsd_reboot(sections: Dict[str, List[str]]) -> Optional[Tuple[bool, Opti
return False, None
def _reboot(sections: Dict[str, List[str]]) -> Tuple[Optional[bool], Optional[str]]:
def _reboot(sections: dict[str, list[str]]) -> tuple[bool | None, str | None]:
if "reboot-required" in sections:
return True, f"{_REBOOT_FILE} is present"
needs = sections.get("needs-restarting")
@@ -199,32 +228,43 @@ def _reboot(sections: Dict[str, List[str]]) -> Tuple[Optional[bool], Optional[st
return None, None
def _timer_states(sections: Dict[str, List[str]]) -> Dict[str, str]:
states: Dict[str, str] = {}
def _timer_states(sections: dict[str, list[str]]) -> dict[str, str]:
states: dict[str, str] = {}
for line in sections.get("timers") or []:
unit, _, state = line.partition(" ")
states[unit] = state.strip()
return states
def _periodic(sections: Dict[str, List[str]]) -> Optional[str]:
def _periodic(sections: dict[str, list[str]]) -> str | None:
"""The value of ``APT::Periodic::Unattended-Upgrade``, or None when it is not set."""
lines = sections.get("apt-config") or []
match = next(filter(None, (_PERIODIC.match(line) for line in lines)), None)
return match.group(1) if match else None
def _auto_updates(sections: Dict[str, List[str]]) -> Optional[bool]:
def _dnf_applies(sections: dict[str, list[str]]) -> bool:
"""Whether dnf-automatic.timer installs what it downloads; dnf's default is no."""
lines = sections.get("dnf-automatic") or []
values = [m.group(1) for m in map(_APPLY_UPDATES.match, lines) if m]
return bool(values) and values[-1].lower() in _TRUE
def _auto_updates(sections: dict[str, list[str]]) -> bool | None:
timers = _timer_states(sections)
if any(timers.get(t) == "enabled" for t in _DNF_TIMERS):
if timers.get(_DNF_INSTALL_TIMER) == "enabled":
return True
downloads_only = timers.get(_DNF_TIMER) == "enabled"
if downloads_only and _dnf_applies(sections):
return True
if "apt-config" not in sections:
return None
return False if downloads_only else None
switched_on = _periodic(sections) not in (None, "", "0")
return switched_on and timers.get(_APT_TIMER) not in _OFF_STATES
installed = "unattended-upgrade" in sections
return switched_on and installed and timers.get(_APT_TIMER) not in _OFF_STATES
def _interval(sections: Dict[str, List[str]]) -> Optional[int]:
def _interval(sections: dict[str, list[str]]) -> int | None:
"""apt's interval in seconds, the way ``apt.systemd.daily`` reads it; 0 for "always"."""
value = _periodic(sections)
if value == "always":
@@ -235,19 +275,19 @@ def _interval(sections: Dict[str, List[str]]) -> Optional[int]:
return int(match.group(1)) * _UNIT_SECONDS[match.group(2)]
def _last_success(sections: Dict[str, List[str]]) -> Optional[int]:
def _last_success(sections: dict[str, list[str]]) -> int | None:
stamp = (sections.get("upgrade-stamp") or [""])[0]
return int(stamp) if stamp.isdigit() else None
def _halfway(line: str) -> Optional[InterruptedPackageDict]:
def _halfway(line: str) -> InterruptedPackageDict | None:
status, _, name = line.partition(" ")
name = name.strip()
state = "reinstall-required" if status[2:3] == "R" else _HALFWAY.get(status[1:2])
return {"name": name, "state": state} if name and state else None
def _dpkg(sections: Dict[str, List[str]]) -> Tuple[Optional[bool], List[InterruptedPackageDict]]:
def _dpkg(sections: dict[str, list[str]]) -> tuple[bool | None, list[InterruptedPackageDict]]:
if "dpkg-audit" not in sections or "dpkg-busy" in sections:
return None, []
packages = [p for p in map(_halfway, sections["dpkg-audit"]) if p is not None]
@@ -255,9 +295,9 @@ def _dpkg(sections: Dict[str, List[str]]) -> Tuple[Optional[bool], List[Interrup
return bool(packages) or journal, packages
def _filesystems(sections: Dict[str, List[str]]) -> Optional[List[FilesystemUsageDict]]:
def _filesystems(sections: dict[str, list[str]]) -> list[FilesystemUsageDict] | None:
"""Each filesystem df named, once and in its order; None when it named none."""
found: Dict[str, FilesystemUsageDict] = {}
found: dict[str, FilesystemUsageDict] = {}
for line in sections.get("filesystems") or []:
match = _DF_LINE.match(line)
if not match or int(match.group(1)) == 0:
+28 -24
View File
@@ -10,24 +10,26 @@ Usage::
...
"""
from typing import Any, Dict, List, TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from napalm_device_types.base import DeviceTypeDriver
from napalm_device_types.packages import PackageManagementMixin
from napalm_device_types.health_metrics import HealthMetricsMixin
from napalm_device_types.provisioning import QEMU_GUEST_AGENTS, GuestAgents
from napalm_device_types.models import (
NICConfigDict,
HypervisorHostDict,
NetworkTargetDict,
NICConfigDict,
SnapshotDict,
StorageTargetDict,
StorageVolumeDict,
VirtualNetworkDict,
VMConfigDict,
VMCpuTypeDict,
VMDict,
VMProvisionResultDict,
VMStatusDict,
VirtualNetworkDict,
)
from napalm_device_types.packages import PackageManagementMixin
from napalm_device_types.provisioning import QEMU_GUEST_AGENTS, GuestAgents
class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDriver):
@@ -51,14 +53,13 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
#: the driver can provision; netOrk reads this off the class.
GUEST_AGENTS: GuestAgents = {"linux": QEMU_GUEST_AGENTS["linux"]}
# ------------------------------------------------------------------
# Virtual machines – read
# ------------------------------------------------------------------
if TYPE_CHECKING:
def get_vms(self) -> List[VMDict]:
def get_vms(self) -> list[VMDict]:
"""
Returns a list of all virtual machines known to this hypervisor,
including their runtime status.
@@ -261,7 +262,7 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
# Snapshots
# ------------------------------------------------------------------
def get_vm_snapshots(self, name: str) -> List[SnapshotDict]:
def get_vm_snapshots(self, name: str) -> list[SnapshotDict]:
"""
Returns all snapshots of a virtual machine.
@@ -300,8 +301,9 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
"""
...
def create_vm_snapshot(self, name: str, snapshot: str,
description: str = "", include_memory: bool = False) -> None:
def create_vm_snapshot(
self, name: str, snapshot: str, description: str = "", include_memory: bool = False
) -> None:
"""
Creates a snapshot of a virtual machine.
@@ -328,7 +330,8 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
:param name: VM name or its ``vmid``.
:param snapshot: Name of the snapshot to delete.
:raises ValueError: If the VM or snapshot does not exist.
:raises RuntimeError: If other snapshots depend on this one (must delete children first).
:raises RuntimeError: If other snapshots depend on this one
(must delete children first).
Example::
@@ -358,7 +361,7 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
# Storage
# ------------------------------------------------------------------
def get_vm_storage_pools(self) -> Dict[str, StorageVolumeDict]:
def get_vm_storage_pools(self) -> dict[str, StorageVolumeDict]:
"""
Returns the storage pools / datastores configured on this hypervisor.
@@ -402,7 +405,7 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
# Virtual networking
# ------------------------------------------------------------------
def get_virtual_networks(self) -> Dict[str, VirtualNetworkDict]:
def get_virtual_networks(self) -> dict[str, VirtualNetworkDict]:
"""
Returns the virtual networks / bridges defined on this hypervisor.
@@ -442,11 +445,6 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
# Package management (hypervisor extensions / plugins)
# ------------------------------------------------------------------
# ------------------------------------------------------------------
# Virtual machine provisioning
# ------------------------------------------------------------------
@@ -458,10 +456,10 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
image_url: str,
cpu: int,
memory: int,
nics: List[NICConfigDict],
cloud_init_config: Dict[str, Any],
nics: list[NICConfigDict],
cloud_init_config: dict[str, Any],
image_checksum: str | None = None,
ssh_public_keys: List[str] | None = None,
ssh_public_keys: list[str] | None = None,
disk_resize_gb: int | None = None,
storage: str | None = None,
cpu_type: str | None = None,
@@ -601,7 +599,7 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
"""
...
def get_network_targets(self) -> List[NetworkTargetDict]:
def get_network_targets(self) -> list[NetworkTargetDict]:
"""
List the network targets a new VM's NIC may attach to.
@@ -619,7 +617,7 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
"""
...
def get_image_storages(self) -> List[StorageTargetDict]:
def get_image_storages(self) -> list[StorageTargetDict]:
"""
List the storage pools a new VM's root disk may be placed on, scoped to
the specific node the VM will be created on.
@@ -637,7 +635,13 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
"""
...
def get_vm_cpu_types(self) -> List[VMCpuTypeDict]:
def get_hypervisor_hosts(self) -> list[HypervisorHostDict]:
"""The hosts this hypervisor or its manager runs, and how each stands:
connected, in maintenance, with configuration issues, under a central
manager. Facts; what is a problem is the caller's to decide."""
...
def get_vm_cpu_types(self) -> list[VMCpuTypeDict]:
"""
List the virtual CPU models a new VM may be given on the node it will
be created on.
+4 -5
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Dropping interfaces that are noise rather than signal.
An access point exposes radio PHYs and a loopback alongside its real
@@ -9,19 +8,19 @@ attributes are the customisation point.
from __future__ import annotations
from typing import Any, ClassVar, Dict, FrozenSet, Tuple
from typing import Any, ClassVar
class InterfaceFilterMixin:
"""Adds :meth:`_filter_interfaces` for drivers that report raw interface dicts."""
#: Interface names dropped outright.
_EXCLUDED_INTERFACES: ClassVar[FrozenSet[str]] = frozenset({"lo"})
_EXCLUDED_INTERFACES: ClassVar[frozenset[str]] = frozenset({"lo"})
#: Name prefixes dropped -- ``phy*`` are radio devices, not links.
_EXCLUDED_INTERFACE_PREFIXES: ClassVar[Tuple[str, ...]] = ("phy",)
_EXCLUDED_INTERFACE_PREFIXES: ClassVar[tuple[str, ...]] = ("phy",)
def _filter_interfaces(self, interfaces: Dict[str, Any]) -> Dict[str, Any]:
def _filter_interfaces(self, interfaces: dict[str, Any]) -> dict[str, Any]:
"""Remove loopback and radio-device (phy*) interfaces from an interface dict."""
return {
name: data
+13 -10
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""What the running kernel has built and loaded.
A kernel CVE's exploitability often hangs on code that is simply not there --
@@ -25,7 +24,7 @@ import base64
import binascii
import gzip
import zlib
from typing import Dict, List, Optional, TYPE_CHECKING
from typing import TYPE_CHECKING
from napalm_device_types.models import KernelFactsDict
@@ -42,7 +41,8 @@ KERNEL_FACTS_COMMAND = (
"if [ -r $m/modules.builtin ]; then echo '[builtin]'; cat $m/modules.builtin; fi; "
"if [ -r $m/modules.dep ]; then echo '[available]'; cut -d: -f1 $m/modules.dep; fi; "
"if [ -r /boot/config-$r ]; then echo '[config]'; grep '^CONFIG_' /boot/config-$r; "
"elif [ -r /proc/config.gz ]; then echo '[config]'; zcat /proc/config.gz | grep '^CONFIG_'; fi; "
"elif [ -r /proc/config.gz ]; then echo '[config]'; "
"zcat /proc/config.gz | grep '^CONFIG_'; fi; "
"} 2>/dev/null | gzip -c | base64; "
"printf '%s%s\\n' KFACTS_ END"
)
@@ -70,14 +70,17 @@ def _report(output: str) -> str:
raise ValueError("no kernel facts in the output") from None
try:
packed = base64.b64decode("".join(lines[start + 1 : end]), validate=True)
return gzip.decompress(packed).decode()
report = gzip.decompress(packed).decode()
except (binascii.Error, OSError, EOFError, zlib.error, UnicodeDecodeError) as exc:
raise ValueError(f"the kernel facts could not be decoded: {exc}") from exc
if "[release]" not in report.splitlines():
raise ValueError("the kernel facts are empty: gzip or base64 is missing on the host")
return report
def _sections(report: str) -> Dict[str, List[str]]:
sections: Dict[str, List[str]] = {}
current: Optional[List[str]] = None
def _sections(report: str) -> dict[str, list[str]]:
sections: dict[str, list[str]] = {}
current: list[str] | None = None
for line in report.splitlines():
line = line.strip()
if line.startswith("[") and line.endswith("]"):
@@ -87,8 +90,8 @@ def _sections(report: str) -> Dict[str, List[str]]:
return sections
def _config(lines: List[str]) -> Dict[str, str]:
config: Dict[str, str] = {}
def _config(lines: list[str]) -> dict[str, str]:
config: dict[str, str] = {}
for line in lines:
option, sep, value = line.partition("=")
if not sep:
@@ -109,7 +112,7 @@ def parse_kernel_facts(output: str) -> KernelFactsDict:
"""
sections = _sections(_report(output))
def names(key: str) -> Optional[List[str]]:
def names(key: str) -> list[str] | None:
if key not in sections:
return None
return sorted({module_name(line) for line in sections[key]})
+8 -9
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Logical LAG entries for ``get_interfaces()``, built from their member ports.
Some switches list only physical ports, each tagged with the trunk it belongs
@@ -10,17 +9,17 @@ the same for every vendor, so it lives here once; a driver only has to set
from __future__ import annotations
import re
from typing import Any, Dict, List, Optional
from typing import Any
def _port_order(name: str) -> List[Any]:
def _port_order(name: str) -> list[Any]:
return [int(p) if p.isdigit() else p for p in re.split(r"(\d+)", name)]
def add_lag_interfaces(
interfaces: Dict[str, Dict[str, Any]],
lag_modes: Optional[Dict[str, str]] = None,
) -> Dict[str, Dict[str, Any]]:
interfaces: dict[str, dict[str, Any]],
lag_modes: dict[str, str] | None = None,
) -> dict[str, dict[str, Any]]:
"""Return *interfaces* plus one logical entry per ``trunk_group``.
The LAG entry is up/enabled if any member is, its speed is the members'
@@ -31,7 +30,7 @@ def add_lag_interfaces(
mode gets no ``lag_mode`` key rather than a guessed one.
"""
result = dict(interfaces)
groups: Dict[str, List[str]] = {}
groups: dict[str, list[str]] = {}
for name, iface in interfaces.items():
group = iface.get("trunk_group")
if group:
@@ -41,12 +40,12 @@ def add_lag_interfaces(
if group in result:
continue
members = sorted(members, key=_port_order)
lag: Dict[str, Any] = {
lag: dict[str, Any] = {
"is_up": any(interfaces[m].get("is_up") for m in members),
"is_enabled": any(interfaces[m].get("is_enabled") for m in members),
"description": f"LAG ({', '.join(members)})",
"last_flapped": -1.0,
"speed": sum(float(interfaces[m].get("speed") or 0) for m in members),
"speed": sum(max(float(interfaces[m].get("speed") or 0), 0.0) for m in members),
"mtu": -1,
"mac_address": "",
"lag_members": members,
+31
View File
@@ -0,0 +1,31 @@
"""Licenses and subscriptions a device reports.
A fact for the caller (docs/STRUCTURE.md): whether a free edition, a missing
subscription or a license a feature needs is a problem is decided above the
driver. Declared only; a driver that can read licenses implements it.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from napalm_device_types.models import LicenseDict
class LicenseMixin:
if TYPE_CHECKING: # pragma: no cover - declared for type checkers only
def get_licenses(self) -> list[LicenseDict]:
"""Every license or subscription the device knows of, with its status.
Example::
[{"name": "pve-subscription", "status": "none"},
{"name": "vSphere", "status": "active", "edition": "Free"},
{"name": "DCMS", "status": "missing",
"withholds": ["drives", "firmware inventory"]}]
``withholds`` names what a device keeps back for want of a license while
the rest of it answers; a missing license without it may keep back all.
"""
...
+28 -25
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Listening sockets: what listens on which address, and which service it is.
Whether a service is reachable from outside its host is decided by what it
@@ -34,7 +33,7 @@ from __future__ import annotations
import re
from shlex import quote
from typing import Dict, List, Optional, Tuple, TYPE_CHECKING
from typing import TYPE_CHECKING
from napalm_device_types.models import ListeningSocketDict, ListeningSocketsDict
from napalm_device_types.terminal import strip_terminal_codes
@@ -58,11 +57,11 @@ LISTENING_SOCKETS_COMMAND = (
"t=netstat; s=$(netstat -lntup 2>&1); r=$?; "
"pids=$(printf '%s\\n' \"$s\" | grep -o ' [0-9][0-9]*/' | tr -d ' /'); "
"else t=; fi; "
"if [ -n \"$t\" ]; then "
"echo \"[$t]\"; printf '%s\\n' \"$s\"; echo \"__SS_RC=$r\"; "
'if [ -n "$t" ]; then '
'echo "[$t]"; printf \'%s\\n\' "$s"; echo "__SS_RC=$r"; '
"echo '[cgroups]'; "
"for p in $(printf '%s\\n' \"$pids\" | sort -u); do "
"sed \"s|^|$p |\" /proc/$p/cgroup 2>/dev/null; done; "
'sed "s|^|$p |" /proc/$p/cgroup 2>/dev/null; done; '
"else echo '[no-ss]'; fi; "
"printf '%s%s\\n' SOCK_ END"
)
@@ -88,7 +87,7 @@ class ListeningSocketsUnavailable(NotImplementedError):
"""The host has neither ``ss`` nor ``netstat``; nothing to read, nothing to retry."""
def _frame(output: str) -> List[str]:
def _frame(output: str) -> list[str]:
lines = [line.strip() for line in strip_terminal_codes(output).splitlines()]
try:
start = lines.index(_BEGIN)
@@ -98,9 +97,9 @@ def _frame(output: str) -> List[str]:
return lines[start + 1 : end]
def _sections(lines: List[str]) -> Dict[str, List[str]]:
sections: Dict[str, List[str]] = {}
current: List[str] = []
def _sections(lines: list[str]) -> dict[str, list[str]]:
sections: dict[str, list[str]] = {}
current: list[str] = []
for line in lines:
if line.startswith("[") and line.endswith("]") and " " not in line:
current = sections.setdefault(line[1:-1], [])
@@ -109,20 +108,22 @@ def _sections(lines: List[str]) -> Dict[str, List[str]]:
return sections
def _split_local(local: str) -> Optional[Tuple[str, Optional[str], int]]:
"""``[fe80::1%eth0]:546`` -> ``("fe80::1", "eth0", 546)``; None if no port."""
def _split_local(local: str) -> tuple[str, str | None, int] | None:
"""``[fe80::1]%eth0:546`` (or ``[fe80::1%eth0]:546``) -> ``("fe80::1", "eth0", 546)``;
None if no port."""
host, sep, port = local.rpartition(":")
if not sep or not port.isdigit():
return None
if host.startswith("[") and host.endswith("]"):
host = host[1:-1]
address, _, zone = host.partition("%")
zone = zone.rstrip("]")
if address.startswith("["):
address = address[1:].rstrip("]")
return address or "*", zone or None, int(port)
def _cgroup_paths(lines: List[str]) -> Dict[int, str]:
def _cgroup_paths(lines: list[str]) -> dict[int, str]:
"""Each process's cgroup path: the unified hierarchy, or systemd's under v1."""
paths: Dict[int, str] = {}
paths: dict[int, str] = {}
for line in lines:
pid, _, entry = line.partition(" ")
parts = entry.split(":", 2)
@@ -134,7 +135,7 @@ def _cgroup_paths(lines: List[str]) -> Dict[int, str]:
return paths
def _unit(path: Optional[str]) -> Optional[str]:
def _unit(path: str | None) -> str | None:
services = _SERVICE_RE.findall(path or "")
if services:
return str(services[-1])
@@ -142,17 +143,17 @@ def _unit(path: Optional[str]) -> Optional[str]:
return str(procd.group(1)) if procd else None
def _container(path: Optional[str]) -> Optional[str]:
def _container(path: str | None) -> str | None:
match = _CONTAINER_RE.search(path or "")
return (match.group(1) or match.group(2)) if match else None
def _entry(
proto: str,
local: Tuple[str, Optional[str], int],
process: Optional[str],
pid: Optional[int],
paths: Dict[int, str],
local: tuple[str, str | None, int],
process: str | None,
pid: int | None,
paths: dict[int, str],
) -> ListeningSocketDict:
address, interface, port = local
path = paths.get(pid) if pid is not None else None
@@ -168,7 +169,7 @@ def _entry(
}
def _ss_socket(line: str, paths: Dict[int, str]) -> Optional[ListeningSocketDict]:
def _ss_socket(line: str, paths: dict[int, str]) -> ListeningSocketDict | None:
parts = line.split()
if len(parts) < 5 or parts[0] not in _PROTOCOLS:
return None
@@ -180,7 +181,7 @@ def _ss_socket(line: str, paths: Dict[int, str]) -> Optional[ListeningSocketDict
return _entry(parts[0], local, process, pid, paths)
def _netstat_socket(line: str, paths: Dict[int, str]) -> Optional[ListeningSocketDict]:
def _netstat_socket(line: str, paths: dict[int, str]) -> ListeningSocketDict | None:
"""``Proto Recv-Q Send-Q Local Foreign [State] PID/Program`` -- a UDP line
has no state, and ``-`` is a socket without a process."""
parts = line.split()
@@ -202,7 +203,7 @@ def _netstat_socket(line: str, paths: Dict[int, str]) -> Optional[ListeningSocke
_PARSERS = {"ss": _ss_socket, "netstat": _netstat_socket}
def parse_listening_sockets(output: str) -> List[ListeningSocketDict]:
def parse_listening_sockets(output: str) -> list[ListeningSocketDict]:
"""Parse what :data:`LISTENING_SOCKETS_COMMAND` printed, sorted by protocol,
port and address.
@@ -221,7 +222,9 @@ def parse_listening_sockets(output: str) -> List[ListeningSocketDict]:
paths = _cgroup_paths(sections.get("cgroups", []))
parse = _PARSERS[tool]
sockets = [s for s in (parse(line, paths) for line in lines) if s is not None]
return sorted(sockets, key=lambda s: (s["proto"], s["port"], s["address"], s["interface"] or ""))
return sorted(
sockets, key=lambda s: (s["proto"], s["port"], s["address"], s["interface"] or "")
)
class ListeningSocketsMixin:
+12 -9
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""MAC-based access control, per SSID on an AP and per port on a switch.
The reader is identical either way; only the writer is AP-specific so far.
@@ -10,7 +9,7 @@ this class in can never shadow a working implementation from a sibling base.
from __future__ import annotations
from typing import Dict, List, TYPE_CHECKING
from typing import TYPE_CHECKING
from napalm_device_types.models import MACACLDict
@@ -18,13 +17,13 @@ from napalm_device_types.models import MACACLDict
class MacAclMixin:
if TYPE_CHECKING:
def get_mac_acl(self) -> Dict[str, MACACLDict]:
def get_mac_acl(self) -> dict[str, MACACLDict]:
"""
Returns the MAC-address-based access control lists configured per SSID.
Keys are SSID names. Each value contains:
* ssid (string) - SSID name (repeated for convenience)
* name (string) - SSID name (repeated for convenience)
* policy (string) - ACL mode:
* ``"allow"`` – whitelist: only listed MACs may associate
@@ -44,22 +43,25 @@ class MacAclMixin:
"name": "CorpWiFi",
"policy": "allow",
"entries": [
{"mac": "AA:BB:CC:DD:EE:01", "action": "allow", "description": "CEO-Laptop"},
{"mac": "AA:BB:CC:DD:EE:02", "action": "allow", "description": "CFO-Laptop"},
{"mac": "AA:BB:CC:DD:EE:01", "action": "allow",
"description": "CEO-Laptop"},
{"mac": "AA:BB:CC:DD:EE:02", "action": "allow",
"description": "CFO-Laptop"},
],
},
"GuestNet": {
"name": "GuestNet",
"policy": "deny",
"entries": [
{"mac": "DE:AD:BE:EF:00:01", "action": "deny", "description": "blocked device"},
{"mac": "DE:AD:BE:EF:00:01", "action": "deny",
"description": "blocked device"},
],
},
}
"""
...
def push_mac_acl(self, ssid_name: str, mode: str, macs: List[str]) -> None:
def push_mac_acl(self, ssid_name: str, mode: str, macs: list[str]) -> None:
"""
Rewrites the MAC-address access control list for a single SSID.
@@ -73,7 +75,8 @@ class MacAclMixin:
Example::
driver.push_mac_acl("CorpWiFi", "whitelist", ["AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02"])
macs = ["AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02"]
driver.push_mac_acl("CorpWiFi", "whitelist", macs)
driver.push_mac_acl("GuestNet", "off", [])
"""
...
@@ -0,0 +1,151 @@
"""
Abstract base class for server baseboard management controllers (BMCs).
Usage::
from napalm_device_types import ManagementControllerDriver
class IdracDriver(ManagementControllerDriver):
...
"""
from typing import TYPE_CHECKING
from napalm_device_types.base import DeviceTypeDriver
if TYPE_CHECKING: # pragma: no cover - for type checkers only
from napalm_device_types.models import (
EventLogEntryDict,
FirmwareDict,
HardwareInventoryDict,
ManagementProtocolsDict,
PowerAction,
PowerStateDict,
)
class ManagementControllerDriver(DeviceTypeDriver):
"""
Abstract intermediate driver for the management controller of a server
(e.g. Supermicro BMC, Dell iDRAC, HPE iLO, Lenovo XCC), reached over
Redfish or IPMI rather than SSH.
A BMC is not the server it manages. ``reboot_host`` restarts the BMC
itself (Redfish ``Manager.Reset``), never the managed server; the server is
switched on, off and restarted through :meth:`set_power_state`.
The BMC's sensors (temperatures, fans, PSU power) come through NAPALM's
``get_environment``, with ``cpu`` and ``memory`` empty: a BMC does not know
the load of the operating system on its server. There is no
``HealthMetricsMixin`` here -- that is SNMP's -- and
``CommandChannelMixin`` stays unimplemented: a BMC offers no shell to run a
command in.
"""
#: Stable key netOrk exposes as ``device_class``. The order in which a
#: driver lists its role bases is the ranking; see
#: :func:`napalm_device_types.roles.primary_role_of`.
ROLE: str = "management_controller"
TYPE_LABEL: str = "Management Controller"
USES_SSH: bool = False
if TYPE_CHECKING:
def get_power_state(self) -> PowerStateDict:
"""
Returns the managed server's power state.
* state (string) - ``"On"``, ``"Off"``, ``"PoweringOn"`` or ``"PoweringOff"``
* actions (list) - the :data:`~napalm_device_types.models.PowerAction`
values the BMC accepts right now
Example::
{"state": "On", "actions": ["GracefulShutdown", "ForceOff", "ForceRestart"]}
"""
...
def set_power_state(self, action: PowerAction) -> None:
"""
Switches, shuts down or restarts the managed server.
Returns once the BMC has accepted the request; the server takes its
time. ``GracefulShutdown`` and ``GracefulRestart`` ask the operating
system (ACPI) and may be ignored by it.
:param action: One of ``On``, ``GracefulShutdown``, ``ForceOff``,
``GracefulRestart``, ``ForceRestart`` or ``PowerCycle``.
:raises ValueError: If *action* is not one of them.
:raises RuntimeError: If the BMC refuses it, e.g. ``On`` for a
server that is already on.
"""
...
def get_hardware_inventory(self) -> HardwareInventoryDict:
"""
Returns the managed server's hardware: CPUs, DIMMs, drives, power
supplies and NICs, with the server's serial number, its SMBIOS
system UUID and the MAC address of each NIC -- what ties the BMC to
the operating system running on the server.
Example::
{
"manufacturer": "Dell Inc.",
"model": "PowerEdge R650",
"serial_number": "7XK2Q93",
"system_uuid": "4c4c4544-0058-4b10-8032-b7c04f513933",
"cpus": [{"socket": "CPU.Socket.1", "model": "Intel(R) Xeon(R) Silver 4314",
"cores": 16, "threads": 32, "status": "OK"}],
"memory": [...],
"drives": [...],
"power_supplies": [...],
"nics": [{"name": "NIC.Integrated.1-1", "mac_address": "b0:7b:25:de:1a:40",
"speed_mbps": 10000, "link_up": True}],
}
"""
...
def get_event_log(self, limit: int = 500) -> list[EventLogEntryDict]:
"""
Returns the BMC's system event log (SEL, Lifecycle Log, IML),
newest first.
:param limit: At most this many entries.
"""
...
def get_firmware_inventory(self) -> list[FirmwareDict]:
"""
Returns every firmware component the BMC reports: its own, the
BIOS, storage controllers, NICs, power supplies.
Example::
[
{"name": "Integrated Dell Remote Access Controller",
"version": "7.00.00.172", "updateable": True},
{"name": "BIOS", "version": "1.13.2", "updateable": True},
]
"""
...
def get_management_protocols(self) -> ManagementProtocolsDict:
"""
Returns what the BMC itself listens on: IPMI, SSH, HTTP, HTTPS,
SNMP, the KVM console and virtual media, each with whether it is
enabled and its port.
Example::
{
"ipmi": {"enabled": False, "port": 623},
"ssh": {"enabled": True, "port": 22},
"http": {"enabled": False, "port": 80},
"https": {"enabled": True, "port": 443},
"snmp": {"enabled": False, "port": 161},
"kvm": {"enabled": True, "port": 5900},
"virtual_media": {"enabled": True, "port": None},
}
"""
...
+3 -4
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""
Abstract base class for networked media players.
@@ -10,7 +9,7 @@ Usage::
...
"""
from typing import TYPE_CHECKING, Any, Dict, List
from typing import TYPE_CHECKING, Any
from napalm_device_types.base import DeviceTypeDriver
@@ -34,7 +33,7 @@ class MediaDriver(DeviceTypeDriver):
if TYPE_CHECKING:
def get_playback_state(self) -> Dict[str, Any]:
def get_playback_state(self) -> dict[str, Any]:
"""
Returns the transport state of the renderer.
@@ -51,7 +50,7 @@ class MediaDriver(DeviceTypeDriver):
"""Sets the output volume, 0-100."""
...
def get_zone_info(self) -> List[Dict[str, Any]]:
def get_zone_info(self) -> list[dict[str, Any]]:
"""
Returns the zones or groups this renderer participates in.
File diff suppressed because it is too large. Load diff
+4 -5
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Address translation and VPN tunnels.
A home gateway does a subset of what a firewall does, and these readers are
@@ -11,7 +10,7 @@ this class in can never shadow a working implementation from a sibling base.
from __future__ import annotations
from typing import Dict, List, TYPE_CHECKING
from typing import TYPE_CHECKING
from napalm_device_types.models import NATTranslationDict, PortForwardDict, VPNTunnelDict
@@ -19,7 +18,7 @@ from napalm_device_types.models import NATTranslationDict, PortForwardDict, VPNT
class NatVpnMixin:
if TYPE_CHECKING:
def get_nat_translations(self) -> List[NATTranslationDict]:
def get_nat_translations(self) -> list[NATTranslationDict]:
"""
Returns a list of active NAT translation entries.
@@ -47,7 +46,7 @@ class NatVpnMixin:
"""
...
def get_port_forwards(self) -> List[PortForwardDict]:
def get_port_forwards(self) -> list[PortForwardDict]:
"""
Returns the port forwards that let traffic in from the WAN.
@@ -87,7 +86,7 @@ class NatVpnMixin:
"""
...
def get_vpn_tunnels(self) -> Dict[str, VPNTunnelDict]:
def get_vpn_tunnels(self) -> dict[str, VPNTunnelDict]:
"""
Returns the status of VPN tunnels.
+19 -16
View File
@@ -10,11 +10,9 @@ Usage::
...
"""
from typing import List, Optional, TYPE_CHECKING
from typing import TYPE_CHECKING
from napalm_device_types.base import DeviceTypeDriver
from napalm_device_types.updates import UpdateMixin
from napalm_device_types.services import ServiceControlMixin
from napalm_device_types.packages import PackageManagementMixin
from napalm_device_types.health_metrics import HealthMetricsMixin
from napalm_device_types.models import (
CronJobDict,
@@ -23,9 +21,14 @@ from napalm_device_types.models import (
SNMPConfigDict,
UserDict,
)
from napalm_device_types.packages import PackageManagementMixin
from napalm_device_types.services import ServiceControlMixin
from napalm_device_types.updates import UpdateMixin
class OSDriver(UpdateMixin, ServiceControlMixin, PackageManagementMixin, HealthMetricsMixin, DeviceTypeDriver):
class OSDriver(
UpdateMixin, ServiceControlMixin, PackageManagementMixin, HealthMetricsMixin, DeviceTypeDriver
):
"""
Abstract intermediate driver for general-purpose operating systems
(e.g. Linux, BSD, macOS).
@@ -36,33 +39,28 @@ class OSDriver(UpdateMixin, ServiceControlMixin, PackageManagementMixin, HealthM
#: Stable key netOrk exposes as ``device_class``. The order in which a
#: driver lists its role bases is the ranking; see
#: :func:`napalm_device_types.roles.primary_role_of`.
#: :func:`napalm_device_types.roles.primary_role_of`. A driver for another
#: OS sets its own (``ROLE = "windows"``), which replaces this one.
ROLE: str = "linux"
TYPE_LABEL: str = "OS"
# Interfaces matching this pattern are excluded from health-metric collection.
# Set True for drivers where the out-error counter actually reports drops (e.g. OPNsense).
# ------------------------------------------------------------------
# Package management
# ------------------------------------------------------------------
if TYPE_CHECKING:
# ------------------------------------------------------------------
# Service management
# ------------------------------------------------------------------
# ------------------------------------------------------------------
# Users
# ------------------------------------------------------------------
def get_users(self) -> List[UserDict]:
def get_os_users(self) -> list[UserDict]:
"""
Returns a list of local OS user accounts.
@@ -94,7 +92,7 @@ class OSDriver(UpdateMixin, ServiceControlMixin, PackageManagementMixin, HealthM
# Processes
# ------------------------------------------------------------------
def get_processes(self) -> List[ProcessDict]:
def get_processes(self) -> list[ProcessDict]:
"""
Returns a snapshot of currently running processes.
@@ -137,7 +135,7 @@ class OSDriver(UpdateMixin, ServiceControlMixin, PackageManagementMixin, HealthM
# Cron jobs
# ------------------------------------------------------------------
def get_cron_jobs(self) -> List[CronJobDict]:
def get_cron_jobs(self) -> list[CronJobDict]:
"""
Returns scheduled cron tasks from all user crontabs and ``/etc/cron.d``.
@@ -165,7 +163,7 @@ class OSDriver(UpdateMixin, ServiceControlMixin, PackageManagementMixin, HealthM
# SNMP
# ------------------------------------------------------------------
def get_snmp_config(self) -> Optional[SNMPConfigDict]:
def get_snmp_config(self) -> SNMPConfigDict | None:
"""
Returns the SNMP agent configuration currently active on the device,
or ``None`` if no SNMP daemon is running or detectable.
@@ -200,6 +198,11 @@ class OSDriver(UpdateMixin, ServiceControlMixin, PackageManagementMixin, HealthM
"""
Executes a named administrative action on the device.
**Deprecated** (docs/STRUCTURE.md): the actions become fix templates
in :mod:`napalm_device_types.fixes`, whose values the caller supplies --
``configure_snmp_agent``, ``configure_package_proxy``,
``grant_container_engine_access``, ``ensure_package``. Goes in 5.0.
This method is an extensibility point for driver-specific one-off
operations that do not fit any other NAPALM API method. Each driver
documents the action names it supports.
+30
View File
@@ -0,0 +1,30 @@
"""A device's OSPF state, in neutral terms.
The routing daemon's own output (FRR, Quagga, a vendor's CLI) is the driver's
to translate; whether an interface without neighbours or without
authentication is a problem is the caller's to decide.
Declared only; a driver whose device runs OSPF implements it.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from napalm_device_types.models import OspfDict
class OspfMixin:
if TYPE_CHECKING: # pragma: no cover - declared for type checkers only
def get_ospf(self) -> OspfDict:
"""Router ID, areas and OSPF per interface.
Example::
{"router_id": "10.255.0.1", "areas": ["0.0.0.0"],
"interfaces": {"wg0": {"address": "10.255.0.1", "prefix_length": 24,
"area": "0.0.0.0", "network_type": "POINTOPOINT",
"authentication": "MessageDigest", "neighbors": 1,
"neighbors_adjacent": 1, "cost": 10}}}
"""
...
+27 -21
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Pending package updates: which package, from where, and whether it is a security fix.
A patch deadline -- "security updates within 14 days" -- needs to know which
@@ -16,7 +15,6 @@ security -- netOrk's CVE matching is what catches those.
from __future__ import annotations
import re
from typing import Dict, List, Set
from napalm_device_types.models import UpdateDict
from napalm_device_types.terminal import strip_terminal_codes
@@ -28,41 +26,46 @@ from napalm_device_types.terminal import strip_terminal_codes
#: so it is apt's, not cat's.
APT_UPGRADABLE_COMMAND = "{ LC_ALL=C apt list --upgradable 2>/dev/null; echo __APT_RC=$?; } | cat"
#: Read-only. Lists the packages that a pending security advisory covers.
DNF_SECURITY_COMMAND = "LC_ALL=C dnf updateinfo list --security --quiet 2>/dev/null"
#: Read-only. Lists the packages that a pending security advisory covers. A
#: failed dnf prints nothing, the same as no advisories, so its exit status is
#: printed inside the group, as apt's is.
DNF_SECURITY_COMMAND = (
"{ LC_ALL=C dnf updateinfo list --security --quiet 2>/dev/null; echo __DNF_RC=$?; } | cat"
)
# openssl/noble-updates,noble-security 3.0.13-0ubuntu3.6 amd64 [upgradable from: 3.0.13-0ubuntu3.5]
_APT_LINE = re.compile(r"^(\S+)/(\S+)\s+(\S+)\s+\S+\s+\[upgradable from:\s+(\S+)\]")
_SECURITY_SUITE = "-security"
_APT_STATUS = re.compile(r"^__APT_RC=(\d+)\s*$", re.MULTILINE)
_DNF_STATUS = re.compile(r"^__DNF_RC=(\d+)\s*$", re.MULTILINE)
def _joined_lines(output: str) -> List[str]:
def _joined_lines(output: str) -> list[str]:
"""Lines as apt printed them: a terminal wraps long ones, and the
continuation starts with a space."""
lines: List[str] = []
lines: list[str] = []
for line in output.splitlines():
if line.startswith(" ") and lines:
lines[-1] += line.strip()
lines[-1] += line[1:].rstrip()
else:
lines.append(line)
return lines
def _apt_listing(output: str) -> str:
"""The listing without its exit status, or ``ValueError`` when apt failed or
the output was cut short -- "could not read" must never look like "nothing
pending"."""
def _listing(output: str, status: re.Pattern[str], what: str) -> str:
"""The listing without its exit status, or ``ValueError`` when the command
failed or the output was cut short -- "could not read" must never look like
"nothing pending"."""
text = strip_terminal_codes(output)
statuses = _APT_STATUS.findall(text)
statuses = status.findall(text)
if not statuses:
raise ValueError("apt list --upgradable reported no exit status; the output was cut short")
raise ValueError(f"{what} reported no exit status; the output was cut short")
if statuses[-1] != "0":
raise ValueError(f"apt list --upgradable failed with exit status {statuses[-1]}")
return _APT_STATUS.sub("", text)
raise ValueError(f"{what} failed with exit status {statuses[-1]}")
return status.sub("", text)
def parse_apt_upgradable(output: str) -> List[UpdateDict]:
def parse_apt_upgradable(output: str) -> list[UpdateDict]:
"""Parse :data:`APT_UPGRADABLE_COMMAND`'s output, one entry per package.
A package listed for several architectures (``libc6`` for amd64 and i386)
@@ -70,8 +73,8 @@ def parse_apt_upgradable(output: str) -> List[UpdateDict]:
:raises ValueError: when apt failed or its exit status never arrived.
"""
by_name: Dict[str, UpdateDict] = {}
for line in _joined_lines(_apt_listing(output)):
by_name: dict[str, UpdateDict] = {}
for line in _joined_lines(_listing(output, _APT_STATUS, "apt list --upgradable")):
match = _APT_LINE.match(line)
if not match:
continue
@@ -98,13 +101,16 @@ def nevra_name(nevra: str) -> str:
return without_arch.rsplit("-", 2)[0]
def parse_dnf_security(output: str) -> Set[str]:
def parse_dnf_security(output: str) -> set[str]:
"""The names of the packages a pending security advisory covers.
Parses :data:`DNF_SECURITY_COMMAND`'s ``ADVISORY SEVERITY/Sec. NEVRA`` lines.
:raises ValueError: when dnf failed or its exit status never arrived -- every
update would otherwise read as not security.
"""
names: Set[str] = set()
for line in output.splitlines():
names: set[str] = set()
for line in _listing(output, _DNF_STATUS, "dnf updateinfo list --security").splitlines():
parts = line.split()
if len(parts) >= 3 and parts[1].endswith("/Sec."):
names.add(nevra_name(parts[-1]))
+4 -9
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Listing and managing installed software.
Identical on every device type that has a package manager -- this was five
@@ -12,7 +11,7 @@ this class in can never shadow a working implementation from a sibling base.
from __future__ import annotations
from typing import Any, Dict, List, TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from napalm_device_types.models import PackageDict
@@ -20,7 +19,7 @@ from napalm_device_types.models import PackageDict
class PackageManagementMixin:
if TYPE_CHECKING:
def get_packages(self) -> List[PackageDict]:
def get_packages(self) -> list[PackageDict]:
"""
Returns all packages currently known to the device's package manager
(e.g. ``opkg`` on OpenWrt, ``apk`` on Alpine-based APs).
@@ -68,7 +67,6 @@ class PackageManagementMixin:
:param name: Package name as known to the package manager.
:param version: Exact version to install. An empty string (default)
installs the latest available version.
:raises NotImplementedError: If the driver does not support package management.
:raises ValueError: If the package name is unknown or the version does
not exist in any configured feed.
:raises RuntimeError: If the installation fails on the device side
@@ -88,7 +86,6 @@ class PackageManagementMixin:
The method blocks until the removal is complete.
:param name: Package name to remove.
:raises NotImplementedError: If the driver does not support package management.
:raises ValueError: If the package is not currently installed.
:raises RuntimeError: If the removal fails on the device side
(e.g. other packages depend on it).
@@ -99,13 +96,12 @@ class PackageManagementMixin:
"""
...
def get_package_config(self, name: str) -> Dict[str, Any]:
def get_package_config(self, name: str) -> dict[str, Any]:
"""
Returns the current configuration of an installed package as a
dictionary. The structure is package-specific.
:param name: Package name.
:raises NotImplementedError: If the driver does not support package management.
:raises ValueError: If the package is not installed.
Example::
@@ -126,7 +122,7 @@ class PackageManagementMixin:
"""
...
def set_package_config(self, name: str, config: Dict[str, Any]) -> None:
def set_package_config(self, name: str, config: dict[str, Any]) -> None:
"""
Writes a new configuration for an installed package.
@@ -140,7 +136,6 @@ class PackageManagementMixin:
:param name: Package name.
:param config: New configuration as a nested dictionary.
:raises NotImplementedError: If the driver does not support package management.
:raises ValueError: If the package is not installed or the configuration
contains invalid values.
:raises RuntimeError: If the device rejects the configuration.
+3 -4
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""
Abstract base class for IP telephony endpoints.
@@ -10,7 +9,7 @@ Usage::
...
"""
from typing import TYPE_CHECKING, Any, Dict, List
from typing import TYPE_CHECKING, Any
from napalm_device_types.base import DeviceTypeDriver
@@ -36,7 +35,7 @@ class PhoneDriver(DeviceTypeDriver):
if TYPE_CHECKING:
def get_sip_accounts(self) -> List[Dict[str, Any]]:
def get_sip_accounts(self) -> list[dict[str, Any]]:
"""
Returns the SIP registrations configured on the phone.
@@ -60,7 +59,7 @@ class PhoneDriver(DeviceTypeDriver):
"""
...
def get_call_status(self) -> Dict[str, Any]:
def get_call_status(self) -> dict[str, Any]:
"""
Returns what the phone is doing right now.
+16 -9
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Generic ICMP sweep on top of the NAPALM-standard ``ping()``.
Sweeping a range of addresses is orchestration, not device mechanics: the
@@ -21,7 +20,8 @@ target list (see ``PING_SWEEP_MAX_TARGETS``).
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Dict, Iterable, List, Optional
from collections.abc import Callable, Iterable
from typing import TYPE_CHECKING, Any, ClassVar
from napalm.base import NetworkDriver
@@ -60,7 +60,7 @@ class PingSweepMixin:
#: ``False`` opts a driver out of ping sweeps even though it implements
#: ``ping()``. ``None`` (the default) means "decide by introspection".
SUPPORTS_PING: ClassVar[Optional[bool]] = None
SUPPORTS_PING: ClassVar[bool | None] = None
if TYPE_CHECKING: # pragma: no cover - declared for type checkers only
@@ -73,7 +73,7 @@ class PingSweepMixin:
size: int = 100,
count: int = 5,
vrf: str = "",
) -> Dict[str, Any]: ...
) -> dict[str, Any]: ...
@classmethod
def supports_ping(cls) -> bool:
@@ -86,9 +86,9 @@ class PingSweepMixin:
*,
count: int = 1,
timeout: int = 1,
max_targets: Optional[int] = None,
on_progress: Optional[Callable[[int, int], None]] = None,
should_stop: Optional[Callable[[], bool]] = None,
max_targets: int | None = None,
on_progress: Callable[[int, int], None] | None = None,
should_stop: Callable[[], bool] | None = None,
) -> PingSweepResultDict:
"""Ping every address in *destinations* and report who answered.
@@ -115,7 +115,7 @@ class PingSweepMixin:
targets = targets[:limit]
total = len(targets)
entries: List[PingSweepEntryDict] = []
entries: list[PingSweepEntryDict] = []
for done, destination in enumerate(targets, start=1):
if should_stop is not None and should_stop():
break
@@ -150,6 +150,13 @@ class PingSweepMixin:
return {"ip": destination, "alive": False, "rtt_ms": None, "error": error}
success = reply.get("success") or {}
if not isinstance(success, dict):
return {
"ip": destination,
"alive": False,
"rtt_ms": None,
"error": "malformed ping reply",
}
probes_sent = _as_int(success.get("probes_sent"))
packet_loss = _as_int(success.get("packet_loss"), default=probes_sent)
alive = bool(success.get("results")) or probes_sent > packet_loss
@@ -165,7 +172,7 @@ def _as_int(value: Any, default: int = 0) -> int:
return default
def _as_float(value: Any) -> Optional[float]:
def _as_float(value: Any) -> float | None:
try:
return float(value)
except (TypeError, ValueError):
View File
Whitespace-only changes.
+8 -11
View File
@@ -19,11 +19,11 @@ Usage::
...
"""
from typing import Dict, List, TYPE_CHECKING
from typing import TYPE_CHECKING
from napalm_device_types.base import DeviceTypeDriver
from napalm_device_types.nat_vpn import NatVpnMixin
from napalm_device_types.health_metrics import HealthMetricsMixin
from napalm_device_types.dhcp import DhcpServerMixin
from napalm_device_types.health_metrics import HealthMetricsMixin
from napalm_device_types.models import (
HostDict,
RadioStatusDict,
@@ -31,6 +31,7 @@ from napalm_device_types.models import (
WANStatusDict,
WirelessClientDict,
)
from napalm_device_types.nat_vpn import NatVpnMixin
class ResidentialGatewayDriver(NatVpnMixin, HealthMetricsMixin, DhcpServerMixin, DeviceTypeDriver):
@@ -48,8 +49,6 @@ class ResidentialGatewayDriver(NatVpnMixin, HealthMetricsMixin, DhcpServerMixin,
ROLE: str = "residential_gateway"
TYPE_LABEL: str = "Gateway"
if TYPE_CHECKING:
def get_wan_status(self) -> WANStatusDict:
@@ -85,7 +84,7 @@ class ResidentialGatewayDriver(NatVpnMixin, HealthMetricsMixin, DhcpServerMixin,
"""
...
def get_hosts(self) -> List[HostDict]:
def get_hosts(self) -> list[HostDict]:
"""
Returns the list of hosts known to the gateway (LAN clients).
@@ -113,9 +112,7 @@ class ResidentialGatewayDriver(NatVpnMixin, HealthMetricsMixin, DhcpServerMixin,
"""
...
def get_wireless_clients(self) -> List[WirelessClientDict]:
def get_wireless_clients(self) -> list[WirelessClientDict]:
"""
Returns the list of wireless clients currently associated with the
device's built-in access point(s).
@@ -125,7 +122,7 @@ class ResidentialGatewayDriver(NatVpnMixin, HealthMetricsMixin, DhcpServerMixin,
"""
...
def get_ssids(self) -> Dict[str, SSIDDict]:
def get_ssids(self) -> dict[str, SSIDDict]:
"""
Returns the configured wireless networks (SSIDs).
@@ -134,7 +131,7 @@ class ResidentialGatewayDriver(NatVpnMixin, HealthMetricsMixin, DhcpServerMixin,
"""
...
def get_radio_status(self) -> Dict[str, RadioStatusDict]:
def get_radio_status(self) -> dict[str, RadioStatusDict]:
"""
Returns the status of the device's wireless radios.
+10 -7
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Which roles a driver class fills, and which of them is primary.
A *role* is what a device is: a firewall, a switch, a NAS. Real devices are
@@ -22,7 +21,7 @@ when a concrete driver provided it.
from __future__ import annotations
from typing import Any, List, Optional, Type
from typing import Any
def _is_role_base(cls: type) -> bool:
@@ -36,21 +35,25 @@ def _is_role_base(cls: type) -> bool:
return isinstance(role, str) and bool(role)
def roles_of(driver_cls: type) -> List[Type[Any]]:
def roles_of(driver_cls: type) -> list[type[Any]]:
"""Every role base in ``driver_cls``'s MRO, most significant first.
MRO order is inheritance order, so the list reflects exactly what the driver
author wrote in the class definition.
author wrote in the class definition. A class that sets ``ROLE`` itself
replaces the role it inherits rather than adding one: a Windows driver on
:class:`~napalm_device_types.os.OSDriver` sets ``ROLE = "windows"`` and is
not also ``"linux"``.
"""
return [cls for cls in driver_cls.__mro__ if _is_role_base(cls)]
roles = [cls for cls in driver_cls.__mro__ if _is_role_base(cls)]
return [cls for cls in roles if not any(o is not cls and issubclass(o, cls) for o in roles)]
def role_keys_of(driver_cls: type) -> List[str]:
def role_keys_of(driver_cls: type) -> list[str]:
"""The stable string keys of :func:`roles_of`, e.g. ``["storage", "linux"]``."""
return [vars(cls)["ROLE"] for cls in roles_of(driver_cls)]
def primary_role_of(driver_cls: type) -> Optional[str]:
def primary_role_of(driver_cls: type) -> str | None:
"""The driver's headline role, or ``None`` when it declares no role at all.
This is the value netOrk exposes as ``device_class``.
+538
View File
@@ -0,0 +1,538 @@
"""Service clusters: the configuration that says which services run together.
Two hosts that answer at one virtual IP may run the same service by accident.
What proves a cluster is the configuration its members share. That is read the
same way on every Linux host, so the command and its parse live here once, and
a driver only carries the command across (netOrk #1125).
**What is read.**
- *Patroni*: every ``patroni.service`` and ``patroni@*.service`` unit systemd
knows. Its configuration file -- the unit's first non-flag argument, else
``/etc/patroni/config.yml`` -- gives ``scope``, ``name`` and
``restapi.connect_address``. The REST API there (``/cluster``, which Patroni
answers without a login) lists the members its DCS knows, each with its role:
``leader``, ``sync_standby``, ``replica``. When it does not answer, the host
is a member of its scope on its own.
- *adguardhome-sync*: a running container whose image is adguardhome-sync.
``ORIGIN_URL`` and ``REPLICA*_URL`` name the AdGuard Home instances it keeps in
step: the origin pushes to every replica. A sync configured by a YAML file
instead of its environment is not read.
- *BunkerWeb*: a running instance container (``bunkerity/bunkerweb``) admits a
manager's configuration pushes from the addresses in ``API_WHITELIST_IP``; a
manager (``bunkerweb-all-in-one``, ``bunkerweb-scheduler``) names its
instances in ``BUNKERWEB_INSTANCES``. BunkerWeb's own default whitelist,
loopback and the docker network, names no one. A manager that keeps its
instances in its database names none here: its workers name it.
**Filtered on the host.** The same configuration holds secrets -- Patroni's
superuser and REST passwords, the sync's AdGuard logins, BunkerWeb's database
URI. The command never prints a configuration: awk programs print allowlisted
fields only, and a URL loses its ``user:password@``. The DCS endpoints stay on
the host too; the members come from ``/cluster``.
**None is "did not look".** A Patroni configuration that exists but cannot be
read, a host without systemd, a docker the login may not ask, a sync configured
by file: that mechanism is None, and the caller keeps what it knew. ``[]`` is a
host that has none.
**Root, and without it.** Patroni's configuration is the postgres user's alone.
The whole script goes to the host as one ``sh -c`` argument; when the privileged
call brings no report back, it runs again without privilege.
"""
from __future__ import annotations
import ipaddress
import json
import re
from collections.abc import Callable
from shlex import quote
from typing import TYPE_CHECKING
from urllib.parse import urlsplit
from napalm_device_types.models import (
ServiceClusterDict,
ServiceClusterMemberDict,
ServiceClustersDict,
)
from napalm_device_types.terminal import strip_terminal_codes
_BEGIN = "SVCC_BEGIN"
_END = "SVCC_END"
#: Exists only while systemd runs as PID 1; a container may carry systemctl without.
_SYSTEMD_RUN = "/run/systemd/system"
_PATRONI_CONFIG = "/etc/patroni/config.yml"
#: The container environment that may leave the host.
_ENV_KEYS = "ORIGIN_URL|REPLICA[0-9]*_URL|API_WHITELIST_IP|BUNKERWEB_INSTANCES|API_HTTP_PORT"
#: BunkerWeb's instance API, where a manager pushes its configuration.
_BUNKERWEB_API_PORT = 5000
#: Fed a unit's ``systemctl show`` output: ``active <state>`` and ``config <path>``,
#: the first argument of the command line that is no flag.
_UNIT_AWK = " ".join(
(
r'/^ActiveState=/ { print "active " substr($0, 13); next }',
r'/^ExecStart=/ { s = $0; i = index(s, "argv[]="); if (!i) next; s = substr(s, i + 7);'
r' j = index(s, " ;"); if (j) s = substr(s, 1, j - 1); n = split(s, t, " ");'
r' for (k = 2; k <= n; k++) if (!c && t[k] !~ /^-/) { print "config " t[k]; c = 1 } }',
)
)
#: Fed Patroni's YAML: ``scope``, ``name`` (top level only -- ``tags`` may carry a
#: ``name`` too), ``restapi`` (its connect_address) and ``tls yes`` when the REST
#: API has a certificate. Nothing else leaves the file.
_PATRONI_AWK = " ".join(
(
r'function v(l) { sub(/^[^:]*:[ \t]*/, "", l); return l }',
r'/^[A-Za-z_][A-Za-z0-9_]*:/ { sec = $0; sub(/:.*/, "", sec);'
r' if (sec == "scope" || sec == "name") print sec " " v($0); next }',
r'sec == "restapi" && /^[ \t]+connect_address:/ { print "restapi " v($0) }',
r'sec == "restapi" && /^[ \t]+certfile:/ { print "tls yes" }',
)
)
#: Fed a container's environment, one ``KEY=value`` a line: the allowlisted keys,
#: each URL without its ``user:password@``.
_ENV_AWK = " ".join(
(
r'{ x = index($0, "="); if (!x) next; k = substr($0, 1, x - 1); v = substr($0, x + 1);',
rf" if (k !~ /^({_ENV_KEYS})$/) next;",
r' gsub(/:\/\/[^@\/ ]*@/, "://", v); print "env " k " " v }',
)
)
_PATRONI_SH = (
f"if command -v systemctl >/dev/null 2>&1 && [ -d {_SYSTEMD_RUN} ]; then echo '[systemd]'; "
"for u in $(systemctl list-units --all --plain --no-legend --type=service "
"patroni.service 'patroni@*' 2>/dev/null | awk '{print $1}'); do "
'echo "[patroni $u]"; '
'o=$(systemctl show -p ActiveState -p ExecStart "$u" 2>/dev/null | '
f"awk {quote(_UNIT_AWK)}); "
'echo "$o"; '
"f=$(echo \"$o\" | sed -n 's/^config //p' | head -n 1); "
f'[ -n "$f" ] || f={_PATRONI_CONFIG}; '
'if [ -r "$f" ]; then '
f'p=$(awk {quote(_PATRONI_AWK)} "$f"); echo "$p"; '
"r=$(echo \"$p\" | sed -n 's/^restapi //p' | head -n 1 | tr -d '\\042\\047 '); "
's=http; case "$p" in *"tls yes"*) s=https;; esac; '
'if [ -n "$r" ]; then '
'b=$(curl -s -k -m 3 "$s://$r/cluster" 2>/dev/null || '
'wget -q -T 3 --no-check-certificate -O - "$s://$r/cluster" 2>/dev/null); '
'if [ -n "$b" ]; then echo "rest $(echo "$b" | tr -d \'\\n\')"; '
"else echo rest-unreachable; fi; fi; "
'elif [ -e "$f" ]; then echo "unreadable $f"; '
'else echo "missing $f"; fi; '
"done; "
"else echo '[no-systemd]'; fi; "
)
def _docker_sh(binary: str | None) -> str:
"""The containers part: running containers of the clustered images, their
allowlisted environment. ``[no-docker]`` for a host without the engine."""
if not binary:
return "echo '[no-docker]'; "
return (
f"D={quote(binary)}; "
'if command -v "$D" >/dev/null 2>&1; then '
"if c=$(\"$D\" ps --format '{{.Names}}' 2>/dev/null); then echo '[docker]'; "
"for n in $c; do "
'i=$("$D" inspect --format \'{{.Config.Image}}\' "$n" 2>/dev/null); '
'case "$i" in *adguardhome-sync*|*bunkerweb*) '
'echo "[container $n]"; echo "image $i"; '
'"$D" inspect --format \'{{range .Config.Env}}{{println .}}{{end}}\' "$n" '
f"2>/dev/null | awk {quote(_ENV_AWK)};; esac; done; "
"else echo '[docker-unreadable]'; fi; "
"else echo '[no-docker]'; fi; "
)
def service_clusters_command(docker: str | None = "docker") -> str:
"""One line of POSIX ``sh``, read-only. *docker* is the engine's binary as the
driver reaches it (QNAP's lives elsewhere), None for a host without one. The
frame markers are printed in two halves so that a transport which echoes the
command does not show them early."""
return (
"PATH=$PATH:/usr/sbin:/sbin; "
"printf '%s%s\\n' SVCC_ BEGIN; "
+ _PATRONI_SH
+ _docker_sh(docker)
+ "printf '%s%s\\n' SVCC_ END"
)
SERVICE_CLUSTERS_COMMAND = service_clusters_command()
_COMMENT_RE = re.compile(r"\s+#.*$")
_Record = tuple[str, str]
_Section = tuple[str, str, list[_Record]]
def _frame(output: str) -> list[str]:
lines = [line.strip() for line in strip_terminal_codes(output).splitlines()]
try:
start = lines.index(_BEGIN)
end = lines.index(_END, start)
except ValueError:
raise ValueError("no intact service cluster report in the output") from None
return lines[start + 1 : end]
def _sections(lines: list[str]) -> list[_Section]:
"""``[<kind> <name>]`` headers, each with its records as ``(kind, rest)``."""
sections: list[_Section] = []
for line in lines:
if line.startswith("[") and line.endswith("]"):
kind, _, name = line[1:-1].partition(" ")
sections.append((kind, name, []))
elif line and sections:
kind, _, rest = line.partition(" ")
sections[-1][2].append((kind, rest.strip()))
return sections
def _clean(value: str) -> str:
"""Without a trailing ``# comment`` and the quotes around it."""
value = _COMMENT_RE.sub("", value.strip())
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
value = value[1:-1]
return value.strip()
def _member(
*,
address: str | None = None,
port: int | None = None,
name: str | None = None,
role: str | None = None,
state: str | None = None,
this_host: bool = False,
config_from: int | None = None,
) -> ServiceClusterMemberDict:
return {
"name": name,
"address": address,
"port": port,
"role": role,
"state": state,
"this_host": this_host,
"config_from": config_from,
}
def _cluster(
mechanism: str,
service: str,
source: str,
cluster: str | None,
members: list[ServiceClusterMemberDict],
running: bool | None = True,
) -> ServiceClusterDict:
return {
"mechanism": mechanism,
"service": service,
"source": source,
"cluster": cluster,
"running": running,
"members": members,
}
def _host_port(text: str) -> tuple[str | None, int | None]:
"""``"10.0.0.5:8008"`` or ``"[fd00::5]:8008"`` -> host and port."""
try:
split = urlsplit(f"//{text.strip()}")
return split.hostname, split.port
except ValueError:
return None, None
def _url(text: str) -> tuple[str | None, int | None]:
"""A URL's host and port, the scheme's default where it names none."""
value = text.strip()
if "://" not in value:
value = f"http://{value}"
try:
split = urlsplit(value)
port = split.port or {"http": 80, "https": 443}.get(split.scheme)
except ValueError:
return None, None
return split.hostname, port
# --- Patroni ------------------------------------------------------------------
def _patroni_members(rest: str, own: str | None) -> list[ServiceClusterMemberDict] | None:
try:
members = json.loads(rest).get("members")
except (ValueError, AttributeError):
return None
if not isinstance(members, list):
return None
found = []
for entry in members:
if not isinstance(entry, dict) or not entry.get("name"):
continue
port = entry.get("port")
found.append(
_member(
name=str(entry["name"]),
address=str(entry["host"]) if entry.get("host") else None,
port=port if isinstance(port, int) else None,
role=str(entry["role"]) if entry.get("role") else None,
state=str(entry["state"]) if entry.get("state") else None,
this_host=entry["name"] == own,
)
)
return sorted(found, key=lambda m: m["name"] or "")
def _patroni(unit: str, records: list[_Record]) -> list[ServiceClusterDict]:
values: dict[str, str] = {}
rest: str | None = None
for kind, text in records:
if kind in ("active", "scope", "name", "restapi") and kind not in values:
values[kind] = _clean(text)
elif kind == "rest":
rest = text
scope = values.get("scope")
if not scope:
return []
own = values.get("name") or None
members = _patroni_members(rest, own) if rest else None
if not members:
address, _ = _host_port(values.get("restapi", ""))
members = [_member(name=own, address=address, this_host=True)]
running = values["active"] == "active" if "active" in values else None
source = unit[: -len(".service")] if unit.endswith(".service") else unit
return [_cluster("patroni", "patroni", source, scope, members, running)]
# --- Containers ----------------------------------------------------------------
def _env(records: list[_Record]) -> dict[str, str]:
env: dict[str, str] = {}
for kind, text in records:
if kind == "env":
key, _, value = text.partition(" ")
env[key] = value.strip()
return env
def _image_name(records: list[_Record]) -> str:
"""``bunkerity/bunkerweb@sha256:...`` -> ``bunkerweb``."""
image = next((text for kind, text in records if kind == "image"), "")
return image.split("@")[0].rsplit("/", 1)[-1].split(":")[0]
def _adguardhome_sync(name: str, records: list[_Record]) -> list[ServiceClusterDict] | None:
env = _env(records)
origin = _url(env["ORIGIN_URL"]) if env.get("ORIGIN_URL") else (None, None)
if origin[0] is None:
return None # configured by its YAML file, which is not read
replicas = sorted(
(key for key in env if re.fullmatch(r"REPLICA[0-9]*_URL", key)),
key=lambda key: int(key[7:-4] or 0),
)
members = [_member(address=origin[0], port=origin[1], role="origin")]
for key in replicas:
host, port = _url(env[key])
if host:
members.append(_member(address=host, port=port, role="replica", config_from=0))
cluster = f"{origin[0]}:{origin[1]}" if origin[1] else origin[0]
return [_cluster("adguardhome-sync", "adguardhome", name, cluster, members)]
def _peer_address(entry: str) -> str | None:
"""An entry of a whitelist that names one peer: a single address that is no
loopback, link-local or unspecified one -- not a network."""
text = entry.strip()
if "/" in text:
try:
network = ipaddress.ip_network(text, strict=False)
except ValueError:
return None
if network.num_addresses != 1:
return None
text = str(network.network_address)
try:
address = ipaddress.ip_address(text)
except ValueError:
return None
if address.is_loopback or address.is_link_local or address.is_unspecified:
return None
return str(address)
def _api_port(env: dict[str, str]) -> int:
port = env.get("API_HTTP_PORT", "")
return int(port) if port.isdigit() else _BUNKERWEB_API_PORT
def _bunkerweb_worker(name: str, env: dict[str, str]) -> list[ServiceClusterDict]:
managers = [
address
for entry in re.split(r"[\s,]+", env.get("API_WHITELIST_IP", ""))
if entry and (address := _peer_address(entry))
]
if not managers:
return []
members = [_member(port=_api_port(env), role="worker", this_host=True, config_from=1)]
members += [_member(address=address, role="manager") for address in managers]
return [_cluster("bunkerweb", "bunkerweb", name, managers[0], members)]
def _bunkerweb_manager(name: str, env: dict[str, str]) -> list[ServiceClusterDict]:
instances = [entry for entry in env.get("BUNKERWEB_INSTANCES", "").split() if entry]
if not instances:
return []
members = [_member(role="manager", this_host=True)]
for entry in instances:
host, port = _host_port(entry)
if host:
members.append(
_member(
address=host, port=port or _BUNKERWEB_API_PORT, role="worker", config_from=0
)
)
return [_cluster("bunkerweb", "bunkerweb", name, None, members)] if len(members) > 1 else []
def _bunkerweb(name: str, records: list[_Record]) -> list[ServiceClusterDict]:
image = _image_name(records)
if image == "bunkerweb":
return _bunkerweb_worker(name, _env(records))
if image in ("bunkerweb-all-in-one", "bunkerweb-scheduler"):
return _bunkerweb_manager(name, _env(records))
return [] # the UI, the API, autoconf: no instance and no manager
_CONTAINER_READERS: dict[str, Callable[[str, list[_Record]], list[ServiceClusterDict] | None]] = {
"adguardhome_sync": _adguardhome_sync,
"bunkerweb": _bunkerweb,
}
def _reader_for(records: list[_Record]) -> str | None:
image = next((text for kind, text in records if kind == "image"), "")
if "adguardhome-sync" in image:
return "adguardhome_sync"
if "bunkerweb" in image:
return "bunkerweb"
return None
def _containers(sections: list[_Section]) -> dict[str, list[ServiceClusterDict] | None]:
kinds = {kind for kind, _, _ in sections}
if "no-docker" in kinds:
return {"adguardhome_sync": [], "bunkerweb": []}
if "docker" not in kinds:
return {"adguardhome_sync": None, "bunkerweb": None}
found: dict[str, list[ServiceClusterDict] | None] = {"adguardhome_sync": [], "bunkerweb": []}
for kind, name, records in sections:
key = _reader_for(records) if kind == "container" else None
if key is None or found[key] is None:
continue
clusters = _CONTAINER_READERS[key](name, records)
if clusters is None:
found[key] = None
else:
found[key].extend(clusters) # type: ignore[union-attr]
return found
def parse_service_clusters(output: str) -> ServiceClustersDict:
"""Parse what :func:`service_clusters_command` printed.
:raises ValueError: when the output carries no intact report.
"""
sections = _sections(_frame(output))
patroni: list[ServiceClusterDict] | None
if any(kind == "no-systemd" for kind, _, _ in sections):
patroni = None
elif any(
kind == "patroni" and any(r[0] == "unreadable" for r in records)
for kind, _, records in sections
):
patroni = None
else:
patroni = [
c
for kind, unit, records in sections
if kind == "patroni"
for c in _patroni(unit, records)
]
containers = _containers(sections)
return {
"patroni": patroni,
"adguardhome_sync": containers["adguardhome_sync"],
"bunkerweb": containers["bunkerweb"],
}
class ServiceClustersMixin:
"""Adds :meth:`get_service_clusters` to a driver that can run a command on a Linux host.
The template form (README, "Function classes"): the command and its parse are
the same everywhere, so they are concrete here, and a driver supplies only
:meth:`_run_service_clusters_command` -- how a command reaches its host, and
how it gains root there. A driver with a container engine of its own path
(``ContainerEngineMixin._container_engine_binary``) has it asked with that.
Mixed in by the drivers that can, so ``hasattr(driver,
"get_service_clusters")`` stays a truthful answer.
"""
if TYPE_CHECKING: # pragma: no cover - declared for type checkers only
def _run_service_clusters_command(self, command: str, *, privileged: bool) -> str:
"""Run *command* on the host and return what it printed; as root when
*privileged*. The command is a single ``sh -c`` invocation of a few kB:
send it on an exec channel, not typed into a shell."""
...
def _service_clusters_docker(self) -> str:
binary = getattr(self, "_container_engine_binary", None)
return binary("docker") if callable(binary) else "docker"
def get_service_clusters(self) -> ServiceClustersDict:
"""
Returns the clusters the host's configuration says it belongs to, per
mechanism: Patroni (its scope and the members its DCS lists),
adguardhome-sync (its origin and replicas), BunkerWeb (a worker's manager,
a manager's instances). See :class:`~napalm_device_types.models.ServiceClusterDict`.
None for a mechanism means it could not be read; ``[]`` that the host has none.
Example::
{
"patroni": [
{"mechanism": "patroni", "service": "patroni", "source": "patroni",
"cluster": "netork-db",
"running": True,
"members": [
{"name": "db-01", "address": "10.7.234.12", "port": 5432,
"role": "leader", "state": "running", "this_host": True,
"config_from": None},
{"name": "db-02", "address": "10.7.234.11", "port": 5432,
"role": "sync_standby", "state": "streaming", "this_host": False,
"config_from": None},
]},
],
"adguardhome_sync": [],
"bunkerweb": [],
}
:raises ValueError: if neither reading carried an intact report.
"""
command = f"sh -c {quote(service_clusters_command(self._service_clusters_docker()))}"
try:
return parse_service_clusters(
self._run_service_clusters_command(command, privileged=True)
)
except ValueError:
pass
return parse_service_clusters(self._run_service_clusters_command(command, privileged=False))
+182
View File
@@ -0,0 +1,182 @@
"""Which installed packages a systemd service is made of.
A vulnerability is found on a *package*; a statement about a *service* -- the
postgresql unit, nginx -- reaches its findings only once it is known which
packages that service is. Here that is the package shipping the unit file and
the one shipping the binary its ``ExecStart`` runs: ``nginx-common`` and
``nginx-core``, ``postgresql-common`` and ``postgresql-16``. Reading it is the
same on every systemd host, so the command and its parse live here once and a
driver only carries the command across.
**Only the unit's own packages.** The libraries its binary links against
(``libssl3`` under nginx) are every service's at once; a statement about nginx
is not one about libc, and following the dependencies would make it one.
The command is read-only and needs no privileges. It prints ``systemctl show``
for every loaded service unit and then asks the package manager about every
path at once: ``dpkg -S`` once for all of them -- one call, since each loads the
whole file list, and per path that is minutes on an ordinary host -- or
``rpm -qf`` per path. dpkg knows a file by the path its package shipped, so each
path is asked across the ``/usr`` merge too. The report is framed; one whose end
is missing raises, because a list cut short must never read as services that own
nothing.
"""
from __future__ import annotations
import re
from typing import TYPE_CHECKING
_BEGIN = "SVCPKG_BEGIN"
_END = "SVCPKG_END"
_SUFFIX = ".service"
#: A Debian or RPM package name.
_PACKAGE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9+._-]*$")
#: The binary of an ``ExecStart`` as ``systemctl show`` prints it.
_EXEC_PATH = re.compile(r"\bpath=(/[^ ;]+)")
#: Every unit file and ``ExecStart`` binary in ``$s``, each also across the
#: ``/usr`` merge, one per line.
_PATHS = (
"printf '%s\\n' \"$s\" | sed -n -e 's/^FragmentPath=\\(\\/.*\\)/\\1/p' "
"-e 's/^ExecStart=.*path=\\(\\/[^ ;]*\\).*/\\1/p' | sort -u "
"| sed -e 'p' -e 's#^/usr/#/#;t' -e 's#^/#/usr/#'"
)
#: One line, POSIX ``sh``, read-only. The frame markers are printed in two
#: halves so that a transport which echoes the command does not show them early.
SERVICE_PACKAGES_COMMAND = (
"s=$(systemctl list-units --type=service --all --no-legend --no-pager --plain 2>/dev/null "
"| awk '{print $1}' | tr '\\n' '\\0' | xargs -0 -r systemctl show --no-pager "
"-p Id -p FragmentPath -p ExecStart -- 2>/dev/null); "
"printf '%s%s\\n' SVCPKG_ BEGIN; echo '[units]'; printf '%s\\n\\n' \"$s\"; "
"if command -v dpkg >/dev/null 2>&1; then echo '[dpkg]'; "
f"{_PATHS} | tr '\\n' '\\0' | xargs -0 -r dpkg -S 2>/dev/null; "
"elif command -v rpm >/dev/null 2>&1; then echo '[rpm]'; "
f"{_PATHS} | while IFS= read -r f; do "
'printf \'%s\\t%s\\n\' "$f" "$(rpm -qf --qf \'%{NAME} \' "$f" 2>/dev/null)"; done; fi; '
"printf '%s%s\\n' SVCPKG_ END"
)
def _report(output: str) -> dict[str, list[str]]:
lines = [line.rstrip() for line in output.splitlines()]
stripped = [line.strip() for line in lines]
try:
start = stripped.index(_BEGIN)
end = stripped.index(_END, start)
except ValueError:
raise ValueError("no intact service package report in the output") from None
sections: dict[str, list[str]] = {}
current: list[str] | None = None
for line in lines[start + 1 : end]:
if line in ("[units]", "[dpkg]", "[rpm]"):
current = sections.setdefault(line[1:-1], [])
elif current is not None:
current.append(line)
return sections
def _units(lines: list[str]) -> dict[str, set[str]]:
"""``systemctl show`` blocks -> ``{unit: its unit file and binary}``."""
units: dict[str, set[str]] = {}
unit = ""
paths: set[str] = set()
for line in [*lines, ""]:
key, _, value = line.partition("=")
if not line.strip():
if unit:
units[unit] = paths
unit, paths = "", set()
elif key == "Id" and value.endswith(_SUFFIX):
unit = value[: -len(_SUFFIX)]
elif key == "FragmentPath" and value.startswith("/"):
paths.add(value)
elif key == "ExecStart":
match = _EXEC_PATH.search(value)
if match:
paths.add(match.group(1))
return units
def _names(raw: str) -> set[str]:
"""``libfoo1:amd64, foo-bin`` -> ``{"libfoo1", "foo-bin"}``; nothing else counts."""
found = set()
for name in raw.replace(",", " ").split():
name = name.split(":", 1)[0]
if _PACKAGE.match(name):
found.add(name)
return found
def _owners(sections: dict[str, list[str]]) -> dict[str, set[str]]:
"""``{path: the packages owning it}`` from dpkg's or rpm's answer."""
owners: dict[str, set[str]] = {}
for line in sections.get("dpkg", []):
# "pkg[, pkg2][:arch]: /path"; "diversion by x from: /path" is no owner.
if line.startswith("diversion by") or ": /" not in line:
continue
names, _, path = line.partition(": /")
owners.setdefault("/" + path.strip(), set()).update(_names(names))
for line in sections.get("rpm", []):
path, _, names = line.partition("\t")
if " is not owned" not in names:
owners.setdefault(path.strip(), set()).update(_names(names))
return owners
def _across_usr_merge(path: str) -> tuple[str, str]:
return (path, path[4:] if path.startswith("/usr/") else "/usr" + path)
def parse_service_packages(output: str) -> dict[str, list[str]]:
"""Parse what :data:`SERVICE_PACKAGES_COMMAND` printed.
``{unit name without ".service": its packages, sorted}``. A unit no package
owns -- a unit file an admin wrote -- maps to an empty list: read, and none.
:raises ValueError: when the output carries no intact report.
"""
sections = _report(output)
owners = _owners(sections)
return {
unit: sorted(
{name for p in paths for alt in _across_usr_merge(p) for name in owners.get(alt, ())}
)
for unit, paths in _units(sections.get("units", [])).items()
}
class ServicePackagesMixin:
"""Adds :meth:`get_service_packages` to a driver that can run a command on a systemd host.
The template form, like :class:`~napalm_device_types.kernel.KernelFactsMixin`:
the reading and its parse are concrete here, and a driver supplies only
:meth:`_run_service_packages_command` -- how a command reaches its host.
"""
if TYPE_CHECKING: # pragma: no cover - declared for type checkers only
def _run_service_packages_command(self, command: str) -> str:
"""Run *command* on the host with ``sh`` and return what it printed."""
...
def get_service_packages(self) -> dict[str, list[str]]:
"""
Returns, per systemd service, the installed packages it is made of.
The package that ships its unit file and the one that ships the binary
its ``ExecStart`` runs -- not the libraries that binary links against.
Example::
{
"nginx": ["nginx-common", "nginx-core"],
"postgresql@16-main": ["postgresql-16", "postgresql-common"],
"local-backup": [],
}
:raises ValueError: if the host's output carried no intact report.
"""
return parse_service_packages(self._run_service_packages_command(SERVICE_PACKAGES_COMMAND))
+4 -5
View File
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
"""Init-system services: listing them and starting/stopping them.
Deliberately *not* the same thing as a NAS's exported shares, which live on
``StorageServiceCapability`` as ``get_storage_services``. Those two used to
``StorageDriver`` as ``get_storage_services``. Those two used to
share the name ``get_services`` with incompatible return types, which is why
a QNAP -- storage and OS at once -- could not satisfy both.
@@ -13,7 +12,7 @@ this class in can never shadow a working implementation from a sibling base.
from __future__ import annotations
from typing import Any, Dict, List, TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from napalm_device_types.models import ServiceDict
@@ -21,7 +20,7 @@ from napalm_device_types.models import ServiceDict
class ServiceControlMixin:
if TYPE_CHECKING:
def get_services(self) -> List[ServiceDict]:
def get_services(self) -> list[ServiceDict]:
"""
Returns the list of system services known to the device's init system.
@@ -42,7 +41,7 @@ class ServiceControlMixin:
"""
...
def manage_service(self, name: str, action: str) -> Dict[str, Any]:
def manage_service(self, name: str, action: str) -> dict[str, Any]:
"""
Execute a lifecycle action on a named service.
+299
View File
@@ -0,0 +1,299 @@
"""SSH host keys: trust on first use, and a changed key refused before any login.
A driver that accepts whatever host key answers at a device's address logs in
there with the device's credentials, whoever that is (NetOrk/netork#1167). The
layer above keeps the store of the keys it trusts; a driver only verifies:
* It receives the keys to expect in ``optional_args["ssh_host_keys"]``
(:data:`KNOWN_KEYS_ARG`), by the port they were seen on:
``{"22": {"key_type": "ssh-ed25519", "key": "<base64>"}}``, the two fields of
a ``known_hosts`` line. A driver takes the one for the port it connects to, so
the caller need not know which port that is (Proxmox: its API on 8006, SSH on
22). With a key, any other key is refused before a password or a private key
leaves the caller. Without one, the first key is accepted: trust on first use.
* It reports every key it sees through ``optional_args["ssh_host_key_observer"]``
(:data:`OBSERVER_ARG`), a callable that receives one dict per connection::
{"host": str, "port": int,
"seen": {"key_type", "key", "fingerprint"},
"expected": {...} | None,
"accepted": bool}
``accepted`` is False only for a refused key; the connection then raises
:class:`SshHostKeyMismatch`. A failing observer is logged and ignored, so the
store being away never stops a connection that verified.
A caller that passes neither keeps working as before (first use, every time),
and a driver that ignores both still connects for a caller that passes them:
old and new versions on either side do not break each other.
:class:`HostKeyCheck` is the one implementation, for a paramiko ``SSHClient``
(:meth:`~HostKeyCheck.connect`) and for netmiko (:meth:`~HostKeyCheck.netmiko`,
then :meth:`~HostKeyCheck.observe`). Like :mod:`napalm_device_types.channel` it
works on the objects paramiko hands it and imports no SSH library itself.
"""
from __future__ import annotations
import base64
import hashlib
import logging
import os
import tempfile
from collections.abc import Iterator, Mapping
from contextlib import contextmanager
from dataclasses import dataclass
from typing import Any
from napalm.base.exceptions import ConnectionException
logger = logging.getLogger(__name__)
#: The ``optional_args`` key that carries the host keys to expect, by port.
KNOWN_KEYS_ARG = "ssh_host_keys"
#: The ``optional_args`` key that carries the callable every seen key goes to.
OBSERVER_ARG = "ssh_host_key_observer"
#: The netmiko arguments this module decides; a caller's own values give way.
_NETMIKO_HOST_KEY_ARGS = ("ssh_strict", "system_host_keys", "alt_host_keys", "alt_key_file")
_DEFAULT_SSH_PORT = 22
@dataclass(frozen=True)
class SshHostKey:
"""A host's public key, as the two fields of a ``known_hosts`` line."""
key_type: str
#: The public key blob, base64.
key: str
@property
def fingerprint(self) -> str:
"""``SHA256:…``, as ``ssh-keygen -l`` and the ssh client print it."""
digest = hashlib.sha256(base64.b64decode(self.key)).digest()
return "SHA256:" + base64.b64encode(digest).decode("ascii").rstrip("=")
def as_dict(self) -> dict[str, str]:
return {"key_type": self.key_type, "key": self.key, "fingerprint": self.fingerprint}
@classmethod
def from_value(cls, value: Any) -> SshHostKey | None:
"""The key in *value* (a mapping with ``key_type`` and ``key``), or None for none."""
if not value:
return None
if not isinstance(value, Mapping) or not value.get("key_type") or not value.get("key"):
raise ValueError(f"not an SSH host key: {value!r}")
return cls(str(value["key_type"]), str(value["key"]))
@classmethod
def of(cls, pkey: Any) -> SshHostKey:
"""The key of a paramiko ``PKey`` (what a transport says the server presented)."""
return cls(pkey.get_name(), pkey.get_base64())
def known_hosts_line(self, host: str, port: int) -> str:
"""The line under which an ssh client looks this host up."""
name = host if port == _DEFAULT_SSH_PORT else f"[{host}]:{port}"
return f"{name} {self.key_type} {self.key}"
@dataclass(frozen=True)
class HostKeyObservation:
"""The key one connection saw, against the key it was to expect."""
host: str
port: int
seen: SshHostKey
expected: SshHostKey | None
@property
def accepted(self) -> bool:
"""True on first use and for the expected key; False for a refused one."""
return self.expected is None or self.expected == self.seen
def as_dict(self) -> dict[str, Any]:
return {
"host": self.host,
"port": self.port,
"seen": self.seen.as_dict(),
"expected": self.expected.as_dict() if self.expected else None,
"accepted": self.accepted,
}
class SshHostKeyMismatch(ConnectionException): # type: ignore[misc]
"""The device presented another host key than the one it is known by.
Raised before any authentication: no password and no private key went to it.
"""
def __init__(self, observation: HostKeyObservation) -> None:
self.observation = observation
expected = observation.expected
trusted = f"{expected.fingerprint} ({expected.key_type})" if expected else "none"
super().__init__(
f"SSH host key mismatch at {observation.host}:{observation.port}: it presented "
f"{observation.seen.fingerprint} ({observation.seen.key_type}), the trusted key is "
f"{trusted}. Nothing was sent to it."
)
class _AcceptFirst:
"""A paramiko missing-host-key policy for first use: accept, keep nothing.
The key is reported once the connection stands (:meth:`HostKeyCheck.observe`);
nothing is written to a ``known_hosts`` file.
"""
def missing_host_key(self, client: Any, hostname: str, key: Any) -> None:
return None
class _RefuseUnknown:
"""A paramiko policy for a host with a known key: refuse anything else.
paramiko compares a loaded key itself and raises ``BadHostKeyException``
for another one; this only answers if the loaded key was not found under
the name connected to, which must not turn into an accepted key.
"""
def missing_host_key(self, client: Any, hostname: str, key: Any) -> None:
raise ConnectionException(f"SSH host key of {hostname} is not the one expected")
def _expected_key(known: Any, port: int) -> SshHostKey | None:
"""The key in *known* (``{port: key}``, the port as text or number) for *port*."""
if not known:
return None
if not isinstance(known, Mapping):
raise ValueError(f"SSH host keys must be given by port, not as {type(known).__name__}")
return SshHostKey.from_value(known.get(str(port)) or known.get(port))
def _refused_key(exc: BaseException) -> Any | None:
"""paramiko's ``BadHostKeyException`` in *exc*'s chain (netmiko and drivers wrap it)."""
seen: set[int] = set()
stack: list[BaseException | None] = [exc]
while stack:
current = stack.pop()
if current is None or id(current) in seen:
continue
seen.add(id(current))
if hasattr(current, "expected_key") and hasattr(current, "key"):
return current
stack.extend([current.__cause__, current.__context__])
return None
class HostKeyCheck:
"""How one driver connection verifies its host's key.
*optional_args* are the driver's (both keys optional), *host* and *port* the
address the connection goes to, as given to paramiko or netmiko.
"""
def __init__(self, optional_args: Mapping[str, Any] | None, host: str, port: int) -> None:
args = optional_args or {}
self.host = host
self.port = int(port)
self.expected = _expected_key(args.get(KNOWN_KEYS_ARG), self.port)
self._observer = args.get(OBSERVER_ARG)
# ------------------------------------------------------------------
# paramiko
# ------------------------------------------------------------------
def prepare(self, client: Any) -> None:
"""Before ``client.connect()``: the known key, or acceptance of the first one.
The known key goes into the client's host keys, so paramiko asks the
server for that key type first and refuses any other key itself.
"""
if self.expected is None:
client.set_missing_host_key_policy(_AcceptFirst())
return
with self._known_hosts_file(self.expected) as path:
client.get_host_keys().load(path)
client.set_missing_host_key_policy(_RefuseUnknown())
def connect(self, client: Any, **kwargs: Any) -> HostKeyObservation:
"""``client.connect()`` to this host with *kwargs*, verified and reported."""
self.prepare(client)
with self.refusing():
client.connect(hostname=self.host, port=self.port, **kwargs)
return self.observe(client.get_transport())
# ------------------------------------------------------------------
# netmiko
# ------------------------------------------------------------------
@contextmanager
def netmiko(self, base: Mapping[str, Any]) -> Iterator[dict[str, Any]]:
"""``ConnectHandler`` keyword arguments: *base* with the host key decided.
Open the connection inside the block and call :meth:`observe` on its
transport after it. A known key goes to netmiko in a ``known_hosts``
file that exists only for the block, with ``ssh_strict``: should the
file not be read, netmiko refuses rather than accepts.
"""
kwargs = {k: v for k, v in base.items() if k not in _NETMIKO_HOST_KEY_ARGS}
kwargs["system_host_keys"] = False
if self.expected is None:
kwargs.update(ssh_strict=False, alt_host_keys=False)
with self.refusing():
yield kwargs
return
with self._known_hosts_file(self.expected) as path:
kwargs.update(ssh_strict=True, alt_host_keys=True, alt_key_file=path)
with self.refusing():
yield kwargs
# ------------------------------------------------------------------
# both
# ------------------------------------------------------------------
@contextmanager
def refusing(self) -> Iterator[None]:
"""Around a connect: a refused host key becomes :class:`SshHostKeyMismatch`."""
try:
yield
except Exception as exc:
refused = _refused_key(exc)
if refused is None:
raise
expected = self.expected or SshHostKey.of(refused.expected_key)
offered = SshHostKey.of(refused.key)
observation = HostKeyObservation(self.host, self.port, offered, expected)
self._report(observation)
raise SshHostKeyMismatch(observation) from exc
def observe(self, transport: Any) -> HostKeyObservation:
"""After a connect: the key the server presented on *transport*, reported."""
seen = SshHostKey.of(transport.get_remote_server_key())
observation = HostKeyObservation(self.host, self.port, seen, self.expected)
self._report(observation)
return observation
def _report(self, observation: HostKeyObservation) -> None:
if not callable(self._observer):
return
try:
self._observer(observation.as_dict())
except Exception: # the store is the caller's; a connection does not hang on it
logger.warning(
"Could not report the SSH host key of %s:%s", self.host, self.port, exc_info=True
)
@contextmanager
def _known_hosts_file(self, expected: SshHostKey) -> Iterator[str]:
"""A private ``known_hosts`` file holding only *expected*."""
fd, path = tempfile.mkstemp(prefix="known_hosts-")
try:
with os.fdopen(fd, "w") as f:
f.write(expected.known_hosts_line(self.host, self.port) + "\n")
yield path
finally:
try:
os.unlink(path)
except OSError:
pass
+173 -54
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""OpenSSH server hardening: taking algorithms out of what sshd offers.
A scanner such as ssh-audit names algorithms a server should stop offering --
@@ -22,7 +21,9 @@ Only without such an ``Include`` is sshd_config itself rewritten, after a copy.
1. The plan never removes the last algorithm of a class, and -- given the
lists of the client that manages the host -- never the last one that client
speaks. Each one kept back is reported, with why.
speaks. Each one kept back is reported, with why. For host key algorithms
only those the host has a key for count (``ssh-keygen -l`` on each
``HostKey``); when the keys cannot be read, the host key list is not touched.
2. ``sshd -t`` must accept the result, and ``sshd -T`` must show the new lists.
A file read before ours can set the same directive; ours is then valid and
ignored, and that is reported rather than called a success.
@@ -34,15 +35,18 @@ Only without such an ``Include`` is sshd_config itself rewritten, after a copy.
from __future__ import annotations
import time
from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass, field
from shlex import quote
from typing import Callable, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple, TYPE_CHECKING
from typing import (
TYPE_CHECKING,
)
if TYPE_CHECKING: # pragma: no cover - for type checkers only
from napalm_device_types.channel import CommandResult
#: The algorithm classes: key -> (``sshd -T`` keyword, sshd_config directive).
SSH_ALGORITHM_CLASSES: Dict[str, Tuple[str, str]] = {
SSH_ALGORITHM_CLASSES: dict[str, tuple[str, str]] = {
"kex": ("kexalgorithms", "KexAlgorithms"),
"hostkey": ("hostkeyalgorithms", "HostKeyAlgorithms"),
"cipher": ("ciphers", "Ciphers"),
@@ -61,6 +65,17 @@ _HEADER = (
"# taken out of what this server offers. Delete this file to undo it.\n"
)
#: Host key type, as ``ssh-keygen -l`` prints it -> the algorithms that key serves.
#: Certificate algorithms are left out: they also need a HostCertificate.
_KEY_TYPE_ALGORITHMS: dict[str, tuple[str, ...]] = {
"ED25519": ("ssh-ed25519",),
"RSA": ("rsa-sha2-512", "rsa-sha2-256", "ssh-rsa"),
"DSA": ("ssh-dss",),
"ED25519-SK": ("sk-ssh-ed25519@openssh.com",),
"ECDSA-SK": ("sk-ecdsa-sha2-nistp256@openssh.com",),
}
_ECDSA_CURVES = ("256", "384", "521")
_RELOAD = (
"if systemctl is-active --quiet ssh 2>/dev/null; then systemctl reload ssh;"
" elif systemctl is-active --quiet sshd 2>/dev/null; then systemctl reload sshd;"
@@ -69,7 +84,7 @@ _RELOAD = (
)
def _directive_key(line: str) -> Optional[str]:
def _directive_key(line: str) -> str | None:
"""The algorithm class a global sshd_config line sets, if it sets one."""
parts = line.split(None, 1)
if not parts:
@@ -81,12 +96,12 @@ def _directive_key(line: str) -> Optional[str]:
return None
def parse_sshd_effective(text: str) -> Dict[str, List[str]]:
def parse_sshd_effective(text: str) -> dict[str, list[str]]:
"""The four algorithm lists from ``sshd -T`` output, in the server's order.
:raises ValueError: if a list is missing -- not sshd -T output, or cut short.
"""
found: Dict[str, List[str]] = {}
found: dict[str, list[str]] = {}
for raw in text.splitlines():
parts = raw.strip().split(None, 1)
if len(parts) != 2:
@@ -100,12 +115,46 @@ def parse_sshd_effective(text: str) -> Dict[str, List[str]]:
return found
def parse_sshd_hostkeys(text: str) -> list[str]:
"""The ``hostkey`` paths from ``sshd -T`` output, in order."""
paths: list[str] = []
for raw in text.splitlines():
parts = raw.strip().split(None, 1)
if len(parts) == 2 and parts[0].lower() == "hostkey":
paths.append(parts[1].strip())
return paths
def host_key_algorithms(fingerprints: str) -> set[str] | None:
"""The host key algorithms the keys in ``ssh-keygen -l`` output can serve.
None when a line is not understood or there is no key: then nothing is known.
"""
usable: set[str] = set()
for raw in fingerprints.splitlines():
parts = raw.split()
if not parts:
continue
kind = parts[-1].strip("()")
if len(parts) < 3 or not parts[-1].startswith("("):
return None
if kind == "ECDSA" and parts[0] in _ECDSA_CURVES:
usable.add(f"ecdsa-sha2-nistp{parts[0]}")
elif kind in _KEY_TYPE_ALGORITHMS:
usable.update(_KEY_TYPE_ALGORITHMS[kind])
else:
return None
return usable or None
@dataclass(frozen=True)
class Refusal:
"""One algorithm that was asked to go and stays, with why.
*reason* is ``"last"`` (the class would be empty), ``"client"`` (the managing
client would share none of the class with the server) or ``"not_offered"``
*reason* is ``"last"`` (the class would be empty -- for host keys: have no
algorithm the host has a key for), ``"client"`` (the managing client would
share none of the class with the server), ``"host_keys_unknown"`` (the host
keys could not be read, so the host key list stays) or ``"not_offered"``
(the server does not offer it, so there is nothing to remove).
"""
@@ -116,13 +165,13 @@ class Refusal:
@dataclass(frozen=True)
class SshHardeningPlan:
keep: Dict[str, List[str]]
remove: Dict[str, List[str]]
refused: List[Refusal] = field(default_factory=list)
not_offered: List[Refusal] = field(default_factory=list)
keep: dict[str, list[str]]
remove: dict[str, list[str]]
refused: list[Refusal] = field(default_factory=list)
not_offered: list[Refusal] = field(default_factory=list)
@property
def changed_classes(self) -> List[str]:
def changed_classes(self) -> list[str]:
return [k for k in SSH_ALGORITHM_CLASSES if self.remove.get(k)]
@@ -130,13 +179,19 @@ def plan_ssh_hardening(
effective: Mapping[str, Sequence[str]],
remove: Mapping[str, Iterable[str]],
*,
client: Optional[Mapping[str, Iterable[str]]] = None,
client: Mapping[str, Iterable[str]] | None = None,
host_keys: Iterable[str] | None = None,
) -> SshHardeningPlan:
"""What the lists become when *remove* is taken out of *effective*.
*client* is what the client managing this host speaks, per class. A class it
has no list for is not guarded beyond "never empty".
*host_keys* are the host key algorithms the host has a key for (see
:func:`host_key_algorithms`). Only those count as a host key algorithm that
stays: sshd offers the others but cannot use them. Without it nothing is
known, and no host key algorithm is removed.
When a removal would leave a class empty, or without an algorithm *client*
speaks, the first of the asked-for algorithms in the server's order that
fixes it stays, and is reported in ``refused``.
@@ -146,25 +201,36 @@ def plan_ssh_hardening(
unknown = [k for k in remove if k not in SSH_ALGORITHM_CLASSES]
if unknown:
raise ValueError(f"unknown SSH algorithm class: {', '.join(unknown)}")
keep: Dict[str, List[str]] = {}
removed: Dict[str, List[str]] = {}
refused: List[Refusal] = []
not_offered: List[Refusal] = []
keep: dict[str, list[str]] = {}
removed: dict[str, list[str]] = {}
refused: list[Refusal] = []
not_offered: list[Refusal] = []
usable_keys = set(host_keys) if host_keys is not None else None
for kind in SSH_ALGORITHM_CLASSES:
offered = list(effective.get(kind, []))
asked = set(remove.get(kind, []))
not_offered += [Refusal(kind, a, "not_offered") for a in sorted(asked - set(offered))]
going = [a for a in offered if a in asked]
staying = [a for a in offered if a not in asked]
if kind == "hostkey" and usable_keys is None:
refused += [Refusal(kind, a, "host_keys_unknown") for a in going]
going = []
# A host key algorithm without a key is offered, but nobody gets in with it.
usable = usable_keys if kind == "hostkey" and usable_keys is not None else set(offered)
staying = [a for a in offered if a not in going and a in usable]
speaks = set(client[kind]) if client and kind in client else None
if going and speaks is not None and not speaks & set(staying):
spared = next((a for a in going if a in speaks), None)
spared = next((a for a in going if a in speaks and a in usable), None)
if spared:
going.remove(spared)
staying.append(spared)
refused.append(Refusal(kind, spared, "client"))
if going and not [a for a in offered if a not in going]:
spared = going.pop(0)
refused.append(Refusal(kind, spared, "last"))
if going and not staying:
spared = next((a for a in going if a in usable), None)
if spared is None and not [a for a in offered if a not in going]:
spared = going[0]
if spared is not None:
going.remove(spared)
refused.append(Refusal(kind, spared, "last"))
keep[kind] = [a for a in offered if a not in going]
if going:
removed[kind] = going
@@ -181,7 +247,7 @@ def render_dropin(keep: Mapping[str, Sequence[str]], kinds: Iterable[str]) -> st
return _HEADER + "".join(lines)
def dropin_classes(text: str) -> List[str]:
def dropin_classes(text: str) -> list[str]:
"""The classes an existing drop-in sets."""
return [k for k in (_directive_key(line.strip()) for line in text.splitlines()) if k]
@@ -204,11 +270,11 @@ def has_dropin_include(text: str) -> bool:
return False
def _split_our_block(text: str) -> Tuple[List[str], str]:
def _split_our_block(text: str) -> tuple[list[str], str]:
"""(the classes an earlier rewrite put above the file, the file without them)."""
if not text.startswith(_HEADER):
return [], text
lines = text[len(_HEADER):].splitlines(keepends=True)
lines = text[len(_HEADER) :].splitlines(keepends=True)
ours = 0
while ours < len(lines) and _directive_key(lines[ours].strip()):
ours += 1
@@ -224,7 +290,7 @@ def render_main_config(text: str, keep: Mapping[str, Sequence[str]], kinds: Iter
"""
earlier, body = _split_our_block(text)
wanted = set(kinds) | set(earlier)
out: List[str] = []
out: list[str] = []
in_match = False
for raw in body.splitlines():
if _is_match(raw.strip()):
@@ -248,8 +314,8 @@ class SshHardeningResult:
plan: SshHardeningPlan
applied: bool = False
rolled_back: bool = False
path: Optional[str] = None
backup: Optional[str] = None
path: str | None = None
backup: str | None = None
message: str = ""
@@ -272,26 +338,29 @@ class OpenSshServerMixin:
*,
privileged: bool = False,
timeout: float = 60,
stdin: Optional[bytes] = None,
stdin: bytes | None = None,
) -> CommandResult: ...
# ── the host ────────────────────────────────────────────────────────
def _ssh_run(self, command: str, stdin: Optional[bytes] = None) -> "CommandResult":
def _ssh_run(self, command: str, stdin: bytes | None = None) -> CommandResult:
return self.run_command(command, privileged=True, timeout=30, stdin=stdin)
def _ssh_read(self, path: str) -> Optional[str]:
def _ssh_read(self, path: str) -> str | None:
result = self._ssh_run(f"cat -- {quote(path)}")
return result.stdout if result.exit_code == 0 else None
def _ssh_write(self, path: str, content: str) -> None:
def _ssh_write(self, path: str, content: str, mode_of: str | None = None) -> None:
"""Replace *path* with *content*, as root, without root reading stdin.
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 a ``cat`` under sudo writes it into the file. So the
content goes, without privilege, to a file only the login user can read;
root then installs that file (mode 644, owned by root) and removes it.
root then installs that file, owned by root, and removes it.
The mode is that of *mode_of* (default: *path* itself), so a 0600
sshd_config stays 0600 and so does its copy; 644 for a new file.
"""
staged = self.run_command(
'umask 077 && t=$(mktemp /tmp/netork-ssh.XXXXXX) && cat > "$t" && printf %s "$t"',
@@ -300,15 +369,24 @@ class OpenSshServerMixin:
stdin=content.encode(),
)
tmp = staged.stdout.strip()
if staged.exit_code != 0 or not tmp.startswith("/tmp/netork-ssh."):
raise RuntimeError(f"could not stage {path}: {(staged.stderr or staged.stdout).strip()}")
# A path on the device, made by mktemp under umask 077: not a local temp file.
if staged.exit_code != 0 or not tmp.startswith("/tmp/netork-ssh."): # nosec B108
raise RuntimeError(
f"could not stage {path}: {(staged.stderr or staged.stdout).strip()}"
)
part = path + ".netork-tmp"
result = self._ssh_run(
f"install -m 644 -- {quote(tmp)} {quote(part)} && mv -f -- {quote(part)} {quote(path)};"
# GNU stat, then BSD's; 644 for a file that does not exist yet.
f'r={quote(mode_of or path)}; m=$(stat -c %a -- "$r" 2>/dev/null'
f' || stat -f %Lp -- "$r" 2>/dev/null) || m=644;'
f' install -m "$m" -- {quote(tmp)} {quote(part)}'
f" && mv -f -- {quote(part)} {quote(path)};"
f" s=$?; rm -f -- {quote(tmp)} {quote(part)}; exit $s"
)
if result.exit_code != 0:
raise RuntimeError(f"could not write {path}: {(result.stderr or result.stdout).strip()}")
raise RuntimeError(
f"could not write {path}: {(result.stderr or result.stdout).strip()}"
)
def _ssh_remove(self, path: str) -> None:
self._ssh_run(f"rm -f -- {quote(path)}")
@@ -319,17 +397,36 @@ class OpenSshServerMixin:
if result.exit_code != 0:
raise RuntimeError(f"could not reload sshd: {(result.stderr or result.stdout).strip()}")
def get_ssh_server_algorithms(self) -> Dict[str, List[str]]:
def get_ssh_server_algorithms(self) -> dict[str, list[str]]:
"""The algorithm lists sshd uses now (``sshd -T``), per class.
:raises RuntimeError: if sshd -T fails -- no OpenSSH, or no root.
"""
return parse_sshd_effective(self._sshd_effective_text())
def _sshd_effective_text(self) -> str:
result = self._ssh_run("PATH=$PATH:/usr/sbin:/sbin sshd -T")
if result.exit_code != 0:
raise RuntimeError(f"sshd -T failed: {(result.stderr or result.stdout).strip()}")
return parse_sshd_effective(result.stdout)
return result.stdout
def _sshd_accepts(self) -> Optional[str]:
def _ssh_host_key_algorithms(self, sshd_t: str) -> set[str] | None:
"""The host key algorithms the host has a key for; None when that cannot be read.
A ``HostKey`` whose file does not exist is skipped, as sshd skips it.
"""
paths = parse_sshd_hostkeys(sshd_t)
if not paths:
return None
result = self._ssh_run(
f"for k in {' '.join(quote(p) for p in paths)}; do"
' [ -e "$k" ] || continue; ssh-keygen -l -f "$k" || exit 1; done'
)
if result.exit_code != 0:
return None
return host_key_algorithms(result.stdout)
def _sshd_accepts(self) -> str | None:
"""None when ``sshd -t`` accepts the configuration, else what it said."""
result = self._ssh_run("PATH=$PATH:/usr/sbin:/sbin sshd -t")
return None if result.exit_code == 0 else (result.stderr or result.stdout).strip()
@@ -340,9 +437,9 @@ class OpenSshServerMixin:
self,
remove: Mapping[str, Iterable[str]],
*,
client: Optional[Mapping[str, Iterable[str]]] = None,
verify: Optional[Callable[[], bool]] = None,
log: Optional[Callable[[str], None]] = None,
client: Mapping[str, Iterable[str]] | None = None,
verify: Callable[[], bool] | None = None,
log: Callable[[str], None] | None = None,
) -> SshHardeningResult:
"""Stop the server offering the algorithms in *remove* (class -> names).
@@ -357,9 +454,22 @@ class OpenSshServerMixin:
"""
say = log or (lambda _line: None)
say("Reading the algorithms sshd offers (sshd -T)")
plan = plan_ssh_hardening(self.get_ssh_server_algorithms(), remove, client=client)
sshd_t = self._sshd_effective_text()
host_keys: set[str] | None = None
if remove.get("hostkey"):
say("Reading which host keys the server has (ssh-keygen -l)")
host_keys = self._ssh_host_key_algorithms(sshd_t)
plan = plan_ssh_hardening(
parse_sshd_effective(sshd_t), remove, client=client, host_keys=host_keys
)
whys = {
"last": "it is the last one",
"client": "netOrk could no longer connect",
"host_keys_unknown": "the host keys could not be read",
}
for r in plan.refused:
why = "it is the last one" if r.reason == "last" else "netOrk could no longer connect"
last_key = r.reason == "last" and r.kind == "hostkey"
why = "it is the last one the host has a key for" if last_key else whys[r.reason]
say(f"Keeping {r.algorithm}: {why}")
kinds = plan.changed_classes
result = SshHardeningResult(plan=plan)
@@ -377,7 +487,11 @@ class OpenSshServerMixin:
return self._apply_or_restore(plan, kinds, result, restore, verify, say)
def _write_change(
self, plan: SshHardeningPlan, main: str, result: SshHardeningResult, say: Callable[[str], None]
self,
plan: SshHardeningPlan,
main: str,
result: SshHardeningResult,
say: Callable[[str], None],
) -> Callable[[], None]:
"""Write the new lists; return what puts the previous state back."""
if has_dropin_include(main):
@@ -396,7 +510,7 @@ class OpenSshServerMixin:
return restore
backup = f"{MAIN_CONFIG}.netork-{time.strftime('%Y%m%d-%H%M%S')}.bak"
self._ssh_write(backup, main)
self._ssh_write(backup, main, mode_of=MAIN_CONFIG)
self._ssh_write(MAIN_CONFIG, render_main_config(main, plan.keep, plan.changed_classes))
result.path, result.backup = MAIN_CONFIG, backup
say(f"{MAIN_CONFIG} has no Include of {DROPIN_DIR}; rewrote it (copy at {backup})")
@@ -409,18 +523,23 @@ class OpenSshServerMixin:
def _apply_or_restore(
self,
plan: SshHardeningPlan,
kinds: List[str],
kinds: list[str],
result: SshHardeningResult,
restore: Callable[[], None],
verify: Optional[Callable[[], bool]],
verify: Callable[[], bool] | None,
say: Callable[[str], None],
) -> SshHardeningResult:
def back(message: str, reload: bool) -> SshHardeningResult:
say(message)
say("Putting the previous configuration back")
restore()
if reload:
self._reload_ssh_server()
try:
restore()
if reload:
self._reload_ssh_server()
except Exception as exc: # noqa: BLE001 - report it, the caller must look
result.message = f"{message}; putting the previous configuration back failed: {exc}"
say(result.message)
return result
result.rolled_back = True
result.message = message
return result
+21 -20
View File
@@ -10,9 +10,9 @@ Usage::
...
"""
from typing import Any, Dict, List, TYPE_CHECKING
from typing import TYPE_CHECKING
from napalm_device_types.base import DeviceTypeDriver
from napalm_device_types.packages import PackageManagementMixin
from napalm_device_types.models import (
DiskPoolDict,
LogicalVolumeDict,
@@ -23,6 +23,7 @@ from napalm_device_types.models import (
StorageServiceDict,
VolumeSnapshotDict,
)
from napalm_device_types.packages import PackageManagementMixin
class StorageDriver(PackageManagementMixin, DeviceTypeDriver):
@@ -47,7 +48,7 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver):
if TYPE_CHECKING:
def get_disks(self) -> List[PhysicalDiskDict]:
def get_disks(self) -> list[PhysicalDiskDict]:
"""
Returns all physical drives detected by the storage device.
@@ -62,7 +63,8 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver):
* rpm (int) - rotational speed; ``0`` for SSD/NVMe
* temperature (int) - current temperature in Celsius; ``-1`` if unavailable
* health (string) - ``"healthy"``, ``"warning"``, ``"failed"``, or ``"unknown"``
* pool (string) - name of the pool this disk belongs to; empty string if unassigned/spare
* pool (string) - name of the pool this disk belongs to;
empty string if unassigned/spare
Example::
@@ -95,7 +97,7 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver):
"""
...
def get_disk_pools(self) -> Dict[str, DiskPoolDict]:
def get_disk_pools(self) -> dict[str, DiskPoolDict]:
"""
Returns the disk pools (RAID arrays, ZFS pools, LVM volume groups, etc.)
configured on the device.
@@ -106,12 +108,14 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver):
* type (string) - ``"zfs"``, ``"lvm"``, ``"md"``, ``"hardware-raid"``, ``"btrfs"``
* level (string) - RAID/redundancy level: ``"mirror"``, ``"raidz1"``, ``"raidz2"``,
``"raidz3"``, ``"stripe"``, ``"raid0"`` … ``"raid60"``, ``"single"``
* status (string) - ``"online"``, ``"degraded"``, ``"faulted"``, ``"offline"``, ``"unknown"``
* status (string) - ``"online"``, ``"degraded"``, ``"faulted"``,
``"offline"``, ``"unknown"``
* total (int) - usable capacity in bytes
* used (int) - used space in bytes
* available (int) - free space in bytes
* disks (list of strings) - slot identifiers of member drives
* auto_expand (bool) - whether the pool grows automatically when disks are replaced with larger ones
* auto_expand (bool) - whether the pool grows automatically when disks
are replaced with larger ones
* dedup (bool) - whether deduplication is enabled
* compression (string) - pool-level compression algorithm: ``"off"``, ``"lz4"``,
``"gzip"``, ``"zstd"``, etc.
@@ -140,7 +144,7 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver):
# Logical volumes / datasets
# ------------------------------------------------------------------
def get_volumes(self) -> Dict[str, LogicalVolumeDict]:
def get_volumes(self) -> dict[str, LogicalVolumeDict]:
"""
Returns all logical volumes, ZFS datasets, or LUNs on the device.
@@ -154,7 +158,8 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver):
* used (int) - space currently used in bytes
* available (int) - space available in bytes
* mountpoint (string) - local mount path; empty string for block volumes / LUNs
* compression (string) - active compression algorithm (``"off"``, ``"lz4"``, ``"zstd"``, etc.)
* compression (string) - active compression algorithm
(``"off"``, ``"lz4"``, ``"zstd"``, etc.)
* dedup (bool) - whether deduplication is active on this volume
* readonly (bool) - whether the volume is mounted read-only
* snapshots (int) - number of snapshots currently held
@@ -196,7 +201,7 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver):
# Shares
# ------------------------------------------------------------------
def get_shares(self) -> Dict[str, NASShareDict]:
def get_shares(self) -> dict[str, NASShareDict]:
"""
Returns all network shares and iSCSI targets exported by the device.
@@ -244,7 +249,7 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver):
# Snapshots
# ------------------------------------------------------------------
def get_volume_snapshots(self, volume: str = "") -> List[VolumeSnapshotDict]:
def get_volume_snapshots(self, volume: str = "") -> list[VolumeSnapshotDict]:
"""
Returns volume / dataset snapshots.
@@ -253,7 +258,8 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver):
Each entry contains:
* name (string) - snapshot name (e.g. ``"auto-2026-05-11"`` or full ``"tank/media@auto-2026-05-11"``)
* name (string) - snapshot name (e.g. ``"auto-2026-05-11"``
or full ``"tank/media@auto-2026-05-11"``)
* volume (string) - parent volume / dataset path
* created (float) - creation timestamp (Unix epoch)
* size (int) - bytes of unique data referenced only by this snapshot
@@ -339,7 +345,7 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver):
# Quotas
# ------------------------------------------------------------------
def get_quotas(self) -> List[StorageQuotaDict]:
def get_quotas(self) -> list[StorageQuotaDict]:
"""
Returns all filesystem quotas configured on the device.
@@ -380,7 +386,7 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver):
# Services
# ------------------------------------------------------------------
def get_storage_services(self) -> Dict[str, StorageServiceDict]:
def get_storage_services(self) -> dict[str, StorageServiceDict]:
"""
Returns the file-sharing and access services available on the device.
@@ -443,7 +449,7 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver):
# Replication
# ------------------------------------------------------------------
def get_replication_jobs(self) -> List[ReplicationJobDict]:
def get_replication_jobs(self) -> list[ReplicationJobDict]:
"""
Returns all replication / sync jobs configured on the device.
@@ -493,8 +499,3 @@ class StorageDriver(PackageManagementMixin, DeviceTypeDriver):
# ------------------------------------------------------------------
# Package management (plugins / extensions)
# ------------------------------------------------------------------
+8 -7
View File
@@ -10,7 +10,9 @@ Usage::
...
"""
from typing import Any, Awaitable, Callable, Dict, List, TYPE_CHECKING
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Any
from napalm_device_types.base import DeviceTypeDriver
from napalm_device_types.mac_acl import MacAclMixin
from napalm_device_types.models import (
@@ -45,7 +47,7 @@ class SwitchDriver(MacAclMixin, DeviceTypeDriver):
async def get_health_metrics(
cls,
snmp_get: Callable[..., Awaitable[Any]],
snmp_walk: Callable[..., Awaitable[Dict[str, Any]]],
snmp_walk: Callable[..., Awaitable[dict[str, Any]]],
) -> HealthMetricsDict:
"""Collect SNMP health metrics for this switch type.
@@ -54,7 +56,7 @@ class SwitchDriver(MacAclMixin, DeviceTypeDriver):
"""
...
def get_spanning_tree(self) -> Dict[str, SpanningTreeDict]:
def get_spanning_tree(self) -> dict[str, SpanningTreeDict]:
"""
Returns spanning tree status for each STP instance.
@@ -104,7 +106,7 @@ class SwitchDriver(MacAclMixin, DeviceTypeDriver):
"""
...
def get_port_channels(self) -> Dict[str, PortChannelDict]:
def get_port_channels(self) -> dict[str, PortChannelDict]:
"""
Returns port-channel (LAG) configuration and status.
@@ -129,8 +131,7 @@ class SwitchDriver(MacAclMixin, DeviceTypeDriver):
"""
...
def get_dot1x_ports(self) -> Dict[str, Dot1XPortDict]:
def get_dot1x_ports(self) -> dict[str, Dot1XPortDict]:
"""
Returns the 802.1X / NAC configuration per switch port.
@@ -350,7 +351,7 @@ class SwitchDriver(MacAclMixin, DeviceTypeDriver):
"""
...
def set_lag_members(self, lag_name: str, members: List[str]) -> None:
def set_lag_members(self, lag_name: str, members: list[str]) -> None:
"""
Sets the full member-port list of a LAG/trunk interface.
+132
View File
@@ -0,0 +1,132 @@
"""The SMBIOS identity of a host: system UUID, product serial, board serial.
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 firmware tables. Both sides
read them the same way on every Linux host, so the command, its parse and the
placeholder filter live here once, and a driver only carries the command across.
**Placeholders are no identity.** Board vendors leave defaults in the DMI tables
that are the same on thousands of machines: a Supermicro board's product serial
is ``0123456789``, others read ``To be filled by O.E.M.`` or ``Default string``,
and a common UUID on cheap boards is ``03000200-0400-0500-0006-000700080009``.
Matching on those would join unrelated machines, so they read as empty.
**Root.** ``product_uuid`` and ``product_serial`` are readable by root only; without
it they come back empty, which is "not known", not a mismatch.
"""
from __future__ import annotations
import re
from typing import TYPE_CHECKING
from napalm_device_types.models import SystemIdentityDict
from napalm_device_types.terminal import strip_terminal_codes
_BEGIN = "IDBEGIN"
_END = "IDEND"
_DMI = "/sys/class/dmi/id"
#: Reads the three files in one round trip, framed so a banner or prompt around the
#: output cannot shift a field.
SYSTEM_IDENTITY_COMMAND = (
f"printf '{_BEGIN}\\n%s\\n%s\\n%s\\n{_END}\\n' "
f'"$(cat {_DMI}/product_uuid 2>/dev/null)" '
f'"$(cat {_DMI}/product_serial 2>/dev/null)" '
f'"$(cat {_DMI}/board_serial 2>/dev/null)"'
)
#: Lowercased defaults board vendors leave in place.
_PLACEHOLDERS = frozenset(
{
"",
"none",
"n/a",
"na",
"not specified",
"not applicable",
"not available",
"to be filled by o.e.m.",
"to be filled by oem",
"default string",
"default",
"system serial number",
"base board serial number",
"chassis serial number",
"serial number",
"0123456789",
"1234567890",
"123456789",
"o.e.m.",
"oem",
"03000200-0400-0500-0006-000700080009",
}
)
#: A value of one repeated character ("0", "00000000", "FFFF-...") or of zeros and Fs.
_REPEATED = re.compile(r"^(.)\1*$")
def is_placeholder_identity(value: str | None) -> bool:
"""Whether *value* is a vendor default rather than this machine's identity."""
text = (value or "").strip().lower()
if text in _PLACEHOLDERS:
return True
compact = text.replace("-", "").replace(" ", "")
return bool(_REPEATED.match(compact))
def _clean(value: str) -> str:
value = value.strip()
return "" if is_placeholder_identity(value) else value
def parse_system_identity(output: str) -> SystemIdentityDict:
"""The identity from :data:`SYSTEM_IDENTITY_COMMAND`'s output, placeholders empty.
:raises ValueError: when the output carries no intact frame.
"""
lines = strip_terminal_codes(output).splitlines()
try:
start = lines.index(_BEGIN) + 1
end = lines.index(_END, start)
except ValueError:
raise ValueError("system identity: no intact IDBEGIN/IDEND frame") from None
fields = (lines[start:end] + ["", "", ""])[:3]
uuid, product_serial, board_serial = (_clean(f) for f in fields)
return {
"system_uuid": uuid.lower(),
"product_serial": product_serial,
"board_serial": board_serial,
}
class SystemIdentityMixin:
"""Adds :meth:`get_system_identity` to a driver that can run a command on a Linux host.
The template form (README, "Function classes"): command and parse are the same
everywhere and concrete here; a driver supplies only
:meth:`_run_system_identity_command` -- how a command reaches its host, and how it
gains root there. Mixed in by the drivers that can, so
``hasattr(driver, "get_system_identity")`` stays a truthful answer.
"""
if TYPE_CHECKING: # pragma: no cover - declared for type checkers only
def _run_system_identity_command(self, command: str, *, privileged: bool) -> str:
"""Run *command* on the host and return what it printed; as root when
*privileged* (``product_uuid`` and ``product_serial`` are root's)."""
...
def get_system_identity(self) -> SystemIdentityDict:
"""
Returns the host's SMBIOS identity, each value empty when unknown or a vendor
placeholder:
* system_uuid (str) - lowercase
* product_serial (str)
* board_serial (str)
:raises ValueError: if the output carried no intact reading.
"""
output = self._run_system_identity_command(SYSTEM_IDENTITY_COMMAND, privileged=True)
return parse_system_identity(output)
+19 -20
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""systemd services: listing them in one round trip, and starting and stopping them.
What systemd reports about its services, and how one is started or stopped, is
@@ -31,7 +30,7 @@ from __future__ import annotations
import re
from shlex import quote
from typing import Any, Dict, List, Set, Tuple, TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from napalm_device_types.models import ServiceDict
from napalm_device_types.services import ServiceControlMixin
@@ -131,7 +130,7 @@ def service_action_command(name: str, action: str) -> str:
)
def parse_action_result(output: str) -> Dict[str, Any]:
def parse_action_result(output: str) -> dict[str, Any]:
"""``{"success", "output"}`` from what :func:`service_action_command` printed.
Only the exit status decides. A job still running when ``timeout`` gave up
@@ -151,7 +150,7 @@ def parse_action_result(output: str) -> Dict[str, Any]:
return {"success": False, "output": text or f"systemctl exited with status {status}."}
def _frame(output: str) -> List[str]:
def _frame(output: str) -> list[str]:
lines = [line.strip() for line in strip_terminal_codes(output).splitlines()]
try:
start = lines.index(_BEGIN)
@@ -161,9 +160,9 @@ def _frame(output: str) -> List[str]:
return lines[start + 1 : end]
def _sections(lines: List[str]) -> Dict[str, List[str]]:
sections: Dict[str, List[str]] = {}
current: List[str] = []
def _sections(lines: list[str]) -> dict[str, list[str]]:
sections: dict[str, list[str]] = {}
current: list[str] = []
for line in lines:
if line.startswith("[") and line.endswith("]"):
current = sections.setdefault(line[1:-1], [])
@@ -172,15 +171,15 @@ def _sections(lines: List[str]) -> Dict[str, List[str]]:
return sections
def _unit_blocks(lines: List[str]) -> List[Dict[str, str]]:
def _unit_blocks(lines: list[str]) -> list[dict[str, str]]:
"""``systemctl show``'s output, one dict per unit.
Units are separated by a blank line -- except where ``xargs`` split the
list over two runs and the blocks meet, so a key seen twice starts the next
unit as well.
"""
blocks: List[Dict[str, str]] = []
current: Dict[str, str] = {}
blocks: list[dict[str, str]] = []
current: dict[str, str] = {}
for line in lines:
key, sep, value = line.partition("=")
if not sep or key in current:
@@ -198,17 +197,17 @@ def _base(unit: str) -> str:
return unit[: -len(_SUFFIX)]
def _main_pid(block: Dict[str, str]) -> int:
def _main_pid(block: dict[str, str]) -> int:
try:
return int(block.get("MainPID") or 0)
except ValueError:
return 0
def _loaded(blocks: List[Dict[str, str]]) -> Tuple[Dict[str, ServiceDict], Set[str]]:
def _loaded(blocks: list[dict[str, str]]) -> tuple[dict[str, ServiceDict], set[str]]:
"""The loaded services, and every name they go by (aliases included)."""
services: Dict[str, ServiceDict] = {}
names: Set[str] = set()
services: dict[str, ServiceDict] = {}
names: set[str] = set()
for block in blocks:
unit = block.get("Id", "")
if not unit.endswith(_SUFFIX) or block.get("LoadState") == "not-found":
@@ -224,14 +223,14 @@ def _loaded(blocks: List[Dict[str, str]]) -> Tuple[Dict[str, ServiceDict], Set[s
return services, names
def _installed(lines: List[str], known: Set[str]) -> Dict[str, ServiceDict]:
def _installed(lines: list[str], known: set[str]) -> dict[str, ServiceDict]:
"""Installed services that are not loaded: neither running nor starting now.
Templates, static units and aliases are left out -- the last also when an
older systemd lists an alias as ``enabled``, which is why every name a
loaded unit goes by is skipped.
"""
services: Dict[str, ServiceDict] = {}
services: dict[str, ServiceDict] = {}
for line in lines:
parts = line.split()
if len(parts) < 2:
@@ -253,7 +252,7 @@ def _installed(lines: List[str], known: Set[str]) -> Dict[str, ServiceDict]:
return services
def _apply_generated(services: Dict[str, ServiceDict], lines: List[str]) -> None:
def _apply_generated(services: dict[str, ServiceDict], lines: list[str]) -> None:
"""Take a generated unit's boot state from ``is-enabled``'s answer."""
for line in lines:
parts = line.split()
@@ -261,7 +260,7 @@ def _apply_generated(services: Dict[str, ServiceDict], lines: List[str]) -> None
services[_base(parts[0])]["enabled"] = parts[1] in _ENABLED
def parse_systemd_services(output: str) -> List[ServiceDict]:
def parse_systemd_services(output: str) -> list[ServiceDict]:
"""Parse what :data:`SYSTEMD_SERVICES_COMMAND` printed, sorted by name.
Lists every loaded service unit but those that are not found, and every
@@ -299,7 +298,7 @@ class SystemdServicesMixin(ServiceControlMixin):
"""
...
def get_services(self) -> List[ServiceDict]:
def get_services(self) -> list[ServiceDict]:
"""
Returns the services systemd knows, in one round trip.
@@ -316,7 +315,7 @@ class SystemdServicesMixin(ServiceControlMixin):
)
return parse_systemd_services(output)
def manage_service(self, name: str, action: str) -> Dict[str, Any]:
def manage_service(self, name: str, action: str) -> dict[str, Any]:
"""
Applies *action* (start, stop, restart, enable, disable) to the service *name*.
-1
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""What a pseudo-terminal adds to a command's output, taken out again.
A screen-scraping transport (netmiko) gives the remote command a terminal. Tools
+17 -6
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Pending package updates and applying them.
The two device types that offer this used to declare it under two different
@@ -13,15 +12,15 @@ this class in can never shadow a working implementation from a sibling base.
from __future__ import annotations
from typing import Any, Dict, List, TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from napalm_device_types.models import ApplyUpdatesResultDict, UpdateDict
from napalm_device_types.models import ApplyUpdatesResultDict, UpdateDict, UpdateProgressDict
class UpdateMixin:
if TYPE_CHECKING:
def get_available_updates(self) -> List[UpdateDict]:
def get_available_updates(self) -> list[UpdateDict]:
"""
Returns a list of packages that have a newer version available.
@@ -51,7 +50,7 @@ class UpdateMixin:
"""
...
def refresh_available_updates(self) -> Dict[str, Any]:
def refresh_available_updates(self) -> dict[str, Any]:
"""
Refreshes the host's package index, so that :meth:`get_available_updates`
reports what the repositories offer now (``apt-get update``,
@@ -61,7 +60,19 @@ class UpdateMixin:
"""
...
def apply_updates(self, packages: List[str]) -> ApplyUpdatesResultDict:
def get_update_progress(self) -> UpdateProgressDict:
"""
Where an update the device runs by itself stands, for a caller that
follows it: a firewall's firmware upgrade, which :meth:`apply_updates`
only starts. ``state`` is ``"running"`` until it ends in ``"done"``
or ``"failed"``; ``log`` is the device's whole log of it.
Declared only: a driver whose :meth:`apply_updates` returns when the
update is over has nothing to report here.
"""
...
def apply_updates(self, packages: list[str]) -> ApplyUpdatesResultDict:
"""
Upgrades the given packages to the newest available version.
+38 -31
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Virtual IPs: the addresses a host's HA configuration lets float between machines.
vip-manager and keepalived put a service address on whichever member is master
@@ -34,7 +33,8 @@ host.
handed to a root shell.
**None is "did not look".** A file that exists but cannot be read, an include
that cannot be followed, a host without systemd: that mechanism is None, and the
that cannot be followed, a host without systemd (no ``systemctl``, or systemd not
running as PID 1, as in a container): that mechanism is None, and the
caller keeps what it knew. ``[]`` is a host that has none.
**Root, and without it.** The configuration may be root's alone. The whole
@@ -50,9 +50,10 @@ from __future__ import annotations
import ipaddress
import re
from collections.abc import Callable
from dataclasses import dataclass, field
from shlex import quote
from typing import Callable, Dict, List, Optional, Set, Tuple, TYPE_CHECKING
from typing import TYPE_CHECKING
from napalm_device_types.models import VirtualIpDict, VirtualIpsDict
from napalm_device_types.terminal import strip_terminal_codes
@@ -60,6 +61,8 @@ from napalm_device_types.terminal import strip_terminal_codes
_BEGIN = "VIPS_BEGIN"
_END = "VIPS_END"
_NO_SYSTEMD = "no-systemd"
#: Exists only while systemd runs as PID 1; a container may carry systemctl without.
_SYSTEMD_RUN = "/run/systemd/system"
#: A record that says a mechanism was not fully read.
_BLIND = frozenset({"unreadable", "too-deep"})
_KEEPALIVED_CONF = "/etc/keepalived/keepalived.conf"
@@ -72,7 +75,9 @@ _CFG_KEYS = "(ip|netmask|mask|interface|iface|trigger-key|key)"
#: ``instance <name>`` / ``interface`` / ``virtual_router_id`` / ``vip <addr> [dev <if>]``
#: / ``end``, and ``unreadable``/``too-deep``/``missing <path>``. No ``>`` but in
#: ``2>/dev/null``: comparisons are written the other way round. ``\047`` is a
#: single quote, so the program itself needs none.
#: single quote, so the program itself needs none. A statement ends with its line
#: unless the next one opens with ``{``: keepalived takes ``vrrp_instance VI_2``
#: and its brace on two lines.
_AWK = " ".join(
(
r'function q(p) { return "\047" p "\047" }',
@@ -102,10 +107,11 @@ _AWK = " ".join(
r' if (4 < d || (f in seen)) { print "too-deep " f; return }'
r" if (!ok(f)) return; seen[f] = 1;"
r' while (0 < (r = (getline l < f))) { sub(/[#!].*/, "", l); gsub(/[{}]/, " & ", l);'
r' n = split(l, t, " "); nw = 0; for (k = 1; k <= n; k++) {'
r' n = split(l, t, " "); if (n && nw && t[1] != "{") { stmt(w, nw, f, d); nw = 0 }'
r" for (k = 1; k <= n; k++) {"
r' if (t[k] == "{") { push(nw ? w[1] : "", 1 < nw ? w[2] : ""); nw = 0 }'
r' else if (t[k] == "}") { stmt(w, nw, f, d); nw = 0; pop() } else w[++nw] = t[k] }'
r" stmt(w, nw, f, d) }"
r' else if (t[k] == "}") { stmt(w, nw, f, d); nw = 0; pop() } else w[++nw] = t[k] } }'
r" stmt(w, nw, f, d);"
r' if (r < 0) print "unreadable " f; close(f); delete seen[f] }',
r"function inc(p, f, d, c, g, dir, fs, n, i) {"
r' if (p !~ /^\//) { dir = f; sub(/[^\/]*$/, "", dir); p = dir p }'
@@ -138,7 +144,7 @@ _AWK = " ".join(
VIRTUAL_IPS_COMMAND = (
"PATH=$PATH:/usr/sbin:/sbin; "
"printf '%s%s\\n' VIPS_ BEGIN; "
"if command -v systemctl >/dev/null 2>&1; then echo '[systemd]'; "
f"if command -v systemctl >/dev/null 2>&1 && [ -d {_SYSTEMD_RUN} ]; then echo '[systemd]'; "
"for u in $(systemctl list-units --all --plain --no-legend --type=service "
"vip-manager.service 'vip-manager@*' keepalived.service 2>/dev/null | awk '{print $1}'); do "
"case $u in keepalived*) m=keepalived;; *) m=vip-manager;; esac; "
@@ -151,10 +157,10 @@ VIRTUAL_IPS_COMMAND = (
_COMMENT_RE = re.compile(r"\s+#.*$")
_VARIABLE_RE = re.compile(r"^\$\{?(\w+)\}?$")
_Record = Tuple[str, str]
_Record = tuple[str, str]
def _frame(output: str) -> List[str]:
def _frame(output: str) -> list[str]:
lines = [line.strip() for line in strip_terminal_codes(output).splitlines()]
try:
start = lines.index(_BEGIN)
@@ -164,9 +170,9 @@ def _frame(output: str) -> List[str]:
return lines[start + 1 : end]
def _sections(lines: List[str]) -> List[Tuple[str, str, List[_Record]]]:
def _sections(lines: list[str]) -> list[tuple[str, str, list[_Record]]]:
"""``[<mechanism> <unit>]`` headers, each with its records as ``(kind, rest)``."""
sections: List[Tuple[str, str, List[_Record]]] = []
sections: list[tuple[str, str, list[_Record]]] = []
for line in lines:
if line.startswith("[") and line.endswith("]"):
mechanism, _, unit = line[1:-1].partition(" ")
@@ -185,7 +191,7 @@ def _clean(value: str) -> str:
return value.strip()
def _address(text: str) -> Optional[Tuple[str, Optional[int]]]:
def _address(text: str) -> tuple[str, int | None] | None:
"""``"10.0.0.10/24"`` -> ``("10.0.0.10", 24)``; None for what is no address."""
host, slash, prefix = text.partition("/")
try:
@@ -199,7 +205,7 @@ def _address(text: str) -> Optional[Tuple[str, Optional[int]]]:
return str(address), int(prefix)
def _prefix(mask: Optional[str]) -> Optional[int]:
def _prefix(mask: str | None) -> int | None:
"""``"24"`` or ``"255.255.255.0"`` -> 24."""
if not mask:
return None
@@ -211,27 +217,27 @@ def _prefix(mask: Optional[str]) -> Optional[int]:
return None
def _running(records: List[_Record]) -> Optional[bool]:
def _running(records: list[_Record]) -> bool | None:
states = [rest for kind, rest in records if kind == "active"]
return states[-1] == "active" if states else None
def _vip_manager(unit: str, records: List[_Record]) -> List[VirtualIpDict]:
values: Dict[str, Dict[str, str]] = {"arg": {}, "env": {}, "cfg": {}}
def _vip_manager(unit: str, records: list[_Record]) -> list[VirtualIpDict]:
values: dict[str, dict[str, str]] = {"arg": {}, "env": {}, "cfg": {}}
for kind, rest in records:
if kind in values:
key, _, value = rest.partition(" ")
values[kind][key] = value
env = values["env"]
def resolve(value: str) -> Optional[str]:
def resolve(value: str) -> str | None:
value = _clean(value)
variable = _VARIABLE_RE.match(value)
if variable:
value = _clean(env.get(variable.group(1), ""))
return value if value and "$" not in value else None
def pick(args: Tuple[str, ...], envs: Tuple[str, ...], cfgs: Tuple[str, ...]) -> Optional[str]:
def pick(args: tuple[str, ...], envs: tuple[str, ...], cfgs: tuple[str, ...]) -> str | None:
for kind, names in (("arg", args), ("env", envs), ("cfg", cfgs)):
for name in names:
if name in values[kind]:
@@ -266,15 +272,15 @@ def _vip_manager(unit: str, records: List[_Record]) -> List[VirtualIpDict]:
@dataclass
class _Instance:
name: str
interface: Optional[str] = None
interface: str | None = None
vrid: str = ""
vips: List[str] = field(default_factory=list)
vips: list[str] = field(default_factory=list)
def _keepalived(unit: str, records: List[_Record]) -> List[VirtualIpDict]:
def _keepalived(unit: str, records: list[_Record]) -> list[VirtualIpDict]:
running = _running(records)
instances: List[_Instance] = []
current: Optional[_Instance] = None
instances: list[_Instance] = []
current: _Instance | None = None
for kind, rest in records:
if kind == "instance":
current = _Instance(rest or unit)
@@ -292,8 +298,8 @@ def _keepalived(unit: str, records: List[_Record]) -> List[VirtualIpDict]:
return [entry for instance in instances for entry in _instance_entries(instance, running)]
def _instance_entries(instance: _Instance, running: Optional[bool]) -> List[VirtualIpDict]:
entries: List[VirtualIpDict] = []
def _instance_entries(instance: _Instance, running: bool | None) -> list[VirtualIpDict]:
entries: list[VirtualIpDict] = []
for vip in instance.vips:
tokens = vip.split()
parsed = _address(tokens[0]) if tokens else None
@@ -320,13 +326,13 @@ def _host_prefix(address: str) -> int:
return ipaddress.ip_address(address).max_prefixlen
_READERS: Dict[str, Tuple[str, Callable[[str, List[_Record]], List[VirtualIpDict]]]] = {
_READERS: dict[str, tuple[str, Callable[[str, list[_Record]], list[VirtualIpDict]]]] = {
"vip-manager": ("vip_manager", _vip_manager),
"keepalived": ("keepalived", _keepalived),
}
def _order(entry: VirtualIpDict) -> Tuple[int, int, str]:
def _order(entry: VirtualIpDict) -> tuple[int, int, str]:
address = ipaddress.ip_address(entry["address"])
return address.version, int(address), entry["instance"]
@@ -340,8 +346,8 @@ def parse_virtual_ips(output: str) -> VirtualIpsDict:
sections = _sections(_frame(output))
if any(mechanism == _NO_SYSTEMD for mechanism, _, _ in sections):
return {"vip_manager": None, "keepalived": None}
found: Dict[str, List[VirtualIpDict]] = {"vip_manager": [], "keepalived": []}
blind: Set[str] = set()
found: dict[str, list[VirtualIpDict]] = {"vip_manager": [], "keepalived": []}
blind: set[str] = set()
for mechanism, unit, records in sections:
if mechanism not in _READERS:
continue
@@ -350,7 +356,8 @@ def parse_virtual_ips(output: str) -> VirtualIpsDict:
blind.add(key)
else:
found[key].extend(read(unit, records))
def result(key: str) -> Optional[List[VirtualIpDict]]:
def result(key: str) -> list[VirtualIpDict] | None:
return None if key in blind else sorted(found[key], key=_order)
return {"vip_manager": result("vip_manager"), "keepalived": result("keepalived")}
+35 -11
View File
@@ -1,14 +1,14 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"
[project]
name = "napalm-device-types"
version = "4.5.1"
version = "4.21.1"
description = "Abstract device-type base classes for NAPALM drivers"
readme = "README.md"
requires-python = ">=3.10"
license = { text = "Apache-2.0" }
license = "Apache-2.0"
authors = [
{ name = "Christian Manivong", email = "christian@manivong.de" },
]
@@ -28,38 +28,62 @@ classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Intended Audience :: System Administrators",
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: System :: Networking",
"Topic :: System :: Systems Administration",
"Typing :: Typed",
]
dependencies = [
"napalm>=4.0",
"typing-extensions>=4.0",
]
[project.urls]
Homepage = "https://github.com/chrismanivong/napalm-device-types"
Repository = "https://github.com/chrismanivong/napalm-device-types"
Issues = "https://github.com/chrismanivong/napalm-device-types/issues"
Repository = "https://git.netork.io/NAPALM/napalm-device-types"
Issues = "https://git.netork.io/NAPALM/napalm-device-types/issues"
Changelog = "https://git.netork.io/NAPALM/napalm-device-types/src/branch/main/CHANGELOG.md"
[project.optional-dependencies]
dev = [
"pytest",
"mypy",
"build",
"twine",
"pytest>=8",
"pytest-cov>=5",
"mypy>=1.11",
"ruff>=0.6",
]
[tool.setuptools.packages.find]
where = ["."]
include = ["napalm_device_types*"]
[tool.setuptools.package-data]
napalm_device_types = ["py.typed"]
[tool.ruff]
line-length = 100
target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
[tool.ruff.lint.per-file-ignores]
# Recorded device output keeps its lines.
"tests/**" = ["E501"]
[tool.mypy]
python_version = "3.10"
strict = true
ignore_missing_imports = true
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.coverage.run]
source = ["napalm_device_types"]
[tool.coverage.report]
fail_under = 95
+39
View File
@@ -0,0 +1,39 @@
{
"coverage_floor": 95.0,
"function_complexity": {
"napalm_device_types/_ucd_metrics.py::build_if_metrics": 20,
"napalm_device_types/_ucd_metrics.py::collect_ucd_metrics": 17,
"napalm_device_types/host_firewall.py::_iptables_rule": 11,
"napalm_device_types/host_firewall.py::_module_match": 13,
"napalm_device_types/host_firewall.py::_module_options": 14,
"napalm_device_types/host_firewall.py::_nft_field": 15,
"napalm_device_types/host_firewall.py::_nft_match": 12,
"napalm_device_types/host_firewall.py::_nft_rule": 16,
"napalm_device_types/host_firewall.py::_parse_nft": 14,
"napalm_device_types/host_firewall.py::rule_text": 14,
"napalm_device_types/host_status.py::_reboot": 12,
"napalm_device_types/lag.py::add_lag_interfaces": 11,
"napalm_device_types/listening.py::parse_listening_sockets": 13,
"napalm_device_types/service_clusters.py::_patroni": 11,
"napalm_device_types/ssh_server.py::plan_ssh_hardening": 38,
"napalm_device_types/virtual_ips.py::_keepalived": 12
},
"function_lines": {
"napalm_device_types/_ucd_metrics.py::collect_ucd_metrics": 119,
"napalm_device_types/dhcp.py::DhcpServerMixin.diff_dhcp_reservations": 54,
"napalm_device_types/dhcp.py::DhcpServerMixin.diff_dhcp_subnets": 60,
"napalm_device_types/firewall_rules.py::FirewallRuleMixin.diff_firewall_rules": 56,
"napalm_device_types/hypervisor.py::HypervisorDriver.create_vm_from_cloud_init": 84,
"napalm_device_types/hypervisor.py::HypervisorDriver.get_vm_config": 77,
"napalm_device_types/ssh_server.py::OpenSshServerMixin.harden_ssh_server": 52,
"napalm_device_types/ssh_server.py::plan_ssh_hardening": 60,
"napalm_device_types/storage.py::StorageDriver.get_volumes": 52,
"napalm_device_types/switch.py::SwitchDriver.get_dot1x_ports": 51,
"napalm_device_types/switch.py::SwitchDriver.get_poe_status": 63
},
"mypy_relaxed": [],
"python_file_lines": {
"napalm_device_types/models.py": 1504
},
"untyped_public": []
}
+18 -3
View File
@@ -8,8 +8,6 @@ paramiko channel is faked, so the tests pin behaviour, not paramiko.
from __future__ import annotations
import socket
import pytest
from napalm_device_types.channel import (
@@ -56,7 +54,7 @@ class FakeChannel:
if self.out:
return self.out.pop(0)
if self.hang:
raise socket.timeout()
raise TimeoutError()
return b""
def recv_stderr_ready(self):
@@ -167,3 +165,20 @@ def test_close_closes_the_channel():
ParamikoExecStream(ch).close()
assert ch.closed
def test_run_keeps_output_that_arrives_after_the_exit_status():
"""sshd may send exit-status before the last stdout bytes."""
class EarlyExit(FakeChannel):
def exit_status_ready(self):
return True
def recv_ready(self):
return False
channel = EarlyExit(stdout=[b"head ", b"tail"], stderr=[b"warn"])
result = run_on_transport(FakeTransport(channel), "cmd")
assert result == CommandResult("head tail", "warn", 0)
+6 -2
View File
@@ -32,7 +32,9 @@ class FakeDriver(ContainerEngineMixin):
def run_command(self, command, *, privileged=False, timeout=60, stdin=None):
self.commands.append((command, privileged, timeout, stdin))
found = any(command == f"command -v {b}" for b in self._binaries())
return CommandResult(stdout="/usr/bin/x\n" if found else "", stderr="", exit_code=0 if found else 1)
return CommandResult(
stdout="/usr/bin/x\n" if found else "", stderr="", exit_code=0 if found else 1
)
def open_stream(self, command, *, privileged=False):
self.streams.append((command, privileged))
@@ -86,7 +88,9 @@ def test_cli_arguments_are_quoted_and_prefixed_by_the_binary():
def test_cli_stdin_and_timeout_reach_the_channel():
driver = FakeDriver()
driver.open_container_engine("docker").run_cli(["login", "--password-stdin"], stdin=b"x", timeout=30)
driver.open_container_engine("docker").run_cli(
["login", "--password-stdin"], stdin=b"x", timeout=30
)
_command, _privileged, timeout, stdin = driver.commands[-1]
assert (timeout, stdin) == (30, b"x")
+30 -7
View File
@@ -9,9 +9,10 @@ principle: generic vs. device-specific logic" for why this logic lives here
and not in a vendor driver.
"""
from typing import Any, Dict, List, Optional
from typing import Any
import pytest
from napalm_device_types import DhcpServerMixin, FirewallDriver, ResidentialGatewayDriver
from napalm_device_types.dhcp import normalize_mac
from napalm_device_types.models import DhcpReservationDict
@@ -33,21 +34,21 @@ def _reservation(**overrides: Any) -> DhcpReservationDict:
class _FakeDhcpServer(DhcpServerMixin):
"""In-memory fake — no network, no Kea/UCI specifics."""
def __init__(self, live: Optional[List[DhcpReservationDict]] = None) -> None:
def __init__(self, live: list[DhcpReservationDict] | None = None) -> None:
self.live = live or []
self.applied: List[Dict[str, Any]] = []
self.applied: list[dict[str, Any]] = []
self.commits = 0
def get_dhcp_reservations(self) -> List[DhcpReservationDict]:
def get_dhcp_reservations(self) -> list[DhcpReservationDict]:
return self.live
def apply_dhcp_reservation(
self, reservation: DhcpReservationDict, *, uuid: Optional[str] = None
) -> Dict[str, Any]:
self, reservation: DhcpReservationDict, *, uuid: str | None = None
) -> dict[str, Any]:
self.applied.append({"reservation": reservation, "uuid": uuid})
return {"success": True}
def commit_dhcp_reservations(self) -> Dict[str, Any]:
def commit_dhcp_reservations(self) -> dict[str, Any]:
self.commits += 1
return {"success": True}
@@ -206,3 +207,25 @@ class TestApplyReservationSet:
assert driver.commits == 0
assert driver.applied == []
assert lines == ["[commit] no changes"]
class TestKeysThatCannotNameOneReservation:
def test_a_live_reservation_without_mac_is_never_matched(self):
driver = _FakeDhcpServer([_reservation(uuid="b", mac="")])
diff = driver.diff_dhcp_reservations([_reservation()])
assert diff == {"add": [_reservation()], "update": []}
@pytest.mark.parametrize(
"live, desired",
[
([], [_reservation(mac="")]),
([], [_reservation(mac="AA-BB-CC-DD-EE-02"), _reservation(mac="aabb.ccdd.ee02")]),
([_reservation(uuid="a"), _reservation(uuid="b")], [_reservation()]),
],
ids=["empty desired", "desired twice", "two live share it"],
)
def test_raises(self, live, desired):
with pytest.raises(ValueError):
_FakeDhcpServer(live).diff_dhcp_reservations(desired)
+69
View File
@@ -0,0 +1,69 @@
"""Two facts an HA pair of servers reports about itself.
`get_dhcp_ha` is what a DHCP server's own HA configuration says: the peers it
pairs with and each peer's role, as the server words it. `get_config_sync` is
where a host pushes its configuration and for which of its services. Both are
declarations; what they mean for the pair -- hot standby, who to write to --
is the caller's to work out.
"""
from __future__ import annotations
import inspect
import napalm_device_types as package
from napalm_device_types import ConfigSyncMixin, DhcpServerMixin, FirewallDriver
class TestDeclaredOnly:
def test_get_dhcp_ha_is_absent_until_a_driver_implements_it(self):
assert not hasattr(DhcpServerMixin, "get_dhcp_ha")
def test_get_config_sync_is_absent_until_a_driver_implements_it(self):
assert not hasattr(ConfigSyncMixin, "get_config_sync")
def test_config_sync_mixin_owns_nothing_callable(self):
own = [
name
for name, value in vars(ConfigSyncMixin).items()
if not name.startswith("__") and inspect.isfunction(value)
]
assert own == []
def test_the_firewall_role_does_not_carry_config_sync(self):
"""A driver mixes the capability in; the role does not promise it."""
assert not issubclass(FirewallDriver, ConfigSyncMixin)
def test_a_driver_that_implements_them_is_found_by_hasattr(self):
class Pair(ConfigSyncMixin, DhcpServerMixin):
def get_dhcp_ha(self):
return {"enabled": False, "peers": []}
def get_config_sync(self):
return {"peer": None, "services": [], "unmapped": []}
assert callable(getattr(Pair(), "get_dhcp_ha", None))
assert callable(getattr(Pair(), "get_config_sync", None))
class TestTypes:
def test_are_exported(self):
for name in ("ConfigSyncMixin", "ConfigSyncDict", "DhcpHaDict", "DhcpHaPeerDict"):
assert name in package.__all__ and hasattr(package, name), name
def test_a_peer_needs_only_its_name_and_role(self):
"""A future server may not have a URL to report; Kea does."""
from napalm_device_types import DhcpHaPeerDict
assert DhcpHaPeerDict.__required_keys__ == frozenset({"name", "role"})
def test_ha_needs_only_whether_it_is_on_and_the_peers(self):
from napalm_device_types import DhcpHaDict
assert DhcpHaDict.__required_keys__ == frozenset({"enabled", "peers"})
def test_config_sync_reports_what_it_could_not_name(self):
"""A sync item the driver has no service name for is still a fact."""
from napalm_device_types import ConfigSyncDict
assert ConfigSyncDict.__required_keys__ == frozenset({"peer", "services", "unmapped"})
+19 -10
View File
@@ -11,9 +11,10 @@ A subnet is a heavier object than a reservation: a wrong `pools` or
below lean on the never-delete and preserve-unmanaged-options guarantees.
"""
from typing import Any, Dict, List, Optional
from typing import Any
import pytest
from napalm_device_types import DhcpServerMixin
from napalm_device_types.models import DhcpSubnetDict
@@ -35,21 +36,21 @@ def _subnet(**overrides: Any) -> DhcpSubnetDict:
class _FakeDhcpServer(DhcpServerMixin):
def __init__(self, live: Optional[List[DhcpSubnetDict]] = None) -> None:
def __init__(self, live: list[DhcpSubnetDict] | None = None) -> None:
self.live = live or []
self.applied: List[Dict[str, Any]] = []
self.applied: list[dict[str, Any]] = []
self.commits = 0
def get_dhcp_subnets(self) -> List[DhcpSubnetDict]:
def get_dhcp_subnets(self) -> list[DhcpSubnetDict]:
return self.live
def apply_dhcp_subnet(
self, subnet: DhcpSubnetDict, *, uuid: Optional[str] = None
) -> Dict[str, Any]:
self, subnet: DhcpSubnetDict, *, uuid: str | None = None
) -> dict[str, Any]:
self.applied.append({"subnet": subnet, "uuid": uuid})
return {"success": True}
def commit_dhcp_subnets(self) -> Dict[str, Any]:
def commit_dhcp_subnets(self) -> dict[str, Any]:
self.commits += 1
return {"success": True}
@@ -215,9 +216,7 @@ class _BareDriver(DhcpServerMixin):
pass
@pytest.mark.parametrize(
"method", ["get_dhcp_subnets", "apply_dhcp_subnet", "commit_dhcp_subnets"]
)
@pytest.mark.parametrize("method", ["get_dhcp_subnets", "apply_dhcp_subnet", "commit_dhcp_subnets"])
def test_device_specific_methods_are_absent_until_implemented(method: str) -> None:
"""Declared under TYPE_CHECKING, so they do not exist until a driver adds them."""
assert not hasattr(_BareDriver, method)
@@ -226,3 +225,13 @@ def test_device_specific_methods_are_absent_until_implemented(method: str) -> No
def test_calling_a_missing_device_method_fails_loudly() -> None:
with pytest.raises(AttributeError):
_BareDriver().get_dhcp_subnets()
@pytest.mark.parametrize(
"desired",
[[_subnet(subnet="")], [_subnet(), _subnet(subnet=" 10.10.20.0/24 ")]],
ids=["empty", "twice"],
)
def test_a_subnet_that_cannot_name_one_raises(desired):
with pytest.raises(ValueError):
_FakeDhcpServer([]).diff_dhcp_subnets(desired)
+64
View File
@@ -0,0 +1,64 @@
"""Facts instead of warnings (docs/STRUCTURE.md, NetOrk/netork#1111).
A driver reports what it reads; the application decides what is a warning. Three
facts that drivers so far only reported as warning codes get contracts of their
own: the package manager's proxy, licenses and subscriptions, and the state of a
hypervisor's hosts. All are declared only, so a capability check stays truthful.
"""
from __future__ import annotations
from typing import get_type_hints
from napalm_device_types import (
HypervisorDriver,
HypervisorHostDict,
LicenseDict,
LicenseMixin,
PackageProxyMixin,
)
def test_a_license_says_what_it_is_and_whether_it_holds():
hints = get_type_hints(LicenseDict)
assert {"name", "status"} <= set(hints)
assert LicenseDict.__required_keys__ == frozenset({"name", "status"})
assert set(hints) == {"name", "status", "edition", "expires", "detail", "withholds"}
def test_a_missing_license_may_say_what_the_device_withholds_without_it():
# A Supermicro X10 with only SFT-OOB-LIC answers Redfish but keeps its drives and
# firmware inventory until DCMS is activated: partly licensed, not unlicensed.
hints = get_type_hints(LicenseDict)
# Python 3.10 keeps NotRequired[...] around the type; later versions strip it.
assert "list[str]" in str(hints["withholds"])
assert "withholds" not in LicenseDict.__required_keys__
def test_a_hypervisor_host_says_how_it_stands():
hints = get_type_hints(HypervisorHostDict)
assert set(hints) == {"name", "connected", "maintenance", "config_issues", "managed_by"}
assert HypervisorHostDict.__required_keys__ == frozenset(
{"name", "connected", "maintenance", "config_issues"}
)
def test_the_getters_are_declared_only():
assert not hasattr(PackageProxyMixin, "get_package_proxy")
assert not hasattr(LicenseMixin, "get_licenses")
assert not hasattr(HypervisorDriver, "get_hypervisor_hosts")
def test_a_firmware_offer_says_what_runs_and_what_is_offered():
"""The vendor's newer firmware is a fact the driver can know (a download
catalogue, the device's own update check); whether it is a warning is the
application's call. Comparing vendor version strings stays with the driver,
so it says itself whether the offer is newer."""
from napalm_device_types import FirmwareOfferDict, FirmwareOfferMixin
hints = get_type_hints(FirmwareOfferDict)
assert set(hints) == {"current_version", "latest_version", "newer", "download_url"}
assert FirmwareOfferDict.__required_keys__ == frozenset(
{"current_version", "latest_version", "newer"}
)
assert not hasattr(FirmwareOfferMixin, "get_firmware_offer")
+55 -2
View File
@@ -3,6 +3,8 @@
from __future__ import annotations
import pytest
from napalm.base import NetworkDriver
from napalm_device_types import (
AccessPointDriver,
DeviceTypeDriver,
@@ -14,9 +16,8 @@ from napalm_device_types import (
ResidentialGatewayDriver,
StorageDriver,
SwitchDriver,
rules_max_weight,
)
from napalm.base import NetworkDriver
# ── Hierarchy ─────────────────────────────────────────────────────────────────
@@ -48,6 +49,7 @@ def test_device_type_driver_inherits_network_driver():
# ── Default attributes ────────────────────────────────────────────────────────
def test_defaults_on_device_type_driver():
assert DeviceTypeDriver.VENDOR == ""
assert DeviceTypeDriver.DRIVER_NAME == ""
@@ -71,6 +73,7 @@ def test_fingerprint_attributes_inherited(cls):
# ── FingerprintRule ───────────────────────────────────────────────────────────
def test_fingerprint_rule_defaults():
r = FingerprintRule("fritz!box")
assert r.pattern == "fritz!box"
@@ -90,6 +93,54 @@ def test_fingerprint_rule_negative():
assert r.negative is True
def test_fingerprint_rule_has_no_group_by_default():
assert FingerprintRule("fritz!box").group is None
def test_fingerprint_rule_group():
r = FingerprintRule("idrac", weight=6.0, group="landing_page")
assert r.group == "landing_page"
# ── rules_max_weight: the most a set of rules can add ─────────────────────────
def test_max_weight_sums_rules_that_can_match_together():
rules = [FingerprintRule("proxmox virtual environment", 9.0), FingerprintRule("pve", 2.0)]
assert rules_max_weight(rules) == 11.0
def test_max_weight_counts_one_rule_per_group():
# Alternatives: a page is ATEN's or iDRAC's or iLO's, never all three. Summing
# them made a driver that knows three BMC families lose to one that knows none.
rules = [
FingerprintRule("aten international", 6.0, group="landing_page"),
FingerprintRule("idrac", 6.0, group="landing_page"),
FingerprintRule("integrated lights-out", 4.0, group="landing_page"),
]
assert rules_max_weight(rules) == 6.0
def test_max_weight_mixes_groups_and_single_rules():
rules = [
FingerprintRule("redfishversion", 10.0, group="redfish_answer"),
FingerprintRule("@message.extendedinfo", 8.0, group="redfish_answer"),
FingerprintRule("#serviceroot.", 5.0),
FingerprintRule("aten international", 6.0, group="landing_page"),
FingerprintRule("idrac", 6.0, group="landing_page"),
]
assert rules_max_weight(rules) == 21.0
def test_max_weight_ignores_negative_rules():
rules = [FingerprintRule("vcenter", 9.0), FingerprintRule("vmware esxi", 9.0, negative=True)]
assert rules_max_weight(rules) == 9.0
def test_max_weight_of_no_rules_is_zero():
assert rules_max_weight([]) == 0.0
def test_fingerprint_rule_is_immutable():
r = FingerprintRule("test", weight=3.0)
with pytest.raises(AttributeError):
@@ -98,6 +149,7 @@ def test_fingerprint_rule_is_immutable():
# ── PortSpec ──────────────────────────────────────────────────────────────────
def test_port_spec_defaults():
p = PortSpec("https", 8006)
assert p.scheme == "https"
@@ -121,6 +173,7 @@ def test_port_spec_is_immutable():
# ── Concrete driver subclass ──────────────────────────────────────────────────
def test_concrete_driver_overrides_fingerprint():
class MyDriver(FirewallDriver):
VENDOR = "Acme"
+35 -7
View File
@@ -9,9 +9,10 @@ device-specific logic" for why this logic lives here and not in a vendor
driver.
"""
from typing import Any, Dict, List, Optional
from typing import Any
import pytest
from napalm_device_types import FirewallDriver
from napalm_device_types.models import FirewallRuleDict
@@ -39,21 +40,21 @@ def _rule(**overrides: Any) -> FirewallRuleDict:
class _FakeFirewall(FirewallDriver):
"""In-memory fake — no network, no OPNsense/vendor specifics."""
def __init__(self, live_rules: Optional[List[FirewallRuleDict]] = None) -> None:
def __init__(self, live_rules: list[FirewallRuleDict] | None = None) -> None:
self.live_rules = live_rules or []
self.applied: List[Dict[str, Any]] = []
self.applied: list[dict[str, Any]] = []
self.committed = False
def get_firewall_rules(self) -> List[FirewallRuleDict]:
def get_firewall_rules(self) -> list[FirewallRuleDict]:
return self.live_rules
def apply_firewall_rule(
self, rule: FirewallRuleDict, *, uuid: Optional[str] = None
) -> Dict[str, Any]:
self, rule: FirewallRuleDict, *, uuid: str | None = None
) -> dict[str, Any]:
self.applied.append({"rule": rule, "uuid": uuid})
return {"success": True}
def commit_firewall_rules(self) -> Dict[str, Any]:
def commit_firewall_rules(self) -> dict[str, Any]:
self.committed = True
return {"success": True}
@@ -169,3 +170,30 @@ class TestApplyFirewallRuleset:
assert driver.applied == []
assert driver.committed is True
class TestKeysThatCannotNameOneRule:
"""An empty or shared description must never overwrite a rule by guess."""
def test_a_live_rule_without_description_is_never_matched(self):
driver = _FakeFirewall([_rule(uuid="auto1", description="", action="block")])
diff = driver.diff_firewall_rules([_rule()])
assert diff == {"add": [_rule()], "update": []}
@pytest.mark.parametrize(
"live, desired",
[
([], [_rule(description="")]),
([], [_rule(), _rule(action="block")]),
([_rule(uuid="u1"), _rule(uuid="u2")], [_rule()]),
],
ids=["empty desired", "desired twice", "two live rules share it"],
)
def test_raises(self, live, desired):
driver = _FakeFirewall(live)
with pytest.raises(ValueError):
driver.diff_firewall_rules(desired)
assert driver.applied == []
+187
View File
@@ -0,0 +1,187 @@
"""Fix routines are templates (docs/STRUCTURE.md, NetOrk/netork#1105).
The template fixes the order of the steps, the driver knows how each step is done
on its device, the caller supplies every value. None of them is mixed into a role
base, so ``callable(getattr(driver, name, None))`` stays a truthful answer.
"""
from __future__ import annotations
import pytest
from napalm_device_types import (
AccessPointDriver,
EnsurePackageMixin,
FirewallDriver,
HypervisorDriver,
ManagementControllerDriver,
MediaDriver,
OSDriver,
PackageProxyMixin,
PhoneDriver,
ResidentialGatewayDriver,
SnmpAgentMixin,
StorageDriver,
SwitchDriver,
run_fix_steps,
)
ROLE_BASES = [
AccessPointDriver, FirewallDriver, HypervisorDriver, ManagementControllerDriver, MediaDriver,
OSDriver, PhoneDriver, ResidentialGatewayDriver, StorageDriver, SwitchDriver,
] # fmt: skip
def _ok(output: str = ""):
return lambda: {"ok": True, "output": output}
def _fail(output: str):
return lambda: {"ok": False, "output": output}
class TestRunFixSteps:
def test_every_step_runs_in_order_and_is_reported(self):
result = run_fix_steps([("install", _ok("installed")), ("start", _ok("started"))])
assert result["success"] is True
assert result["steps"] == [
{"step": "install", "ok": True, "output": "installed"},
{"step": "start", "ok": True, "output": "started"},
]
assert result["output"] == "[install] installed\n[start] started"
def test_the_first_failure_stops_the_rest(self):
ran = []
def later():
ran.append("later")
return {"ok": True, "output": ""}
result = run_fix_steps([("install", _fail("no package")), ("start", later)])
assert result["success"] is False
assert [s["step"] for s in result["steps"]] == ["install"]
assert ran == []
def test_a_step_that_raises_is_a_failed_step(self):
def broken():
raise RuntimeError("connection lost")
result = run_fix_steps([("install", broken)])
assert result["success"] is False
assert result["steps"] == [{"step": "install", "ok": False, "output": "connection lost"}]
class _Agent(SnmpAgentMixin):
def __init__(self, fail_at: str = ""):
self.calls: list = []
self.fail_at = fail_at
def _step(self, name, *args):
self.calls.append((name, *args))
return {"ok": name != self.fail_at, "output": name}
def _install_snmp_agent(self):
return self._step("install")
def _write_snmp_agent_config(self, settings):
return self._step("configure", settings)
def _allow_snmp_sources(self, settings):
return self._step("firewall", settings)
def _start_snmp_agent(self):
return self._step("start")
class TestSnmpAgent:
SETTINGS = {
"community": "c0mm",
"allowed_sources": ["10.0.0.5"],
"contact": "ops",
"location": "rack 1",
}
def test_install_configure_open_and_start_with_the_caller_s_values(self):
agent = _Agent()
result = agent.configure_snmp_agent(self.SETTINGS)
assert result["success"] is True
assert agent.calls == [
("install",),
("configure", self.SETTINGS),
("firewall", self.SETTINGS),
("start",),
]
def test_a_failed_install_configures_nothing(self):
agent = _Agent(fail_at="install")
assert agent.configure_snmp_agent(self.SETTINGS)["success"] is False
assert agent.calls == [("install",)]
class _Packages(EnsurePackageMixin):
def __init__(self, installed):
self.installed = installed
self.asked: list = []
def get_packages(self):
return [{"name": n, "installed": True} for n in self.installed] + [
{"name": "lldpd", "installed": False}
]
def install_package(self, name, version=""):
self.asked.append(name)
class TestEnsurePackage:
def test_installs_what_is_missing(self):
driver = _Packages(installed=["curl"])
result = driver.ensure_package("lldpd")
assert result["success"] is True
assert driver.asked == ["lldpd"]
def test_leaves_alone_what_is_there(self):
driver = _Packages(installed=["lldpd"])
result = driver.ensure_package("lldpd")
assert result["success"] is True
assert driver.asked == []
assert "already installed" in result["output"]
def test_an_install_that_reports_failure_fails(self):
driver = _Packages(installed=[])
driver.install_package = lambda name, version="": {
"success": False,
"output": "E: no candidate",
}
result = driver.ensure_package("lldpd")
assert result["success"] is False
assert "no candidate" in result["output"]
@pytest.mark.parametrize("base", ROLE_BASES, ids=lambda b: b.__name__)
@pytest.mark.parametrize(
"method",
[
"configure_snmp_agent",
"ensure_package",
"configure_package_proxy",
"grant_container_engine_access",
],
)
def test_no_role_base_offers_a_fix(base, method):
assert getattr(base, method, None) is None
def test_the_package_proxy_is_declared_only():
assert not hasattr(PackageProxyMixin, "configure_package_proxy")
+501
View File
@@ -0,0 +1,501 @@
"""get_host_firewall: a Linux host's own netfilter rules, read once, in one shape.
Whether a service on a host is reachable from the internet is not only decided
by the address it listens on: the host's own firewall may drop the packets
before they reach it. Reading that is the same on every Linux host, so the
command and the parse live here, and a driver only carries the command across
(NetOrk/netork#1344).
``nft -j list ruleset`` is the primary reading. Rules that iptables-nft wrote
show their extensions there only as an opaque ``xt`` (``-m conntrack``,
``-m set``), so the tables iptables manages are taken from ``iptables-save``,
which spells them out. A legacy iptables is a ruleset of its own beside nft,
and both apply.
"""
from __future__ import annotations
import json
from shlex import quote
import pytest
from napalm_device_types.host_firewall import (
HOST_FIREWALL_COMMAND,
HostFirewallMixin,
HostFirewallUnavailable,
parse_host_firewall,
)
IPTABLES_NFT = """# Generated by iptables-save v1.8.10 (nf_tables) on Wed Oct 21 14:52:11 2026
*filter
:INPUT ACCEPT [2783578:295175610]
:FORWARD ACCEPT [0:0]
:OUTPUT ACCEPT [0:0]
:DOCKER-USER - [0:0]
:WGRUNNER - [0:0]
-A INPUT -s 10.255.255.4/32 -j WGRUNNER
-A INPUT -i ens6 -p udp -m udp --dport 51820 -j ACCEPT
-A INPUT -m set --match-set crowdsec-blacklists src -j DROP
-A INPUT -i ens6 -m state --state RELATED,ESTABLISHED -j ACCEPT
-A INPUT -i ens6 -p tcp -m multiport --dports 80,443,8000:8010 -j ACCEPT
-A INPUT ! -i lo -p tcp -m conntrack --ctstate NEW --ctorigdstport 2222 -j LOG --log-prefix "ssh "
-A INPUT -i ens6 -j DROP
-A FORWARD -j DOCKER-USER
-A DOCKER-USER -i eth+ ! -s 192.168.0.0/16 -g WGRUNNER
-A WGRUNNER -d 10.7.224.12/32 -p tcp -m tcp --dport 10051 -j ACCEPT
-A WGRUNNER -j REJECT --reject-with icmp-port-unreachable
-A WGRUNNER -j RETURN
COMMIT
# Completed on Wed Oct 21 14:52:11 2026
*nat
:PREROUTING ACCEPT [0:0]
:POSTROUTING ACCEPT [0:0]
:DOCKER - [0:0]
-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
-A DOCKER ! -i br-1 -p udp -m udp --dport 53 -j DNAT --to-destination 172.18.0.2:53
COMMIT
"""
IP6TABLES_NFT = """# Generated by ip6tables-save v1.8.10 (nf_tables) on Wed Oct 21 14:52:11 2026
*filter
:INPUT DROP [0:0]
:FORWARD ACCEPT [0:0]
:OUTPUT ACCEPT [0:0]
-A INPUT -i lo -j ACCEPT
COMMIT
"""
def _nft(*objects: dict) -> str:
meta = {"metainfo": {"version": "1.0.9", "json_schema_version": 1}}
return json.dumps({"nftables": [meta, *objects]})
#: The iptables-nft filter table as nft shows it -- with its extensions opaque --
#: and a native ``inet filter`` table of the host's own nftables.service.
NFT = _nft(
{"table": {"family": "ip", "name": "filter", "handle": 1}},
{
"chain": {
"family": "ip",
"table": "filter",
"name": "INPUT",
"type": "filter",
"hook": "input",
"prio": 0,
"policy": "accept",
}
},
{
"rule": {
"family": "ip",
"table": "filter",
"chain": "INPUT",
"expr": [
{"xt": {"type": "match", "name": "set"}},
{"counter": {"packets": 0, "bytes": 0}},
{"drop": None},
],
}
},
{"table": {"family": "inet", "name": "host", "handle": 9}},
{
"set": {
"family": "inet",
"table": "host",
"name": "admins",
"type": "ipv4_addr",
"flags": ["interval"],
"elem": [{"prefix": {"addr": "198.51.100.0", "len": 24}}],
}
},
{
"chain": {
"family": "inet",
"table": "host",
"name": "input",
"type": "filter",
"hook": "input",
"prio": 10,
"policy": "drop",
}
},
{"chain": {"family": "inet", "table": "host", "name": "services"}},
{
"rule": {
"family": "inet",
"table": "host",
"chain": "input",
"expr": [
{
"match": {
"op": "in",
"left": {"ct": {"key": "state"}},
"right": ["established", "related"],
}
},
{"accept": None},
],
}
},
{
"rule": {
"family": "inet",
"table": "host",
"chain": "input",
"expr": [
{"match": {"op": "==", "left": {"meta": {"key": "iif"}}, "right": "lo"}},
{"accept": None},
],
}
},
{
"rule": {
"family": "inet",
"table": "host",
"chain": "input",
"expr": [
{
"match": {
"op": "==",
"left": {"payload": {"protocol": "ip", "field": "saddr"}},
"right": "@admins",
}
},
{
"match": {
"op": "==",
"left": {"payload": {"protocol": "tcp", "field": "dport"}},
"right": {"set": [22, {"range": [8000, 8010]}]},
}
},
{"accept": None},
],
}
},
{
"rule": {
"family": "inet",
"table": "host",
"chain": "input",
"expr": [
{"match": {"op": "!=", "left": {"meta": {"key": "iifname"}}, "right": "wg*"}},
{
"match": {
"op": "==",
"left": {"payload": {"protocol": "ip6", "field": "daddr"}},
"right": {"prefix": {"addr": "2001:db8::", "len": 32}},
}
},
{"jump": {"target": "services"}},
],
}
},
{
"rule": {
"family": "inet",
"table": "host",
"chain": "input",
"expr": [
{"match": {"op": "==", "left": {"meta": {"key": "l4proto"}}, "right": "udp"}},
{"limit": {"rate": 10, "per": "second"}},
{"log": {"prefix": "udp "}},
{"reject": {"type": "icmpx", "expr": "port-unreachable"}},
],
}
},
{
"rule": {
"family": "inet",
"table": "host",
"chain": "input",
"expr": [
{
"match": {
"op": "==",
"left": {"fib": {"result": "type", "flags": ["daddr"]}},
"right": "local",
}
},
{"goto": {"target": "services"}},
],
}
},
{
"rule": {
"family": "inet",
"table": "host",
"chain": "services",
"expr": [
{
"match": {
"op": "==",
"left": {"payload": {"protocol": "udp", "field": "dport"}},
"right": 53,
}
},
{"queue": {"num": 1}},
],
}
},
)
def _report(
*,
nft: str | None = NFT,
nft_rc: int = 0,
ipt: str | None = IPTABLES_NFT,
ip6t: str | None = IP6TABLES_NFT,
) -> str:
parts = ["$ sh -c ...", "FW_BEGIN"]
if nft is not None:
parts += ["[nft]", nft, f"__FW_RC={nft_rc}"]
if ipt is not None:
parts += ["[iptables-save]", ipt, "__FW_RC=0"]
if ip6t is not None:
parts += ["[ip6tables-save]", ip6t, "__FW_RC=0"]
parts.append("FW_END")
return "\n".join(parts) + "\n"
def _chain(reading, family, table, name):
(chain,) = [
c
for c in reading["chains"]
if (c["family"], c["table"], c["name"]) == (family, table, name)
]
return chain
def _fields(rule):
return [(m["field"], m["op"], m["values"]) for m in rule["matches"]]
class TestTheSources:
def test_iptables_tables_come_from_iptables_save_the_rest_from_nft(self):
reading = parse_host_firewall(_report())
assert reading["sources"] == ["nftables", "iptables-nft"]
tables = {(c["family"], c["table"]) for c in reading["chains"]}
assert tables == {("ip", "filter"), ("ip", "nat"), ("ip6", "filter"), ("inet", "host")}
# The filter table's INPUT is iptables-save's: its set match is spelled
# out there, and nft's opaque copy is not added a second time.
assert len(_chain(reading, "ip", "filter", "INPUT")["rules"]) == 7
def test_a_legacy_iptables_is_a_ruleset_of_its_own(self):
legacy = IPTABLES_NFT.replace("(nf_tables)", "(legacy)")
reading = parse_host_firewall(_report(ipt=legacy, ip6t=None))
assert reading["sources"] == ["nftables", "iptables-legacy"]
inputs = [c for c in reading["chains"] if c["name"] == "INPUT" and c["family"] == "ip"]
assert len(inputs) == 2 # nft's own ip filter INPUT and legacy's both apply
def test_nft_alone(self):
reading = parse_host_firewall(_report(ipt=None, ip6t=None))
assert reading["sources"] == ["nftables"]
assert _chain(reading, "ip", "filter", "INPUT")["rules"][0]["matches"] == [
{"field": "unknown", "op": "==", "values": ["xt match set"]}
]
def test_iptables_alone_when_nft_fails(self):
reading = parse_host_firewall(_report(nft="Operation not permitted", nft_rc=1))
assert reading["sources"] == ["iptables-nft"]
def test_no_tool_that_could_be_read(self):
with pytest.raises(HostFirewallUnavailable):
parse_host_firewall(_report(nft=None, ipt=None, ip6t=None))
def test_a_failed_reading_is_no_reading(self):
with pytest.raises(HostFirewallUnavailable):
parse_host_firewall(_report(nft="permission denied", nft_rc=1, ipt=None, ip6t=None))
def test_a_report_cut_short_raises(self):
with pytest.raises(ValueError):
parse_host_firewall(_report().replace("FW_END", ""))
def test_an_empty_ruleset_is_a_host_without_rules(self):
reading = parse_host_firewall(_report(nft=_nft(), ipt=None, ip6t=None))
assert reading == {"sources": ["nftables"], "chains": []}
class TestIptablesSave:
def setup_method(self):
self.reading = parse_host_firewall(_report(nft=None))
def test_a_built_in_chain_has_its_hook_and_policy(self):
chain = _chain(self.reading, "ip", "filter", "INPUT")
assert (chain["type"], chain["hook"], chain["priority"], chain["policy"]) == (
"filter",
"input",
0,
"accept",
)
assert _chain(self.reading, "ip6", "filter", "INPUT")["policy"] == "drop"
nat = _chain(self.reading, "ip", "nat", "PREROUTING")
assert (nat["type"], nat["hook"], nat["priority"]) == ("nat", "prerouting", -100)
def test_a_user_chain_has_none(self):
chain = _chain(self.reading, "ip", "filter", "WGRUNNER")
assert (chain["type"], chain["hook"], chain["priority"], chain["policy"]) == (
None,
None,
None,
None,
)
def test_matches_and_verdicts(self):
rules = _chain(self.reading, "ip", "filter", "INPUT")["rules"]
assert _fields(rules[0]) == [("saddr", "==", ["10.255.255.4/32"])]
assert (rules[0]["verdict"], rules[0]["target"]) == ("jump", "WGRUNNER")
assert _fields(rules[1]) == [
("iifname", "==", ["ens6"]),
("l4proto", "==", ["udp"]),
("dport", "==", ["51820"]),
]
assert rules[1]["verdict"] == "accept"
assert _fields(rules[2]) == [
("unknown", "==", ["-m set --match-set crowdsec-blacklists src"]),
]
assert _fields(rules[3]) == [
("iifname", "==", ["ens6"]),
("ct_state", "==", ["related", "established"]),
]
assert _fields(rules[4]) == [
("iifname", "==", ["ens6"]),
("l4proto", "==", ["tcp"]),
("dport", "==", ["80", "443", "8000-8010"]),
]
assert _fields(rules[5]) == [
("iifname", "!=", ["lo"]),
("l4proto", "==", ["tcp"]),
("ct_state", "==", ["new"]),
("ct_orig_dport", "==", ["2222"]),
]
assert rules[5]["verdict"] == "continue" # LOG decides nothing
assert rules[6]["verdict"] == "drop"
def test_negation_wildcard_and_goto(self):
(rule,) = _chain(self.reading, "ip", "filter", "DOCKER-USER")["rules"]
assert _fields(rule) == [("iifname", "==", ["eth*"]), ("saddr", "!=", ["192.168.0.0/16"])]
assert (rule["verdict"], rule["target"]) == ("goto", "WGRUNNER")
def test_reject_return_and_nat_targets(self):
rules = _chain(self.reading, "ip", "filter", "WGRUNNER")["rules"]
assert [r["verdict"] for r in rules] == ["accept", "reject", "return"]
(dnat,) = _chain(self.reading, "ip", "nat", "DOCKER")["rules"]
assert (dnat["verdict"], dnat["target"]) == ("other", "DNAT")
assert _chain(self.reading, "ip", "nat", "PREROUTING")["rules"][0]["matches"] == [
{"field": "unknown", "op": "==", "values": ["-m addrtype --dst-type LOCAL"]}
]
def test_the_line_is_kept_as_the_rules_text(self):
rule = _chain(self.reading, "ip", "filter", "INPUT")["rules"][6]
assert rule["text"] == "-A INPUT -i ens6 -j DROP"
class TestNftJson:
def setup_method(self):
self.chains = {
c["name"]: c
for c in parse_host_firewall(_report(ipt=None, ip6t=None))["chains"]
if c["table"] == "host"
}
def test_a_base_chain_and_a_regular_one(self):
base, regular = self.chains["input"], self.chains["services"]
assert (base["family"], base["type"], base["hook"], base["priority"], base["policy"]) == (
"inet",
"filter",
"input",
10,
"drop",
)
assert (regular["hook"], regular["policy"]) == (None, None)
def test_matches(self):
rules = self.chains["input"]["rules"]
assert _fields(rules[0]) == [("ct_state", "==", ["established", "related"])]
assert _fields(rules[1]) == [("iifname", "==", ["lo"])]
# A named set is resolved from its elements; tcp dport implies tcp.
assert _fields(rules[2]) == [
("saddr", "==", ["198.51.100.0/24"]),
("l4proto", "==", ["tcp"]),
("dport", "==", ["22", "8000-8010"]),
]
assert _fields(rules[3]) == [("iifname", "!=", ["wg*"]), ("daddr", "==", ["2001:db8::/32"])]
assert (rules[3]["verdict"], rules[3]["target"]) == ("jump", "services")
def test_a_statement_that_takes_only_some_packets_is_unknown(self):
rule = self.chains["input"]["rules"][4]
assert _fields(rule) == [("l4proto", "==", ["udp"]), ("unknown", "==", ["limit"])]
assert rule["verdict"] == "reject"
def test_an_expression_it_does_not_model_is_unknown(self):
rule = self.chains["input"]["rules"][5]
assert rule["matches"][0]["field"] == "unknown"
assert (rule["verdict"], rule["target"]) == ("goto", "services")
def test_a_verdict_it_does_not_model_is_other(self):
(rule,) = self.chains["services"]["rules"]
assert rule["verdict"] == "other"
def test_each_rule_reads_as_a_line(self):
assert self.chains["input"]["rules"][2]["text"] == (
"ip saddr 198.51.100.0/24 meta l4proto tcp tcp dport {22, 8000-8010} accept"
)
def test_an_unresolvable_set_is_unknown(self):
nft = NFT.replace('"@admins"', '"@missing"')
rules = [
c
for c in parse_host_firewall(_report(nft=nft, ipt=None, ip6t=None))["chains"]
if c["name"] == "input"
][0]["rules"]
assert rules[2]["matches"][0] == {
"field": "unknown",
"op": "==",
"values": ["ip saddr @missing"],
}
class _Host(HostFirewallMixin):
def __init__(self, output: str | None = None, fail: Exception | None = None):
self.calls: list[tuple[str, bool]] = []
self._output, self._fail = output, fail
def _run_host_firewall_command(self, command: str, *, privileged: bool) -> str:
self.calls.append((command, privileged))
if self._fail:
raise self._fail
return self._output or ""
class TestTheMixin:
def test_one_privileged_command(self):
host = _Host(_report())
reading = host.get_host_firewall()
assert host.calls == [(f"sh -c {quote(HOST_FIREWALL_COMMAND)}", True)]
assert reading["sources"] == ["nftables", "iptables-nft"]
def test_without_root_there_is_nothing_to_read(self):
# nft and iptables-save both need CAP_NET_ADMIN; a reading without root
# would be an empty ruleset that is not the host's.
host = _Host("FW_BEGIN\n[nft]\nnetlink: Operation not permitted\n__FW_RC=1\nFW_END\n")
with pytest.raises(HostFirewallUnavailable):
host.get_host_firewall()
assert len(host.calls) == 1
def test_the_command_is_framed_and_read_only(self):
assert "nft -j list ruleset" in HOST_FIREWALL_COMMAND
assert "iptables-save" in HOST_FIREWALL_COMMAND
for word in (" -F", " -X", "flush", "delete", "-A ", "-I ", "restore"):
assert word not in HOST_FIREWALL_COMMAND
+68 -4
View File
@@ -11,6 +11,7 @@ The fixtures are the real states of six hosts on netOrk's test server.
from __future__ import annotations
import os
import re
import subprocess
@@ -35,7 +36,9 @@ def _wire(
periodic=None,
stamp=None,
timer="enabled",
unattended=True,
dnf_timers=("not-found", "not-found"),
dnf_conf=(),
dpkg=None,
journal=(),
busy=False,
@@ -47,7 +50,10 @@ def _wire(
lines += ["[needs-restarting]", str(needs_restarting)]
lines += ["[kernel]", running, "[modules]", *modules]
if periodic is not None:
lines += ["[apt-config]", *periodic, "[upgrade-stamp]", *([str(stamp)] if stamp else [])]
lines += ["[apt-config]", *periodic]
if unattended:
lines.append("[unattended-upgrade]")
lines += ["[upgrade-stamp]", *([str(stamp)] if stamp else [])]
if dpkg is not None:
if busy:
lines.append("[dpkg-busy]")
@@ -57,6 +63,8 @@ def _wire(
f"apt-daily-upgrade.timer {timer}",
f"dnf-automatic.timer {dnf_timers[0]}",
f"dnf-automatic-install.timer {dnf_timers[1]}",
"[dnf-automatic]",
*dnf_conf,
"HSTAT_END",
]
return "\n".join(lines) + "\n"
@@ -131,6 +139,13 @@ class TestKernelRebootPending:
("7.0.14-19-pve", ["7.0.14-19-pve", "7.0.2-6-pve"], None),
# Arch: the running kernel's modules were replaced by the upgrade.
("6.10.5-arch1-1", ["6.10.9-arch1-1"], "6.10.9-arch1-1"),
# RHEL/Fedora: the release changes with every update, the arch does not.
(
"5.14.0-427.13.1.el9_4.x86_64",
["5.14.0-427.13.1.el9_4.x86_64", "5.14.0-503.11.1.el9_5.x86_64"],
"5.14.0-503.11.1.el9_5.x86_64",
),
("6.9.7-200.fc40.x86_64", ["6.9.7-200.fc40.x86_64"], None),
],
)
def test_the_newer_kernel_of_the_running_flavour(self, running, installed, newer):
@@ -155,11 +170,57 @@ class TestAutoUpdates:
assert status["auto_updates"] is False
def test_dnf_automatic(self):
status = parse_host_status(_wire(dnf_timers=("enabled", "not-found")))
def test_removed_but_not_purged_does_not_patch_itself(self):
"""``apt remove unattended-upgrades`` leaves its conffile, and its "1", behind."""
status = parse_host_status(_wire(periodic=UNATTENDED, unattended=False))
assert status["auto_updates"] is False
def test_dnf_automatic_install_timer_always_installs(self):
status = parse_host_status(_wire(dnf_timers=("not-found", "enabled")))
assert status["auto_updates"] is True
def test_dnf_automatic_with_apply_updates(self):
conf = ["apply_updates = yes"]
status = parse_host_status(_wire(dnf_timers=("enabled", "not-found"), dnf_conf=conf))
assert status["auto_updates"] is True
@pytest.mark.parametrize(
"conf", [[], ["apply_updates = no"], ["apply_updates=yes", "apply_updates = False"]]
)
def test_dnf_automatic_by_default_only_downloads(self, conf):
"""dnf-automatic.timer installs only with apply_updates; the default is no.
dnf5's file comes last and wins."""
status = parse_host_status(_wire(dnf_timers=("enabled", "not-found"), dnf_conf=conf))
assert status["auto_updates"] is False
def test_the_command_reads_apply_updates_of_dnf4_then_dnf5(self):
conf = "/etc/dnf/automatic.conf /etc/dnf/dnf5-plugins/automatic.conf"
assert conf in HOST_STATUS_COMMAND
@pytest.mark.parametrize("installed", [True, False])
def test_the_command_asks_whether_unattended_upgrade_is_installed(self, tmp_path, installed):
"""Run for real, with a stand-in apt-config that still says "1"."""
if os.path.exists("/usr/bin/unattended-upgrade"):
pytest.skip("this host has unattended-upgrade installed")
apt_config = tmp_path / "apt-config"
apt_config.write_text("#!/bin/sh\necho 'APT::Periodic::Unattended-Upgrade \"1\";'\n")
stubs = [apt_config]
if installed:
stubs.append(tmp_path / "unattended-upgrade")
stubs[-1].write_text("#!/bin/sh\nexit 0\n")
for stub in stubs:
stub.chmod(0o755)
env = {**os.environ, "PATH": f"{tmp_path}:{os.environ.get('PATH', '/usr/bin:/bin')}"}
out = subprocess.run(
["sh", "-c", HOST_STATUS_COMMAND], capture_output=True, text=True, env=env
).stdout
assert ("[unattended-upgrade]" in out) is installed
def test_neither_apt_nor_dnf_is_unknown(self):
assert parse_host_status(_wire())["auto_updates"] is None
@@ -391,7 +452,10 @@ class TestBusyCheck:
UBUNTU_IDLE = [
("systemd", "/sbin/init"),
("unattended-upgr", "/usr/bin/python3 /usr/share/unattended-upgrades/unattended-upgrade-shutdown --wait-for-signal"),
(
"unattended-upgr",
"/usr/bin/python3 /usr/share/unattended-upgrades/unattended-upgrade-shutdown --wait-for-signal",
),
("sshd", "sshd: netork [priv]"),
]
+8
View File
@@ -85,6 +85,14 @@ class TestParsing:
with pytest.raises(ValueError):
parse_kernel_facts("sh: gzip: not found\n")
def test_an_empty_payload_raises(self):
"""gzip or base64 missing on the host: the frame is there, the report is not."""
with pytest.raises(ValueError):
parse_kernel_facts("KFACTS_BEGIN\nKFACTS_END\n")
empty = base64.b64encode(gzip.compress(b"")).decode()
with pytest.raises(ValueError):
parse_kernel_facts(f"KFACTS_BEGIN\n{empty}\nKFACTS_END\n")
def test_a_damaged_payload_raises(self):
with pytest.raises(ValueError):
parse_kernel_facts("KFACTS_BEGIN\nnot base64 at all!\nKFACTS_END\n")
+12 -1
View File
@@ -3,7 +3,9 @@
from napalm_device_types import add_lag_interfaces
def _port(is_up: bool = True, is_enabled: bool = True, speed: float = 1000.0, trunk_group: str = "") -> dict:
def _port(
is_up: bool = True, is_enabled: bool = True, speed: float = 1000.0, trunk_group: str = ""
) -> dict:
port = {
"is_up": is_up,
"is_enabled": is_enabled,
@@ -74,3 +76,12 @@ def test_does_not_modify_its_input():
add_lag_interfaces(ifaces)
assert list(ifaces) == ["3"]
def test_an_unknown_member_speed_does_not_make_the_lag_negative():
ifaces = {
"3": _port(speed=-1.0, trunk_group="Trk3"),
"4": _port(speed=1000.0, trunk_group="Trk3"),
}
assert add_lag_interfaces(ifaces)["Trk3"]["speed"] == 1000.0
+3 -2
View File
@@ -84,11 +84,12 @@ class TestParsing:
("0.0.0.0%ens18:68", "0.0.0.0", "ens18"),
("[::ffff:127.0.0.1]:8125", "::ffff:127.0.0.1", None),
("[fe80::1%eth0]:546", "fe80::1", "eth0"),
("[fe80::1]%eth0:546", "fe80::1", "eth0"), # what iproute2 ss prints
(":::22", "::", None), # iproute2 4.9 prints IPv6 without brackets
],
)
def test_every_address_form(self, local: str, address: str, interface):
line = f"tcp LISTEN 0 128 {local} *:* users:((\"x\",pid=1,fd=3))\n"
line = f'tcp LISTEN 0 128 {local} *:* users:(("x",pid=1,fd=3))\n'
[socket] = parse_listening_sockets(_wire(line, "1 0::/system.slice/x.service\n"))
@@ -187,7 +188,7 @@ class TestFailures:
def test_a_report_cut_short_raises(self):
with pytest.raises(ValueError):
parse_listening_sockets(_wire()[:-len("SOCK_END\n")])
parse_listening_sockets(_wire()[: -len("SOCK_END\n")])
def test_a_host_without_ss_or_netstat_is_unavailable(self):
with pytest.raises(ListeningSocketsUnavailable):
+40 -1
View File
@@ -11,10 +11,14 @@ Fixture lines are from real hosts (Ubuntu 24.04, Debian 13 / OMV, Proxmox VE 9).
from __future__ import annotations
import os
import subprocess
import pytest
from napalm_device_types.package_updates import (
APT_UPGRADABLE_COMMAND,
DNF_SECURITY_COMMAND,
nevra_name,
parse_apt_upgradable,
parse_dnf_security,
@@ -81,6 +85,16 @@ class TestAptUpgradable:
"3.0.13-0ubuntu3.5"
)
def test_a_line_wrapped_at_a_blank_keeps_the_blank(self):
wrapped = (
"openssl/noble-updates 3.0.13-0ubuntu3.6\n"
" amd64 [upgradable from: 3.0.13-0ubuntu3.5]\n__APT_RC=0\n"
)
assert _by_name(parse_apt_upgradable(wrapped))["openssl"]["current_version"] == (
"3.0.13-0ubuntu3.5"
)
def test_one_package_for_several_architectures_is_one_entry(self):
multiarch = (
"libc6/noble-updates 2.39-0ubuntu8.5 amd64 [upgradable from: 2.39-0ubuntu8.4]\n"
@@ -129,13 +143,38 @@ class TestDnfSecurity:
FEDORA-2024-1a2b3c4d5e Important/Sec. openssl-libs-1:3.1.4-2.fc40.x86_64
FEDORA-2024-1a2b3c4d5e Important/Sec. openssl-1:3.1.4-2.fc40.x86_64
RLSA-2024:1234 Moderate/Sec. kernel-core-5.14.0-427.13.1.el9_4.x86_64
__DNF_RC=0
"""
def test_the_names_of_packages_with_a_security_advisory(self):
assert parse_dnf_security(self.ADVISORIES) == {"openssl-libs", "openssl", "kernel-core"}
def test_nothing_is_an_empty_set(self):
assert parse_dnf_security("") == set()
assert parse_dnf_security("__DNF_RC=0\n") == set()
def test_a_failed_dnf_raises_instead_of_reading_as_no_advisories(self):
"""dnf that could not reach its repositories prints nothing to stdout;
every pending update would have become not security."""
with pytest.raises(ValueError, match="1"):
parse_dnf_security("__DNF_RC=1\n")
def test_output_cut_short_raises(self):
with pytest.raises(ValueError, match="no exit status"):
parse_dnf_security(self.ADVISORIES.replace("__DNF_RC=0\n", ""))
def test_the_command_reports_dnfs_exit_status_not_cats(self, tmp_path):
"""A stand-in dnf that fails quietly, as one without its repositories does."""
dnf = tmp_path / "dnf"
dnf.write_text("#!/bin/sh\nexit 1\n")
dnf.chmod(0o755)
env = {"PATH": f"{tmp_path}:{os.environ.get('PATH', '/usr/bin:/bin')}"}
out = subprocess.run(
["sh", "-c", DNF_SECURITY_COMMAND], capture_output=True, text=True, env=env
).stdout
assert out == "__DNF_RC=1\n"
with pytest.raises(ValueError, match="exit status 1"):
parse_dnf_security(out)
@pytest.mark.parametrize(
("nevra", "name"),
+9
View File
@@ -0,0 +1,9 @@
"""The package says it is typed (PEP 561), so it must ship the marker."""
from pathlib import Path
import napalm_device_types
def test_the_package_ships_py_typed():
assert (Path(napalm_device_types.__file__).parent / "py.typed").is_file()
+12 -1
View File
@@ -5,7 +5,6 @@ from napalm.base import NetworkDriver
from napalm_device_types import DeviceTypeDriver, PingSweepMixin, driver_supports_ping
# ── Fakes ─────────────────────────────────────────────────────────────────────
@@ -219,3 +218,15 @@ def test_ping_sweep_stops_when_stop_requested():
assert result["scanned"] == 1
assert len(driver.calls) == 1
@pytest.mark.parametrize("success", [True, "yes", ["x"]])
def test_a_malformed_success_is_no_answer_not_an_abort(success):
entry = PingSweepMixin._parse_ping_reply("192.0.2.1", {"success": success})
assert entry == {
"ip": "192.0.2.1",
"alive": False,
"rtt_ms": None,
"error": "malformed ping reply",
}
+1 -1
View File
@@ -19,7 +19,7 @@ from napalm_device_types.nat_vpn import NatVpnMixin
def test_a_firewall_and_a_gateway_share_the_declaration():
assert issubclass(FirewallDriver, NatVpnMixin)
assert issubclass(ResidentialGatewayDriver, NatVpnMixin)
assert "def get_port_forwards(self) -> List[PortForwardDict]" in inspect.getsource(NatVpnMixin)
assert "def get_port_forwards(self) -> list[PortForwardDict]" in inspect.getsource(NatVpnMixin)
def test_it_is_declared_once():
+1
View File
@@ -4,6 +4,7 @@ the same for every hypervisor (NetOrk/netork#794)."""
from __future__ import annotations
import pytest
from napalm_device_types import HypervisorDriver
from napalm_device_types.provisioning import (
QEMU_GUEST_AGENTS,
+47
View File
@@ -0,0 +1,47 @@
"""Public contracts for what an application reached through driver internals.
netOrk followed a firewall's upgrade, edited its resolver's forwards and read
its OSPF state through the driver's private REST helpers (NetOrk/netork#1113).
Each becomes a declared method with a neutral shape; the vendor's own format
is translated by the driver.
"""
from __future__ import annotations
from typing import get_type_hints
from napalm_device_types import (
DnsForwardDict,
DnsForwardMixin,
OspfDict,
OspfInterfaceDict,
OspfMixin,
UpdateMixin,
UpdateProgressDict,
)
def test_update_progress_is_a_state_and_its_log():
assert set(get_type_hints(UpdateProgressDict)) == {"state", "log"}
assert not hasattr(UpdateMixin, "get_update_progress")
def test_a_dns_forward_names_the_domain_and_where_it_goes():
assert set(get_type_hints(DnsForwardDict)) == {"domain", "server", "port", "enabled"}
for name in ("get_dns_forwards", "set_dns_forward", "remove_dns_forward"):
assert not hasattr(DnsForwardMixin, name), name
def test_ospf_in_neutral_terms():
assert set(get_type_hints(OspfDict)) == {"router_id", "areas", "interfaces"}
assert set(get_type_hints(OspfInterfaceDict)) == {
"address",
"prefix_length",
"area",
"network_type",
"authentication",
"neighbors",
"neighbors_adjacent",
"cost",
}
assert not hasattr(OspfMixin, "get_ospf")
+75 -1
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""The contract that makes multi-role drivers safe.
A role base declares what a device of that kind can be asked for. It must not
@@ -22,6 +21,7 @@ from napalm_device_types import (
DeviceTypeDriver,
FirewallDriver,
HypervisorDriver,
ManagementControllerDriver,
MediaDriver,
OSDriver,
PhoneDriver,
@@ -35,6 +35,7 @@ ROLE_BASES = [
AccessPointDriver,
FirewallDriver,
HypervisorDriver,
ManagementControllerDriver,
MediaDriver,
OSDriver,
PhoneDriver,
@@ -84,6 +85,12 @@ class TestDeclaredMethodsDoNotExistAtRuntime:
(ResidentialGatewayDriver, "get_wan_status"),
(PhoneDriver, "get_sip_accounts"),
(MediaDriver, "get_playback_state"),
(ManagementControllerDriver, "get_power_state"),
(ManagementControllerDriver, "set_power_state"),
(ManagementControllerDriver, "get_hardware_inventory"),
(ManagementControllerDriver, "get_event_log"),
(ManagementControllerDriver, "get_firmware_inventory"),
(ManagementControllerDriver, "get_management_protocols"),
],
)
def test_absent_until_a_driver_implements_it(self, base, method):
@@ -133,9 +140,76 @@ class TestRoleIntrospection:
assert primary_role_of(Nas) == "storage"
assert primary_role_of(Host) == "linux"
def test_a_driver_that_sets_its_own_role_replaces_the_inherited_one(self):
"""A Windows host is an OS driver too, but not a "linux" one."""
class Windows(OSDriver):
ROLE = "windows"
class Nas(StorageDriver, Windows):
pass
assert primary_role_of(Windows) == "windows"
assert [r.__name__ for r in roles_of(Windows)] == ["Windows"]
assert [r.__name__ for r in roles_of(Nas)] == ["StorageDriver", "Windows"]
def test_driver_without_a_role_has_none(self):
class Bare(DeviceTypeDriver):
pass
assert roles_of(Bare) == []
assert primary_role_of(Bare) is None
class TestManagementController:
"""A BMC is reached over Redfish or IPMI, and it is not the server it manages."""
def test_role_key_and_transport(self):
assert ManagementControllerDriver.ROLE == "management_controller"
assert ManagementControllerDriver.USES_SSH is False
def test_power_actions_are_redfish_reset_types(self):
from napalm_device_types import POWER_ACTIONS
assert POWER_ACTIONS == (
"On",
"GracefulShutdown",
"ForceOff",
"GracefulRestart",
"ForceRestart",
"PowerCycle",
)
def test_carries_neither_snmp_health_metrics_nor_a_command_channel(self):
"""Health metrics are SNMP; the BMC's sensors come through get_environment."""
from napalm_device_types import HealthMetricsMixin
assert not issubclass(ManagementControllerDriver, HealthMetricsMixin)
assert not hasattr(ManagementControllerDriver, "get_health_metrics")
assert not hasattr(ManagementControllerDriver, "run_command")
assert not hasattr(ManagementControllerDriver, "open_stream")
def test_docstring_says_reboot_host_restarts_the_bmc_not_the_server(self):
doc = ManagementControllerDriver.__doc__ or ""
assert "reboot_host" in doc and "Manager.Reset" in doc
assert "set_power_state" in doc
assert "get_environment" in doc
def test_its_return_types_are_exported(self):
import napalm_device_types as package
for name in (
"PowerAction",
"PowerStateDict",
"HardwareInventoryDict",
"EventLogEntryDict",
"FirmwareDict",
"ManagementProtocolsDict",
):
assert name in package.__all__ and hasattr(package, name), name
def test_a_server_driver_can_rank_it_first(self):
class Idrac(ManagementControllerDriver):
pass
assert primary_role_of(Idrac) == "management_controller"
+1
View File
@@ -9,6 +9,7 @@ sibling base.
"""
import pytest
from napalm_device_types import FirewallDriver
+596
View File
@@ -0,0 +1,596 @@
"""get_service_clusters: the configuration that says which services run together.
Two hosts that answer at one virtual IP may run the same service by accident.
What proves a cluster is the configuration the members share: Patroni's scope
and the members its DCS lists, adguardhome-sync's origin and replicas, the
address a BunkerWeb worker takes its configuration from. 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 (netOrk #1125).
The same configuration holds secrets -- Patroni's superuser password, the sync's
AdGuard logins, a database URI -- so the command filters on the host: only
allowlisted fields ever leave it. The tests below run the command for real to
hold it to that.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
from pathlib import Path
import pytest
from napalm_device_types.service_clusters import (
SERVICE_CLUSTERS_COMMAND,
ServiceClustersMixin,
parse_service_clusters,
service_clusters_command,
)
SECRET = "s3cr3t-do-not-leak"
def _wire(*sections: str, systemd: bool = True, docker: str = "[docker]", noise: str = "") -> str:
head = "[systemd]" if systemd else "[no-systemd]"
body = (
"\n".join([head, *sections[:1], docker, *sections[1:]]) if sections else f"{head}\n{docker}"
)
return f"{noise}SVCC_BEGIN\n{body}\nSVCC_END\n"
DB01_CLUSTER = {
"members": [
{
"name": "db-01",
"role": "leader",
"state": "running",
"host": "10.7.234.12",
"port": 5432,
},
{
"name": "db-02",
"role": "sync_standby",
"state": "streaming",
"host": "10.7.234.11",
"port": 5432,
"lag": 0,
},
],
"scope": "netork-db",
}
def _patroni(*records: str, unit: str = "patroni.service") -> str:
return "\n".join([f"[patroni {unit}]", *records])
def _container(name: str, image: str, *env: str) -> str:
return "\n".join([f"[container {name}]", f"image {image}", *(f"env {e}" for e in env)])
class TestPatroni:
def test_the_members_its_dcs_lists_with_their_roles(self):
reading = parse_service_clusters(
_wire(
_patroni(
"active active",
"config /etc/patroni/config.yml",
"scope netork-db",
"name db-01",
"restapi 10.7.234.12:8008",
"rest " + json.dumps(DB01_CLUSTER),
)
)
)
assert reading["patroni"] == [
{
"mechanism": "patroni",
"service": "patroni",
"source": "patroni",
"cluster": "netork-db",
"running": True,
"members": [
{
"name": "db-01",
"address": "10.7.234.12",
"port": 5432,
"role": "leader",
"state": "running",
"this_host": True,
"config_from": None,
},
{
"name": "db-02",
"address": "10.7.234.11",
"port": 5432,
"role": "sync_standby",
"state": "streaming",
"this_host": False,
"config_from": None,
},
],
}
]
def test_without_its_rest_api_it_is_itself_in_its_scope(self):
reading = parse_service_clusters(
_wire(
_patroni(
"active active",
"scope 'netork-db' # the cluster",
"name db-01",
"restapi 10.7.234.12:8008",
"rest-unreachable",
)
)
)
(cluster,) = reading["patroni"]
assert cluster["cluster"] == "netork-db"
assert cluster["members"] == [
{
"name": "db-01",
"address": "10.7.234.12",
"port": None,
"role": None,
"state": None,
"this_host": True,
"config_from": None,
}
]
def test_an_unreadable_config_is_not_looked_at(self):
reading = parse_service_clusters(
_wire(_patroni("active active", "unreadable /etc/patroni/config.yml"))
)
assert reading["patroni"] is None
def test_a_config_without_a_scope_declares_no_cluster(self):
assert (
parse_service_clusters(_wire(_patroni("active active", "name db-01")))["patroni"] == []
)
def test_a_host_without_systemd_is_not_looked_at(self):
assert parse_service_clusters(_wire(systemd=False))["patroni"] is None
def test_no_patroni_is_looked_and_found_nothing(self):
assert parse_service_clusters(_wire())["patroni"] == []
class TestAdguardhomeSync:
def test_the_origin_pushes_to_each_replica(self):
reading = parse_service_clusters(
_wire(
"",
_container(
"adguardhome-sync",
"ghcr.io/bakito/adguardhome-sync:v0.9.3",
"ORIGIN_URL http://10.9.100.5:3000",
"REPLICA1_URL http://10.9.100.6:3000",
"REPLICA2_URL https://dns-03.example.org",
),
)
)
(cluster,) = reading["adguardhome_sync"]
assert (cluster["mechanism"], cluster["service"], cluster["cluster"]) == (
"adguardhome-sync",
"adguardhome",
"10.9.100.5:3000",
)
assert [
(m["address"], m["port"], m["role"], m["config_from"]) for m in cluster["members"]
] == [
("10.9.100.5", 3000, "origin", None),
("10.9.100.6", 3000, "replica", 0),
("dns-03.example.org", 443, "replica", 0),
]
assert not any(m["this_host"] for m in cluster["members"])
def test_a_single_replica_url_counts(self):
reading = parse_service_clusters(
_wire(
"",
_container(
"sync",
"bakito/adguardhome-sync",
"ORIGIN_URL http://a:3000",
"REPLICA_URL http://b:3000",
),
)
)
assert [m["address"] for m in reading["adguardhome_sync"][0]["members"]] == ["a", "b"]
def test_a_sync_configured_by_file_is_not_looked_at(self):
"""Its YAML is not read: the cluster is there, unseen."""
reading = parse_service_clusters(_wire("", _container("sync", "bakito/adguardhome-sync")))
assert reading["adguardhome_sync"] is None
class TestBunkerweb:
def test_a_worker_takes_its_configuration_from_the_manager_it_admits(self):
reading = parse_service_clusters(
_wire(
"",
_container(
"bunkerweb", "bunkerity/bunkerweb@sha256:2026", "API_WHITELIST_IP 10.50.100.5"
),
)
)
(cluster,) = reading["bunkerweb"]
assert (cluster["service"], cluster["cluster"]) == ("bunkerweb", "10.50.100.5")
assert cluster["members"] == [
{
"name": None,
"address": None,
"port": 5000,
"role": "worker",
"state": None,
"this_host": True,
"config_from": 1,
},
{
"name": None,
"address": "10.50.100.5",
"port": None,
"role": "manager",
"state": None,
"this_host": False,
"config_from": None,
},
]
def test_the_default_whitelist_names_no_manager(self):
"""127.0.0.0/8 and a docker network are BunkerWeb's default, not a peer."""
reading = parse_service_clusters(
_wire(
"",
_container(
"bw", "bunkerity/bunkerweb:1.6", "API_WHITELIST_IP 127.0.0.0/8 10.20.30.0/24"
),
)
)
assert reading["bunkerweb"] == []
def test_a_manager_names_its_instances(self):
reading = parse_service_clusters(
_wire(
"",
_container(
"bw",
"bunkerity/bunkerweb-all-in-one:1.6",
"BUNKERWEB_INSTANCES 10.50.100.6:5000 bw-03",
),
),
)
(cluster,) = reading["bunkerweb"]
assert cluster["cluster"] is None # the manager cannot name its own address
assert [
(m["address"], m["port"], m["role"], m["this_host"], m["config_from"])
for m in cluster["members"]
] == [
(None, None, "manager", True, None),
("10.50.100.6", 5000, "worker", False, 0),
("bw-03", 5000, "worker", False, 0),
]
def test_a_manager_without_instances_is_no_cluster(self):
"""proxy-01's all-in-one keeps its instances in its database."""
reading = parse_service_clusters(
_wire("", _container("bw", "bunkerity/bunkerweb-all-in-one@sha256:2b34"))
)
assert reading["bunkerweb"] == []
def test_its_ui_and_scheduler_side_containers_are_not_instances(self):
reading = parse_service_clusters(
_wire("", _container("ui", "bunkerity/bunkerweb-ui:1.6", "API_WHITELIST_IP 10.0.0.9"))
)
assert reading["bunkerweb"] == []
class TestTheReport:
def test_a_host_without_docker_runs_no_containers(self):
reading = parse_service_clusters(_wire(docker="[no-docker]"))
assert (reading["adguardhome_sync"], reading["bunkerweb"]) == ([], [])
def test_a_docker_it_may_not_ask_is_not_looked_at(self):
reading = parse_service_clusters(_wire(docker="[docker-unreadable]"))
assert (reading["adguardhome_sync"], reading["bunkerweb"]) == (None, None)
def test_noise_before_the_report_is_ignored(self):
assert parse_service_clusters(_wire(noise="Last login: today\n"))["patroni"] == []
def test_a_report_cut_short_raises_without_quoting_it(self):
with pytest.raises(ValueError) as caught:
parse_service_clusters("SVCC_BEGIN\n[systemd]\nscope netork-db\n")
assert "netork-db" not in str(caught.value)
# ---------------------------------------------------------------------------
# The command itself, run against stubs and real files
# ---------------------------------------------------------------------------
PATRONI_YML = f"""scope: netork-db
namespace: /service/
name: db-01
restapi:
listen: 10.7.234.12:8008
connect_address: 10.7.234.12:8008
authentication:
username: patroni
password: {SECRET}
etcd3:
hosts:
- 10.7.234.12:2379
- 10.7.234.13:2379
password: {SECRET}
postgresql:
connect_address: 10.7.234.12:5432
authentication:
superuser:
username: postgres
password: {SECRET}
tags:
name: not-the-member-name
"""
def _awks() -> list:
found = []
for name, argv in (("mawk", ["mawk"]), ("busybox", ["busybox", "awk"]), ("gawk", ["gawk"])):
if shutil.which(argv[0]):
found.append(pytest.param(argv, id=name))
return found or [pytest.param(None, marks=pytest.mark.skip(reason="no awk to run"))]
class _Host:
"""A host as far as the command can tell: units, containers, a REST API, files."""
def __init__(self, root: Path, awk: list) -> None:
self.root = root
self.bin = root / "bin"
self.bin.mkdir()
self.state = root / "state"
self.state.mkdir()
self.booted = root / "run-systemd-system"
self.booted.mkdir()
(self.state / "units").write_text("")
(self.state / "containers").write_text("")
cat = shutil.which("cat")
self._stub(
"systemctl",
'case "$1" in\n'
f' list-units) {cat} "$d/units" ;;\n'
f' show) for last; do :; done; {cat} "$d/show-$last" ;;\n'
"esac\n",
)
self._stub(
"docker",
'case "$1" in\n'
f' ps) {cat} "$d/containers" ;;\n'
" inspect) for last; do :; done;\n"
' case "$3" in *Config.Image*) '
f'{cat} "$d/image-$last" ;; *) {cat} "$d/env-$last" ;; esac ;;\n'
"esac\n",
)
self._stub("curl", f'for last; do :; done; {cat} "$d/rest" 2>/dev/null || exit 7\n')
for tool in ("sed", "head", "tr"):
if shutil.which(tool):
(self.bin / tool).symlink_to(shutil.which(tool))
wrapper = self.bin / "awk"
program = " ".join([shutil.which(awk[0]) or awk[0], *awk[1:]])
wrapper.write_text(f'#!/bin/sh\nexec {program} "$@"\n')
wrapper.chmod(0o755)
def _stub(self, name: str, body: str) -> None:
stub = self.bin / name
stub.write_text(f"#!/bin/sh\nd={self.state}\n{body}")
stub.chmod(0o755)
def unit(self, name: str, *properties: str) -> None:
with (self.state / "units").open("a") as units:
units.write(f"{name} loaded active running {name}\n")
(self.state / f"show-{name}").write_text("".join(f"{p}\n" for p in properties))
def container(self, name: str, image: str, *env: str) -> None:
with (self.state / "containers").open("a") as containers:
containers.write(f"{name}\n")
(self.state / f"image-{name}").write_text(f"{image}\n")
(self.state / f"env-{name}").write_text("".join(f"{e}\n" for e in env))
def rest(self, body: dict) -> None:
(self.state / "rest").write_text(json.dumps(body))
def file(self, relative: str, content: str) -> Path:
path = self.root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
return path
def run(self, *, docker: bool = True) -> str:
command = service_clusters_command("docker" if docker else None)
assert command.count("/run/systemd/system") == 1
command = command.replace("/run/systemd/system", str(self.booted))
return subprocess.run(
["/bin/sh", "-c", command],
capture_output=True,
text=True,
env={"PATH": str(self.bin)},
timeout=30,
).stdout
def _exec_start(*argv: str) -> str:
return f"ExecStart={{ path={argv[0]} ; argv[]={' '.join(argv)} ; ignore_errors=no ; pid=7 ; status=0/0 }}"
@pytest.fixture(params=_awks())
def host(request, tmp_path) -> _Host:
return _Host(tmp_path, request.param)
class TestTheCommandOnAHost:
def test_patroni_as_on_db_01(self, host):
config = host.file("etc/patroni/config.yml", PATRONI_YML)
host.unit(
"patroni.service", "ActiveState=active", _exec_start("/usr/bin/patroni", str(config))
)
host.rest(DB01_CLUSTER)
out = host.run()
assert SECRET not in out
assert "10.7.234.13" not in out # the DCS endpoints stay on the host
assert "not-the-member-name" not in out
(cluster,) = parse_service_clusters(out)["patroni"]
assert cluster["cluster"] == "netork-db"
assert [(m["name"], m["role"], m["this_host"]) for m in cluster["members"]] == [
("db-01", "leader", True),
("db-02", "sync_standby", False),
]
def test_patroni_whose_rest_api_does_not_answer(self, host):
config = host.file("etc/patroni/config.yml", PATRONI_YML)
host.unit(
"patroni.service", "ActiveState=active", _exec_start("/usr/bin/patroni", str(config))
)
(cluster,) = parse_service_clusters(host.run())["patroni"]
assert [(m["name"], m["address"], m["role"]) for m in cluster["members"]] == [
("db-01", "10.7.234.12", None)
]
@pytest.mark.skipif(os.geteuid() == 0, reason="root reads a file of mode 000")
def test_a_patroni_config_it_cannot_read_is_not_looked_at(self, host):
config = host.file("etc/patroni/config.yml", PATRONI_YML)
config.chmod(0)
host.unit(
"patroni.service", "ActiveState=active", _exec_start("/usr/bin/patroni", str(config))
)
out = host.run()
assert SECRET not in out
assert parse_service_clusters(out)["patroni"] is None
def test_the_sync_and_a_worker_without_their_secrets(self, host):
host.container(
"adguardhome-sync",
"ghcr.io/bakito/adguardhome-sync:v0.9.3",
"ORIGIN_URL=http://10.9.100.5:3000",
f"ORIGIN_PASSWORD={SECRET}",
f"REPLICA1_URL=http://admin:{SECRET}@10.9.100.6:3000",
f"REPLICA1_PASSWORD={SECRET}",
"CRON=*/5 * * * *",
)
host.container(
"bunkerweb",
"bunkerity/bunkerweb@sha256:2026",
"API_WHITELIST_IP=10.50.100.5",
f"DATABASE_URI=mariadb+pymysql://bw:{SECRET}@db/bw",
)
host.container("zabbix", "zabbix/zabbix-agent2:alpine-latest", f"ZBX_PSK={SECRET}")
out = host.run()
assert SECRET not in out
assert "admin" not in out
reading = parse_service_clusters(out)
assert [m["address"] for m in reading["adguardhome_sync"][0]["members"]] == [
"10.9.100.5",
"10.9.100.6",
]
assert reading["bunkerweb"][0]["cluster"] == "10.50.100.5"
assert reading["patroni"] == []
def test_a_host_without_docker(self, host):
reading = parse_service_clusters(host.run(docker=False))
assert (reading["adguardhome_sync"], reading["bunkerweb"]) == ([], [])
class TestTheMixin:
class _Driver(ServiceClustersMixin):
def __init__(self, answers: dict) -> None:
self.answers = answers
self.calls: list = []
def _run_service_clusters_command(self, command: str, *, privileged: bool) -> str:
self.calls.append(privileged)
return self.answers[privileged]
def test_root_first(self):
driver = self._Driver({True: _wire(), False: "unused"})
assert driver.get_service_clusters()["patroni"] == []
assert driver.calls == [True]
def test_without_root_when_root_brought_no_report(self):
driver = self._Driver({True: "sudo: a password is required", False: _wire()})
assert driver.get_service_clusters()["bunkerweb"] == []
assert driver.calls == [True, False]
def test_the_container_engine_binary_is_the_drivers(self):
class _Qnap(self._Driver):
def _container_engine_binary(self, engine: str) -> str:
return "/share/CACHEDEV1_DATA/.qpkg/container-station/bin/docker"
driver = _Qnap({True: _wire(), False: _wire()})
seen: list = []
def run(command: str, *, privileged: bool) -> str:
seen.append(command)
return _wire()
driver._run_service_clusters_command = run # type: ignore[method-assign]
driver.get_service_clusters()
assert "container-station/bin/docker" in seen[0]
def test_the_default_command_asks_docker(self):
assert "docker" in SERVICE_CLUSTERS_COMMAND
class TestTheSource:
"""Where the evidence was read: the unit or container that carries it."""
def test_a_template_unit_is_named_after_its_instance(self):
reading = parse_service_clusters(
_wire(_patroni("active active", "scope pg", "name a", unit="patroni@16-main.service"))
)
assert reading["patroni"][0]["source"] == "patroni@16-main"
def test_a_container_by_its_name(self):
reading = parse_service_clusters(
_wire(
"",
_container(
"adguardhome-sync", "bakito/adguardhome-sync", "ORIGIN_URL http://a:3000"
),
_container("bw", "bunkerity/bunkerweb", "API_WHITELIST_IP 10.0.0.5"),
)
)
assert reading["adguardhome_sync"][0]["source"] == "adguardhome-sync"
assert reading["bunkerweb"][0]["source"] == "bw"
+150
View File
@@ -0,0 +1,150 @@
"""get_service_packages: which installed packages a systemd service is (NetOrk/netork#1236).
A vulnerability is found on a *package*; a decision about a *service* -- the
postgresql unit, nginx -- means something only once it is known which packages
that service is. The package that ships its unit file and the one that ships the
binary it starts (``nginx-common`` and ``nginx-core``, ``postgresql-common`` and
``postgresql-16``) are what the unit is made of. Reading that is the same on
every systemd host -- ``systemctl show`` and ``dpkg -S`` or ``rpm -qf`` -- so the
command and its parse live here once, and a driver only carries the command.
Only the unit's own packages: the libraries its binary links against are every
service's at once, and a decision about nginx is not one about libc.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import pytest
from napalm_device_types import ServicePackagesMixin
from napalm_device_types.service_packages import (
SERVICE_PACKAGES_COMMAND,
parse_service_packages,
)
#: ``systemctl show`` prints the properties in its own order: the name may follow its paths.
NGINX = """FragmentPath=/lib/systemd/system/nginx.service
ExecStart={ path=/usr/sbin/nginx ; argv[]=/usr/sbin/nginx -g daemon on; master_process on; ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }
Id=nginx.service
"""
CRON = """Id=cron.service
FragmentPath=/usr/lib/systemd/system/cron.service
ExecStart={ path=/usr/sbin/cron ; argv[]=/usr/sbin/cron -f $EXTRA_OPTS ; ignore_errors=no }
"""
def _wire(units: str, owners: str = "", *, tool: str = "dpkg", noise: str = "") -> str:
return f"{noise}SVCPKG_BEGIN\n[units]\n{units}\n[{tool}]\n{owners}SVCPKG_END\n"
DPKG = """nginx-common: /lib/systemd/system/nginx.service
nginx-core: /usr/sbin/nginx
cron: /lib/systemd/system/cron.service
cron: /usr/sbin/cron
"""
class TestParsing:
def test_the_unit_file_and_the_binary_name_its_packages(self):
assert parse_service_packages(_wire(NGINX, DPKG)) == {
"nginx": ["nginx-common", "nginx-core"]
}
def test_dpkg_is_asked_across_the_usr_merge(self):
"""dpkg knows ``/lib/systemd/system/cron.service``; the unit says ``/usr/lib/...``."""
assert parse_service_packages(_wire(CRON, DPKG)) == {"cron": ["cron"]}
def test_several_units_are_kept_apart(self):
found = parse_service_packages(_wire(NGINX + "\n" + CRON, DPKG))
assert found == {"nginx": ["nginx-common", "nginx-core"], "cron": ["cron"]}
def test_rpm_names_its_packages_too(self):
units = """Id=postgresql-16.service
FragmentPath=/usr/lib/systemd/system/postgresql-16.service
ExecStart={ path=/usr/pgsql-16/bin/postmaster ; argv[]=/usr/pgsql-16/bin/postmaster -D ${PGDATA} }
"""
owners = (
"/usr/lib/systemd/system/postgresql-16.service\tpostgresql16-server \n"
"/lib/systemd/system/postgresql-16.service\tfile /lib/systemd/system/postgresql-16.service is not owned by any package\n"
"/usr/pgsql-16/bin/postmaster\tpostgresql16-server \n"
)
found = parse_service_packages(_wire(units, owners, tool="rpm"))
assert found == {"postgresql-16": ["postgresql16-server"]}
def test_an_architecture_and_several_owners_are_read(self):
units = "Id=foo.service\nExecStart={ path=/usr/bin/foo ; argv[]=/usr/bin/foo }\n"
owners = "libfoo1:amd64, foo-bin: /usr/bin/foo\n"
assert parse_service_packages(_wire(units, owners)) == {"foo": ["foo-bin", "libfoo1"]}
def test_a_unit_no_package_owns_is_read_as_owning_none(self):
"""A unit file an admin wrote: read, and nothing owns it -- not unknown."""
units = "Id=local-backup.service\nFragmentPath=/etc/systemd/system/local-backup.service\n"
assert parse_service_packages(_wire(units)) == {"local-backup": []}
def test_a_diversion_is_no_owner(self):
units = "Id=x.service\nExecStart={ path=/bin/sh ; argv[]=/bin/sh -c x }\n"
owners = "diversion by dash from: /bin/sh\ndash: /bin/sh\n"
assert parse_service_packages(_wire(units, owners)) == {"x": ["dash"]}
def test_whatever_surrounds_the_frame_is_ignored(self):
noise = "Last login: today\nprintf '%s%s\\n' SVCPKG_ BEGIN; ...\n"
found = parse_service_packages(_wire(NGINX, DPKG, noise=noise))
assert found == {"nginx": ["nginx-common", "nginx-core"]}
def test_a_report_cut_short_raises(self):
"""A list cut short must never read as services that own nothing."""
with pytest.raises(ValueError):
parse_service_packages("SVCPKG_BEGIN\n[units]\n" + NGINX)
class TestTheCommand:
def test_the_frame_is_not_in_the_command_itself(self):
assert "SVCPKG_BEGIN" not in SERVICE_PACKAGES_COMMAND
assert "SVCPKG_END" not in SERVICE_PACKAGES_COMMAND
def test_it_writes_nothing(self):
for verb in ("sudo", " > ", ">>", " rm ", "install", "restart"):
assert verb not in SERVICE_PACKAGES_COMMAND
@pytest.mark.skipif(
not os.path.isdir("/run/systemd/system") or shutil.which("dpkg") is None,
reason="reads a systemd host with dpkg",
)
def test_it_runs_and_parses_on_this_host(self):
out = subprocess.run(
["sh", "-c", SERVICE_PACKAGES_COMMAND], capture_output=True, text=True, timeout=60
).stdout
found = parse_service_packages(out)
assert found, "a systemd host has services"
assert any(found.values()), "and a package owns at least one of them"
assert all(isinstance(p, str) and p for pkgs in found.values() for p in pkgs)
class TestTheMixin:
def test_it_hands_its_command_to_the_driver_and_parses_the_answer(self):
class Driver(ServicePackagesMixin):
def __init__(self):
self.sent = []
def _run_service_packages_command(self, command: str) -> str:
self.sent.append(command)
return _wire(NGINX, DPKG)
driver = Driver()
assert driver.get_service_packages() == {"nginx": ["nginx-common", "nginx-core"]}
assert driver.sent == [SERVICE_PACKAGES_COMMAND]
+53
View File
@@ -0,0 +1,53 @@
"""SNMP health: drivers declare data, the application polls (docs/STRUCTURE.md).
``SnmpHealthProfile`` is the shape a driver states its device's OIDs in.
``HealthMetricsMixin``, which collected in the base package, is deprecated.
"""
from __future__ import annotations
import asyncio
from typing import ClassVar, get_type_hints
import pytest
from napalm_device_types import (
HealthMetricsMixin,
SnmpHealthProfile,
SnmpInterfacesSpec,
SnmpMemorySpec,
SnmpValueSpec,
)
def test_a_profile_says_where_each_value_lives():
assert set(get_type_hints(SnmpHealthProfile)) == {"uptime", "cpu", "memory", "interfaces"}
assert set(get_type_hints(SnmpValueSpec)) == {"oids", "table", "fold", "pattern"}
assert set(get_type_hints(SnmpMemorySpec)) == {"total", "used", "free", "percent", "unit"}
assert set(get_type_hints(SnmpInterfacesSpec)) == {"skip", "tx_errors_are_drops"}
def test_every_key_is_optional():
for spec in (SnmpHealthProfile, SnmpValueSpec, SnmpMemorySpec, SnmpInterfacesSpec):
assert spec.__required_keys__ == frozenset(), spec.__name__
def test_a_driver_declares_its_profile_as_a_class_attribute():
class Driver:
SNMP_HEALTH_PROFILE: ClassVar[SnmpHealthProfile] = {
"cpu": {"oids": ["1.3.6.1.4.1.9.1", "1.3.6.1.4.1.9.2"]},
"memory": {"percent": {"table": "1.3.6.1.4.1.9.3", "fold": "first"}},
}
assert Driver.SNMP_HEALTH_PROFILE["cpu"]["oids"][0] == "1.3.6.1.4.1.9.1"
def test_the_collector_in_the_base_package_warns_that_it_is_deprecated():
async def get(oid):
return None
async def walk(oid):
return {}
with pytest.warns(DeprecationWarning, match="SNMP_HEALTH_PROFILE"):
asyncio.run(HealthMetricsMixin.get_health_metrics(get, walk))
+277
View File
@@ -0,0 +1,277 @@
"""SSH host keys: trust on first use, and a changed key refused before any login.
The layer above keeps the store (NetOrk/netork#1167). A driver gets the key it
should expect through ``optional_args["ssh_host_keys"]`` (by port) and reports every key it
sees through ``optional_args["ssh_host_key_observer"]``. These tests run real
paramiko against an in-process SSH server, because the property that matters —
nothing is sent to a host with the wrong key — is paramiko's behaviour once the
key is loaded, not something a fake could show.
"""
from __future__ import annotations
import os
import socket
import threading
import paramiko
import pytest
from napalm.base.exceptions import ConnectionException
from napalm_device_types.ssh_host_keys import (
KNOWN_KEYS_ARG,
OBSERVER_ARG,
HostKeyCheck,
SshHostKey,
SshHostKeyMismatch,
)
#: An ed25519 public key and the fingerprint ``ssh-keygen -lf`` prints for it.
ED25519 = SshHostKey(
"ssh-ed25519", "AAAAC3NzaC1lZDI1NTE5AAAAIJH2oO06vroJT2ooSB3/2tu8HufXQng4xQlxAk/4raee"
)
ED25519_FP = "SHA256:26dxfTuve20LGGTpJ1T0ns11/ID3XMhbOahQq3k75TU"
class _Server(paramiko.ServerInterface):
"""Accepts any password and remembers each one it was offered."""
def __init__(self) -> None:
self.passwords: list[str] = []
def get_allowed_auths(self, username):
return "password"
def check_auth_password(self, username, password):
self.passwords.append(password)
return paramiko.AUTH_SUCCESSFUL
class _Host:
"""An SSH server on one end of a socket pair, with *host_key*."""
def __init__(self, host_key: paramiko.PKey) -> None:
self.client_sock, server_sock = socket.socketpair()
self.transport = paramiko.Transport(server_sock)
self.transport.add_server_key(host_key)
self.server = _Server()
self._thread = threading.Thread(target=self._serve, daemon=True)
self._thread.start()
def _serve(self) -> None:
try:
self.transport.start_server(server=self.server)
except Exception: # the client hung up on a refused key
pass
def close(self) -> None:
self.transport.close()
self.client_sock.close()
self._thread.join(timeout=5)
@pytest.fixture
def host_key() -> paramiko.PKey:
return paramiko.ECDSAKey.generate()
@pytest.fixture
def host(host_key):
h = _Host(host_key)
yield h
h.close()
def _connect(check: HostKeyCheck, host: _Host) -> paramiko.SSHClient:
client = paramiko.SSHClient()
check.connect(
client,
username="admin",
password="s3cret",
sock=host.client_sock,
look_for_keys=False,
allow_agent=False,
timeout=5,
)
return client
def _check(expected: SshHostKey | None, seen: list, port: int = 22) -> HostKeyCheck:
args: dict = {OBSERVER_ARG: seen.append}
if expected is not None:
args[KNOWN_KEYS_ARG] = {str(port): expected.as_dict()}
return HostKeyCheck(args, "10.0.0.5", port)
class TestSshHostKey:
def test_fingerprint_is_the_one_ssh_keygen_prints(self):
assert ED25519.fingerprint == ED25519_FP
def test_of_reads_a_paramiko_key(self, host_key):
key = SshHostKey.of(host_key)
assert key.key_type == host_key.get_name()
assert key.key == host_key.get_base64()
def test_as_dict_round_trips(self):
d = ED25519.as_dict()
assert d == {"key_type": "ssh-ed25519", "key": ED25519.key, "fingerprint": ED25519_FP}
assert SshHostKey.from_value(d) == ED25519
@pytest.mark.parametrize("value", [None, {}, ""])
def test_no_value_is_no_key(self, value):
assert SshHostKey.from_value(value) is None
@pytest.mark.parametrize(
"value", [{"key_type": "ssh-ed25519"}, {"key": "AAAA"}, "ssh-ed25519 AAAA"]
)
def test_a_malformed_value_is_refused(self, value):
with pytest.raises(ValueError):
SshHostKey.from_value(value)
def test_known_hosts_line_names_a_port_other_than_22(self):
assert ED25519.known_hosts_line("10.0.0.5", 22) == f"10.0.0.5 ssh-ed25519 {ED25519.key}"
line = ED25519.known_hosts_line("10.0.0.5", 2222)
assert line == f"[10.0.0.5]:2222 ssh-ed25519 {ED25519.key}"
class TestKnownKeys:
def test_the_key_is_the_one_for_the_port_connected_to(self):
args = {KNOWN_KEYS_ARG: {"22": ED25519.as_dict()}}
assert HostKeyCheck(args, "10.0.0.5", 22).expected == ED25519
assert HostKeyCheck(args, "10.0.0.5", 2222).expected is None
def test_a_port_may_be_given_as_a_number(self):
args = {KNOWN_KEYS_ARG: {2222: ED25519.as_dict()}}
assert HostKeyCheck(args, "10.0.0.5", 2222).expected == ED25519
def test_a_malformed_mapping_is_refused(self):
with pytest.raises(ValueError):
HostKeyCheck({KNOWN_KEYS_ARG: [ED25519.as_dict()]}, "10.0.0.5", 22)
class TestParamiko:
def test_first_use_accepts_the_key_and_reports_it(self, host, host_key):
seen: list = []
_connect(_check(None, seen), host).close()
assert host.server.passwords == ["s3cret"]
assert seen == [
{
"host": "10.0.0.5",
"port": 22,
"seen": SshHostKey.of(host_key).as_dict(),
"expected": None,
"accepted": True,
}
]
def test_the_known_key_connects(self, host, host_key):
seen: list = []
known = SshHostKey.of(host_key)
_connect(_check(known, seen), host).close()
assert host.server.passwords == ["s3cret"]
assert seen[0]["accepted"] is True
assert seen[0]["expected"] == known.as_dict()
def test_a_changed_key_is_refused_before_any_login(self, host, host_key):
seen: list = []
other = SshHostKey.of(paramiko.ECDSAKey.generate())
with pytest.raises(SshHostKeyMismatch) as caught:
_connect(_check(other, seen), host)
assert host.server.passwords == []
assert caught.value.observation.seen == SshHostKey.of(host_key)
assert caught.value.observation.expected == other
assert seen[0]["accepted"] is False
assert seen[0]["seen"]["fingerprint"] == SshHostKey.of(host_key).fingerprint
def test_a_key_of_another_type_is_refused(self, host):
seen: list = []
with pytest.raises(SshHostKeyMismatch):
_connect(_check(ED25519, seen), host)
assert host.server.passwords == []
def test_the_mismatch_is_a_connection_failure_that_names_both_keys(self, host, host_key):
with pytest.raises(ConnectionException) as caught:
_connect(_check(ED25519, []), host)
message = str(caught.value)
assert "10.0.0.5:22" in message
assert SshHostKey.of(host_key).fingerprint in message
assert ED25519_FP in message
def test_a_failing_observer_does_not_break_the_connection(self, host):
def boom(_observation):
raise RuntimeError("store down")
check = HostKeyCheck({OBSERVER_ARG: boom}, "10.0.0.5", 22)
_connect(check, host).close()
assert host.server.passwords == ["s3cret"]
def test_without_optional_args_it_still_connects(self, host):
_connect(HostKeyCheck(None, "10.0.0.5", 22), host).close()
assert host.server.passwords == ["s3cret"]
class TestNetmiko:
BASE = {
"ssh_strict": False,
"alt_host_keys": True,
"alt_key_file": "/x",
"system_host_keys": True,
"conn_timeout": 9,
}
def test_first_use_lets_netmiko_accept_the_key(self):
with HostKeyCheck({}, "10.0.0.5", 22).netmiko(self.BASE) as kwargs:
assert kwargs == {
"ssh_strict": False,
"alt_host_keys": False,
"system_host_keys": False,
"conn_timeout": 9,
}
def test_a_known_key_is_the_only_one_netmiko_takes(self):
check = HostKeyCheck({KNOWN_KEYS_ARG: {"2222": ED25519.as_dict()}}, "10.0.0.5", 2222)
with check.netmiko(self.BASE) as kwargs:
path = kwargs["alt_key_file"]
assert kwargs["ssh_strict"] is True
assert kwargs["alt_host_keys"] is True
assert kwargs["system_host_keys"] is False
assert kwargs["conn_timeout"] == 9
with open(path) as f:
assert f.read().strip() == ED25519.known_hosts_line("10.0.0.5", 2222)
assert not os.path.exists(path)
def test_a_refusal_inside_netmiko_becomes_the_mismatch(self, host_key):
# netmiko re-raises paramiko's BadHostKeyException as its own exception,
# inside the except block, and a driver wraps that once more.
seen: list = []
check = _check(ED25519, seen)
with pytest.raises(SshHostKeyMismatch) as caught:
with check.netmiko({}):
try:
try:
other = paramiko.ECDSAKey.generate()
raise paramiko.BadHostKeyException("10.0.0.5", host_key, other)
except paramiko.SSHException as exc:
raise RuntimeError(f"A paramiko SSHException occurred: {exc}") from exc
except RuntimeError as exc:
raise ConnectionException(str(exc)) from exc
assert caught.value.observation.seen == SshHostKey.of(host_key)
assert caught.value.observation.expected == ED25519
assert seen[0]["accepted"] is False
def test_any_other_failure_passes_unchanged(self):
with pytest.raises(ConnectionException, match="timed out"):
with HostKeyCheck({KNOWN_KEYS_ARG: {"22": ED25519.as_dict()}}, "10.0.0.5", 22).netmiko(
{}
):
raise ConnectionException("timed out")
def test_observe_reports_the_key_netmiko_saw(self, host_key):
class _Transport:
def get_remote_server_key(self):
return host_key
seen: list = []
observation = _check(None, seen).observe(_Transport())
assert observation.accepted is True
assert seen[0]["seen"] == SshHostKey.of(host_key).as_dict()
+217 -23
View File
@@ -9,7 +9,6 @@ cannot get through -- runs without a machine.
from __future__ import annotations
import re
from typing import Dict, List, Optional
import pytest
@@ -19,7 +18,9 @@ from napalm_device_types.ssh_server import (
MAIN_CONFIG,
OpenSshServerMixin,
has_dropin_include,
host_key_algorithms,
parse_sshd_effective,
parse_sshd_hostkeys,
plan_ssh_hardening,
render_dropin,
render_main_config,
@@ -32,8 +33,26 @@ hostkeyalgorithms ssh-ed25519,ecdsa-sha2-nistp256,rsa-sha2-512,rsa-sha2-256
ciphers chacha20-poly1305@openssh.com,aes128-ctr,aes256-gcm@openssh.com
macs umac-64-etm@openssh.com,hmac-sha2-256-etm@openssh.com,hmac-sha1,hmac-sha2-256
passwordauthentication no
hostkey /etc/ssh/ssh_host_ecdsa_key
hostkey /etc/ssh/ssh_host_ed25519_key
hostkey /etc/ssh/ssh_host_rsa_key
"""
#: What ``ssh-keygen -l`` prints per host key type.
KEYGEN = {
"/etc/ssh/ssh_host_ecdsa_key": "256 SHA256:aaaa root@host (ECDSA)",
"/etc/ssh/ssh_host_ed25519_key": "256 SHA256:bbbb root@host (ED25519)",
"/etc/ssh/ssh_host_rsa_key": "3072 SHA256:cccc root@host (RSA)",
}
#: The lock-out case: sshd offers ECDSA, sk- and certificate algorithms, but the
#: host has only an ed25519 and an RSA key.
SSHD_T_FEW_KEYS = SSHD_T.replace(
"hostkeyalgorithms ssh-ed25519,ecdsa-sha2-nistp256,rsa-sha2-512,rsa-sha2-256",
"hostkeyalgorithms ssh-ed25519-cert-v01@openssh.com,ecdsa-sha2-nistp256-cert-v01@openssh.com,"
"ssh-ed25519,ecdsa-sha2-nistp256,sk-ssh-ed25519@openssh.com,rsa-sha2-512,rsa-sha2-256",
).replace("hostkey /etc/ssh/ssh_host_ecdsa_key\n", "")
PARAMIKO = {
"kex": ["curve25519-sha256@libssh.org", "ecdh-sha2-nistp256", "diffie-hellman-group14-sha256"],
"hostkey": ["ssh-ed25519", "ecdsa-sha2-nistp256", "rsa-sha2-512"],
@@ -49,7 +68,11 @@ def test_parse_reads_the_four_lists_in_server_order():
eff = parse_sshd_effective(SSHD_T)
assert eff["kex"][:2] == ["sntrup761x25519-sha512@openssh.com", "curve25519-sha256"]
assert eff["hostkey"] == ["ssh-ed25519", "ecdsa-sha2-nistp256", "rsa-sha2-512", "rsa-sha2-256"]
assert eff["cipher"] == ["chacha20-poly1305@openssh.com", "aes128-ctr", "aes256-gcm@openssh.com"]
assert eff["cipher"] == [
"chacha20-poly1305@openssh.com",
"aes128-ctr",
"aes256-gcm@openssh.com",
]
assert eff["mac"][-1] == "hmac-sha2-256"
@@ -62,7 +85,9 @@ def test_parse_refuses_output_without_the_lists():
def test_plan_removes_what_was_asked_and_keeps_the_order():
plan = plan_ssh_hardening(parse_sshd_effective(SSHD_T), {"mac": ["hmac-sha1", "umac-64-etm@openssh.com"]})
plan = plan_ssh_hardening(
parse_sshd_effective(SSHD_T), {"mac": ["hmac-sha1", "umac-64-etm@openssh.com"]}
)
assert plan.remove == {"mac": ["umac-64-etm@openssh.com", "hmac-sha1"]}
assert plan.keep["mac"] == ["hmac-sha2-256-etm@openssh.com", "hmac-sha2-256"]
assert plan.changed_classes == ["mac"]
@@ -93,25 +118,101 @@ def test_plan_keeps_one_algorithm_the_managing_client_still_speaks():
know would leave the server reachable, but not by netOrk."""
plan = plan_ssh_hardening(
parse_sshd_effective(SSHD_T),
{"kex": ["curve25519-sha256@libssh.org", "ecdh-sha2-nistp256", "ecdh-sha2-nistp384",
"diffie-hellman-group14-sha256"]},
{
"kex": [
"curve25519-sha256@libssh.org",
"ecdh-sha2-nistp256",
"ecdh-sha2-nistp384",
"diffie-hellman-group14-sha256",
]
},
client=PARAMIKO,
)
assert "curve25519-sha256@libssh.org" in plan.keep["kex"]
assert [(r.algorithm, r.reason) for r in plan.refused] == [("curve25519-sha256@libssh.org", "client")]
assert plan.remove["kex"] == ["ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "diffie-hellman-group14-sha256"]
assert [(r.algorithm, r.reason) for r in plan.refused] == [
("curve25519-sha256@libssh.org", "client")
]
assert plan.remove["kex"] == [
"ecdh-sha2-nistp256",
"ecdh-sha2-nistp384",
"diffie-hellman-group14-sha256",
]
def test_plan_without_a_client_list_only_guards_the_last_algorithm():
plan = plan_ssh_hardening(
parse_sshd_effective(SSHD_T),
{"kex": ["curve25519-sha256@libssh.org", "ecdh-sha2-nistp256", "ecdh-sha2-nistp384",
"diffie-hellman-group14-sha256"]},
{
"kex": [
"curve25519-sha256@libssh.org",
"ecdh-sha2-nistp256",
"ecdh-sha2-nistp384",
"diffie-hellman-group14-sha256",
]
},
)
assert plan.refused == []
assert plan.keep["kex"] == ["sntrup761x25519-sha512@openssh.com", "curve25519-sha256"]
def test_plan_counts_only_host_key_algorithms_the_host_has_a_key_for():
"""Without ed25519 and RSA, only ECDSA, sk- and cert algorithms stay: offered,
but the host has no key for them, so nobody would get in."""
eff = parse_sshd_effective(SSHD_T_FEW_KEYS)
plan = plan_ssh_hardening(
eff,
{"hostkey": ["ssh-ed25519", "rsa-sha2-256", "rsa-sha2-512"]},
host_keys={"ssh-ed25519", "rsa-sha2-512", "rsa-sha2-256", "ssh-rsa"},
)
assert "ssh-ed25519" in plan.keep["hostkey"]
assert [(r.algorithm, r.reason) for r in plan.refused] == [("ssh-ed25519", "last")]
assert plan.remove["hostkey"] == ["rsa-sha2-512", "rsa-sha2-256"]
def test_plan_client_guard_counts_only_usable_host_keys():
eff = parse_sshd_effective(SSHD_T_FEW_KEYS)
plan = plan_ssh_hardening(
eff,
{"hostkey": ["ssh-ed25519", "rsa-sha2-256", "rsa-sha2-512"]},
client=PARAMIKO,
host_keys={"ssh-ed25519", "rsa-sha2-512", "rsa-sha2-256", "ssh-rsa"},
)
# paramiko speaks ecdsa-sha2-nistp256, but the host has no such key.
assert [(r.algorithm, r.reason) for r in plan.refused] == [("ssh-ed25519", "client")]
def test_plan_removes_a_host_key_algorithm_when_a_usable_one_stays():
plan = plan_ssh_hardening(
parse_sshd_effective(SSHD_T_FEW_KEYS),
{"hostkey": ["rsa-sha2-256"]},
host_keys={"ssh-ed25519", "rsa-sha2-512", "rsa-sha2-256", "ssh-rsa"},
)
assert plan.remove == {"hostkey": ["rsa-sha2-256"]}
assert plan.refused == []
def test_plan_leaves_host_keys_alone_when_the_keys_are_unknown():
plan = plan_ssh_hardening(parse_sshd_effective(SSHD_T), {"hostkey": ["ecdsa-sha2-nistp256"]})
assert plan.changed_classes == []
assert [(r.algorithm, r.reason) for r in plan.refused] == [
("ecdsa-sha2-nistp256", "host_keys_unknown")
]
def test_host_key_algorithms_from_ssh_keygen():
assert host_key_algorithms("\n".join(KEYGEN.values())) == {
"ecdsa-sha2-nistp256",
"ssh-ed25519",
"rsa-sha2-512",
"rsa-sha2-256",
"ssh-rsa",
}
assert host_key_algorithms("521 SHA256:x no comment (ECDSA)") == {"ecdsa-sha2-nistp521"}
assert host_key_algorithms("") is None
assert host_key_algorithms("garbage") is None
assert parse_sshd_hostkeys(SSHD_T)[0] == "/etc/ssh/ssh_host_ecdsa_key"
def test_plan_rejects_an_unknown_class():
with pytest.raises(ValueError):
plan_ssh_hardening(parse_sshd_effective(SSHD_T), {"compression": ["zlib"]})
@@ -132,11 +233,15 @@ def test_include_is_found_only_in_the_global_section():
assert has_dropin_include("Include /etc/ssh/sshd_config.d/*.conf\nPort 22\n")
assert has_dropin_include(" include /etc/ssh/sshd_config.d/*.conf\n")
assert not has_dropin_include("Port 22\n")
assert not has_dropin_include("Port 22\nMatch User x\n Include /etc/ssh/sshd_config.d/*.conf\n")
assert not has_dropin_include(
"Port 22\nMatch User x\n Include /etc/ssh/sshd_config.d/*.conf\n"
)
def test_main_config_gets_the_lists_first_and_the_old_lines_disabled():
main = "# defaults\nPort 22\nMACs hmac-sha1,hmac-sha2-256\nMatch User backup\n MACs hmac-sha1\n"
main = (
"# defaults\nPort 22\nMACs hmac-sha1,hmac-sha2-256\nMatch User backup\n MACs hmac-sha1\n"
)
text = render_main_config(main, {"mac": ["hmac-sha2-256"]}, ["mac"])
lines = text.splitlines()
first_directive = next(line for line in lines if line and not line.startswith("#"))
@@ -155,23 +260,34 @@ def test_main_config_rewrite_is_idempotent():
# ── the mixin, against a fake host ───────────────────────────────────────
_DIRECTIVE = {"kexalgorithms": "kex", "hostkeyalgorithms": "hostkey", "ciphers": "cipher", "macs": "mac"}
_DIRECTIVE = {
"kexalgorithms": "kex",
"hostkeyalgorithms": "hostkey",
"ciphers": "cipher",
"macs": "mac",
}
class FakeHost(OpenSshServerMixin):
"""A host with files, an sshd that reads them first-match-wins, and a log of what ran."""
def __init__(self, main: str = "Include /etc/ssh/sshd_config.d/*.conf\nPort 22\n") -> None:
self.files: Dict[str, str] = {MAIN_CONFIG: main}
self.defaults = parse_sshd_effective(SSHD_T)
self.ran: List[str] = []
def __init__(
self, main: str = "Include /etc/ssh/sshd_config.d/*.conf\nPort 22\n", sshd_t: str = SSHD_T
) -> None:
self.files: dict[str, str] = {MAIN_CONFIG: main}
self.modes: dict[str, str] = {MAIN_CONFIG: "644"}
self.sshd_t = sshd_t
self.defaults = parse_sshd_effective(sshd_t)
self.keys = {p: KEYGEN[p] for p in parse_sshd_hostkeys(sshd_t)}
self.keygen_ok = True
self.ran: list[str] = []
self.syntax_ok = True
self.reloads = 0
def _effective(self) -> Dict[str, List[str]]:
def _effective(self) -> dict[str, list[str]]:
eff = {k: list(v) for k, v in self.defaults.items()}
seen = set()
lines: List[str] = []
lines: list[str] = []
for line in self.files[MAIN_CONFIG].splitlines():
if re.match(r"\s*include\s", line, re.I):
lines += self.files.get(DROPIN_PATH, "").splitlines()
@@ -192,8 +308,14 @@ class FakeHost(OpenSshServerMixin):
#: or a cached timestamp): the password the driver put in front of the data.
SUDO_PASSWORD = "hunter2"
def run_command(self, command: str, *, privileged: bool = False, timeout: float = 60,
stdin: Optional[bytes] = None) -> CommandResult:
def run_command(
self,
command: str,
*,
privileged: bool = False,
timeout: float = 60,
stdin: bytes | None = None,
) -> CommandResult:
self.ran.append(command)
if privileged and stdin is not None:
stdin = f"{self.SUDO_PASSWORD}\n".encode() + stdin
@@ -204,19 +326,33 @@ class FakeHost(OpenSshServerMixin):
self.files[path] = (stdin or b"").decode()
return CommandResult(path, "", 0)
assert privileged, command
m = re.search(r"install -m 644 -- '?([^' ]+)'? '?([^' ]+)'?", command)
m = re.search(r'install -m "\$m" -- \'?([^\' ]+)\'? \'?([^\' ]+)\'?', command)
if m:
src, tmp = m.group(1), m.group(2)
target = re.search(r"mv -f -- '?[^' ]+'? '?([^' ;]+)'?", command).group(1)
ref = re.search(r"r='?([^' ;]+)'?; m=\$\(stat -c %a", command).group(1)
self.modes[target] = self.modes.get(ref, "644") if ref in self.files else "644"
self.files[target] = self.files.pop(src)
assert tmp.endswith(".netork-tmp")
return CommandResult("", "", 0)
if "ssh-keygen -l" in command:
if not self.keygen_ok:
return CommandResult("", "Permission denied", 1)
paths = re.search(r"for k in (.*?); do", command).group(1).replace("'", "").split()
return CommandResult(
"".join(self.keys[p] + "\n" for p in paths if p in self.keys), "", 0
)
if command.startswith("sshd -T") or "sshd -T" in command:
eff = self._effective()
out = "".join(f"{d} {','.join(eff[k])}\n" for d, k in _DIRECTIVE.items())
out += "".join(
line + "\n" for line in self.sshd_t.splitlines() if line.startswith("hostkey ")
)
return CommandResult(out, "", 0)
if "sshd -t" in command:
return CommandResult("", "" if self.syntax_ok else "bad line 3", 0 if self.syntax_ok else 255)
return CommandResult(
"", "" if self.syntax_ok else "bad line 3", 0 if self.syntax_ok else 255
)
m = re.match(r"cat -- '?([^' ]+)'?$", command)
if m:
path = m.group(1)
@@ -226,6 +362,7 @@ class FakeHost(OpenSshServerMixin):
m = re.match(r"rm -f -- '?([^' ]+)'?$", command)
if m:
self.files.pop(m.group(1), None)
self.modes.pop(m.group(1), None)
return CommandResult("", "", 0)
if "reload" in command or "HUP" in command:
self.reloads += 1
@@ -237,7 +374,7 @@ class FakeHost(OpenSshServerMixin):
def test_mixin_writes_the_dropin_checks_it_and_reloads():
host = FakeHost()
lines: List[str] = []
lines: list[str] = []
result = host.harden_ssh_server({"mac": ["hmac-sha1"]}, client=PARAMIKO, log=lines.append)
assert result.applied and not result.rolled_back
assert result.path == DROPIN_PATH
@@ -335,3 +472,60 @@ def test_restoring_an_earlier_dropin_leaks_nothing_either():
host.files[DROPIN_PATH] = "# netOrk\nHostKeyAlgorithms ssh-ed25519\n"
host.harden_ssh_server({"mac": ["hmac-sha1"]}, verify=lambda: False)
assert host.files[DROPIN_PATH] == "# netOrk\nHostKeyAlgorithms ssh-ed25519\n"
def test_mixin_reports_a_failed_way_back_instead_of_raising():
class ReloadFails(FakeHost):
def run_command(self, command, **kwargs):
if "reload" in command or "HUP" in command:
return CommandResult("", "Failed to reload ssh.service", 1)
return super().run_command(command, **kwargs)
host = ReloadFails()
result = host.harden_ssh_server({"mac": ["hmac-sha1"]})
assert not result.applied and not result.rolled_back
assert "putting the previous configuration back failed" in result.message
def test_mixin_refuses_to_lock_out_a_host_with_only_ed25519_and_rsa_keys():
host = FakeHost(sshd_t=SSHD_T_FEW_KEYS)
lines: list[str] = []
result = host.harden_ssh_server(
{"hostkey": ["ssh-ed25519", "rsa-sha2-256", "rsa-sha2-512"]}, log=lines.append
)
assert result.applied
assert "ssh-ed25519" in host._effective()["hostkey"]
assert any("Keeping ssh-ed25519" in line for line in lines)
def test_mixin_removes_a_host_key_algorithm_when_a_usable_one_stays():
host = FakeHost(sshd_t=SSHD_T_FEW_KEYS)
result = host.harden_ssh_server({"hostkey": ["rsa-sha2-256", "rsa-sha2-512"]})
assert result.applied and result.plan.refused == []
eff = host._effective()["hostkey"]
assert "rsa-sha2-256" not in eff and "ssh-ed25519" in eff
def test_mixin_leaves_host_keys_alone_when_it_cannot_read_them():
host = FakeHost()
host.keygen_ok = False
result = host.harden_ssh_server({"hostkey": ["ecdsa-sha2-nistp256"]})
assert not result.applied and host.reloads == 0
assert DROPIN_PATH not in host.files
assert [r.reason for r in result.plan.refused] == ["host_keys_unknown"]
def test_a_0600_sshd_config_stays_0600_and_so_does_its_copy():
host = FakeHost(main="Port 22\nMACs hmac-sha1,hmac-sha2-256\n")
host.modes[MAIN_CONFIG] = "600"
result = host.harden_ssh_server({"mac": ["hmac-sha1"]})
assert result.applied
assert host.modes[MAIN_CONFIG] == "600"
assert host.modes[result.backup] == "600"
def test_a_new_dropin_gets_644():
host = FakeHost()
host.harden_ssh_server({"mac": ["hmac-sha1"]})
assert host.modes[DROPIN_PATH] == "644"
+120
View File
@@ -0,0 +1,120 @@
"""SystemIdentityMixin: the SMBIOS identity of a host, for matching it to its BMC."""
from __future__ import annotations
import pytest
from napalm_device_types import (
SYSTEM_IDENTITY_COMMAND,
SystemIdentityMixin,
is_placeholder_identity,
parse_system_identity,
)
UUID = "31303043-534D-C40C-7A72-6F1300000000"
def _out(uuid: str, product_serial: str, board_serial: str) -> str:
return f"IDBEGIN\n{uuid}\n{product_serial}\n{board_serial}\nIDEND\n"
# ── parsing ───────────────────────────────────────────────────────────────────
def test_reads_uuid_and_both_serials():
assert parse_system_identity(_out(UUID, "S123", "ZM155S026022")) == {
"system_uuid": "31303043-534d-c40c-7a72-6f1300000000",
"product_serial": "S123",
"board_serial": "ZM155S026022",
}
def test_a_supermicro_placeholder_serial_is_dropped():
# pve-dual, a Supermicro X10DRL-i: the product serial is the board vendor's default.
identity = parse_system_identity(_out(UUID, "0123456789", "ZM155S026022"))
assert identity["product_serial"] == ""
assert identity["board_serial"] == "ZM155S026022"
@pytest.mark.parametrize(
"value",
[
"",
"None",
"N/A",
"Not Specified",
"To be filled by O.E.M.",
"Default string",
"System Serial Number",
"0123456789",
"1234567890",
"0",
"00000000",
"Not Applicable",
],
)
def test_placeholders(value):
assert is_placeholder_identity(value)
@pytest.mark.parametrize(
"uuid",
[
"00000000-0000-0000-0000-000000000000",
"FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF",
# A UUID many cheap boards ship, the same on every one of them.
"03000200-0400-0500-0006-000700080009",
],
)
def test_placeholder_uuids(uuid):
assert parse_system_identity(_out(uuid, "", ""))["system_uuid"] == ""
def test_real_values_are_not_placeholders():
assert not is_placeholder_identity("ZM155S026022")
assert not is_placeholder_identity(UUID)
def test_unreadable_files_read_as_empty():
# product_serial and product_uuid are root's alone; without root they come back empty.
assert parse_system_identity(_out("", "", "BS1")) == {
"system_uuid": "",
"product_serial": "",
"board_serial": "BS1",
}
def test_output_without_its_frame_is_not_a_reading():
with pytest.raises(ValueError):
parse_system_identity("sudo: a password is required\n")
def test_terminal_noise_around_the_frame_is_ignored():
assert parse_system_identity("\x1b[?2004h" + _out(UUID, "S1", "B1"))["board_serial"] == "B1"
# ── the mixin ─────────────────────────────────────────────────────────────────
class _Host(SystemIdentityMixin):
def __init__(self, output: str):
self.output = output
self.calls: list = []
def _run_system_identity_command(self, command: str, *, privileged: bool) -> str:
self.calls.append((command, privileged))
return self.output
def test_the_driver_only_carries_the_command_across():
host = _Host(_out(UUID, "S1", "B1"))
assert host.get_system_identity()["board_serial"] == "B1"
((command, privileged),) = host.calls
assert command == SYSTEM_IDENTITY_COMMAND
assert privileged is True
def test_the_command_reads_the_three_dmi_files():
for name in ("product_uuid", "product_serial", "board_serial"):
assert f"/sys/class/dmi/id/{name}" in SYSTEM_IDENTITY_COMMAND
+1 -1
View File
@@ -409,4 +409,4 @@ class TestSystemdServicesMixin:
def test_not_every_os_driver_has_it(self):
assert not hasattr(OSDriver, "manage_service")
assert callable(getattr(SystemdServicesMixin, "manage_service"))
assert callable(SystemdServicesMixin.manage_service)
+3 -1
View File
@@ -1,21 +1,23 @@
"""Tests for TYPE_LABEL class attribute on all base driver classes."""
import pytest
from napalm_device_types import (
AccessPointDriver,
FirewallDriver,
HypervisorDriver,
ManagementControllerDriver,
OSDriver,
ResidentialGatewayDriver,
StorageDriver,
SwitchDriver,
)
BASE_CLASSES = [
(AccessPointDriver, "Access Point"),
(FirewallDriver, "Firewall"),
(HypervisorDriver, "Hypervisor"),
(ManagementControllerDriver, "Management Controller"),
(OSDriver, "OS"),
(ResidentialGatewayDriver, "Gateway"),
(StorageDriver, "Storage"),
+56
View File
@@ -0,0 +1,56 @@
"""SNMP interface counters: 64-bit where the agent has ifXTable, 32-bit where not."""
from __future__ import annotations
import asyncio
from napalm_device_types._ucd_metrics import (
OID_IF_DESCR,
OID_IF_HC_IN_OCT,
OID_IF_HC_OUT_OCT,
OID_IF_HIGH_SPEED,
OID_IF_IN_OCT,
OID_IF_OUT_OCT,
OID_IF_SPEED,
collect_ucd_metrics,
)
LEGACY = {
OID_IF_DESCR: {"1": "eth0"},
OID_IF_SPEED: {"1": "4294967295"},
OID_IF_IN_OCT: {"1": "100"},
OID_IF_OUT_OCT: {"1": "200"},
}
def _collect(tables: dict[str, dict[str, str]]) -> dict:
async def get(oid: str) -> str | None:
return None
async def walk(oid: str) -> dict[str, str]:
return tables.get(oid, {})
return asyncio.run(collect_ucd_metrics(get, walk))
def test_the_64_bit_counters_and_high_speed_win():
tables = {
**LEGACY,
OID_IF_HC_IN_OCT: {"1": "8589934592"},
OID_IF_HC_OUT_OCT: {"1": "17179869184"},
OID_IF_HIGH_SPEED: {"1": "10000"},
}
eth0 = _collect(tables)["interfaces"]["eth0"]
assert (eth0["rx_bytes"], eth0["tx_bytes"], eth0["speed_mbps"]) == (
8589934592,
17179869184,
10000,
)
def test_an_agent_without_ifxtable_falls_back_to_the_32_bit_counters():
eth0 = _collect(LEGACY)["interfaces"]["eth0"]
assert (eth0["rx_bytes"], eth0["tx_bytes"], eth0["speed_mbps"]) == (100, 200, 4294)
+56 -7
View File
@@ -152,9 +152,7 @@ class TestVipManager:
assert parse_virtual_ips(_wire(section))["vip_manager"] == []
def test_a_template_unit_is_named_after_its_instance(self):
section = _vip_manager(
"active active", "cfg ip 10.0.0.10", unit="vip-manager@pg16.service"
)
section = _vip_manager("active active", "cfg ip 10.0.0.10", unit="vip-manager@pg16.service")
[entry] = parse_virtual_ips(_wire(section))["vip_manager"]
@@ -365,6 +363,9 @@ class _Host:
self.bin.mkdir()
self.state = root / "systemd"
self.state.mkdir()
#: Stands in for /run/systemd/system, there while systemd is PID 1.
self.booted = root / "run-systemd-system"
self.booted.mkdir()
(self.state / "units").write_text("")
stub = self.bin / "systemctl"
stub.write_text(
@@ -392,11 +393,15 @@ class _Host:
path.write_text(content)
return path
def run(self, *, systemctl: bool = True) -> str:
def run(self, *, systemctl: bool = True, booted: bool = True) -> str:
if not systemctl:
(self.bin / "systemctl").unlink()
if not booted:
self.booted.rmdir()
assert VIRTUAL_IPS_COMMAND.count("/run/systemd/system") == 1
command = VIRTUAL_IPS_COMMAND.replace("/run/systemd/system", str(self.booted))
return subprocess.run(
["/bin/sh", "-c", VIRTUAL_IPS_COMMAND],
["/bin/sh", "-c", command],
capture_output=True,
text=True,
env={"PATH": str(self.bin)},
@@ -489,8 +494,10 @@ class TestTheCommandOnAHost:
assert SECRET not in out
assert "MASTER" not in out and "priority" not in out
reading = parse_virtual_ips(out)
assert [(e["instance"], e["address"], e["prefix_length"], e["interface"], e["group_key"])
for e in reading["keepalived"]] == [
assert [
(e["instance"], e["address"], e["prefix_length"], e["interface"], e["group_key"])
for e in reading["keepalived"]
] == [
("VI_WEB", "192.0.2.10", 24, "eth0", "vrid:51"),
("VI_DB", "192.0.2.20", 32, "eth1", "vrid:52"),
("VI_DB", "2001:db8::20", 64, "eth1", "vrid:52"),
@@ -552,6 +559,48 @@ class TestTheCommandOnAHost:
"keepalived": None,
}
def test_systemctl_without_systemd_running_is_not_looked_at(self, host):
"""A container that carries systemctl but runs something else as PID 1:
systemctl lists no units there, which is not "none declared"."""
config = host.file(
"etc/keepalived/keepalived.conf", "vrrp_instance a { virtual_ipaddress { 192.0.2.4 } }"
)
host.unit("keepalived.service", "ActiveState=active", _exec_start("/k", "-f", str(config)))
assert parse_virtual_ips(host.run(booted=False)) == {
"vip_manager": None,
"keepalived": None,
}
def test_braces_on_their_own_line(self, host):
"""keepalived takes ``vrrp_instance VI_2`` and its ``{`` on separate lines,
the same for every block; the secrets inside stay on the host."""
config = host.file(
"etc/keepalived/keepalived.conf",
"vrrp_instance VI_1 {\n interface eth0\n"
" virtual_ipaddress {\n 192.0.2.50\n }\n}\n"
"vrrp_instance VI_2\n{\n interface eth1\n virtual_router_id 53\n"
f" authentication\n {{\n auth_type PASS\n auth_pass {SECRET}\n }}\n"
" virtual_ipaddress\n\n {\n 192.0.2.60/24\n }\n"
f" notify_master /etc/keepalived/master.sh {SECRET}\n}}\n",
)
host.unit(
"keepalived.service",
"ActiveState=active",
_exec_start("/usr/sbin/keepalived", "-f", str(config)),
)
out = host.run()
assert SECRET not in out
assert [
(e["instance"], e["address"], e["prefix_length"], e["interface"], e["group_key"])
for e in parse_virtual_ips(out)["keepalived"]
] == [
("VI_1", "192.0.2.50", 32, "eth0", None),
("VI_2", "192.0.2.60", 24, "eth1", "vrid:53"),
]
class TestTheCommand:
def test_the_frame_is_not_in_the_command_itself(self):
+6 -3
View File
@@ -15,7 +15,7 @@ from __future__ import annotations
import ast
import inspect
from typing import List, get_type_hints
from typing import get_type_hints
import napalm_device_types.hypervisor as hypervisor_module
from napalm_device_types import HypervisorDriver
@@ -33,7 +33,10 @@ def _declared(name: str) -> ast.FunctionDef:
class TestCreateVmTakesACpuType:
def test_cpu_type_is_an_optional_keyword(self):
fn = _declared("create_vm_from_cloud_init")
kwonly = {arg.arg: default for arg, default in zip(fn.args.kwonlyargs, fn.args.kw_defaults)}
kwonly = {
arg.arg: default
for arg, default in zip(fn.args.kwonlyargs, fn.args.kw_defaults, strict=True)
}
assert "cpu_type" in kwonly
default = kwonly["cpu_type"]
assert isinstance(default, ast.Constant) and default.value is None
@@ -52,7 +55,7 @@ class TestCpuTypeListing:
assert get_type_hints(VMCpuTypeDict) == {
"name": str,
"description": str,
"features": List[str],
"features": list[str],
"available": bool,
"default": bool,
}
+1
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
from typing import get_type_hints
import pytest
from napalm_device_types.models import (
VMConfigDict,
VMDict,