Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22e4377c2b | ||
|
|
49830671c9 | ||
|
|
0475184684 | ||
|
|
b7350a9b2c | ||
|
|
08296d0d2e | ||
|
|
ac8e7c4c11 | ||
|
|
f77091c489 | ||
|
|
be3958e561 | ||
|
|
a21e365c4e | ||
|
|
cce26eff44 | ||
|
|
888a11ca6a | ||
|
|
94bf399d34 | ||
|
|
bb2229fd9e | ||
|
|
680500dd2e | ||
|
|
a0e70d1686 | ||
|
|
2bd7bcc77c | ||
|
|
83cd7db761 | ||
|
|
1dd448e1d3 | ||
|
|
753ddc5eda | ||
|
|
59fb1273e8 | ||
|
|
4641063db6 | ||
|
|
634c142477 | ||
|
|
2d1c40c87d | ||
|
|
87c9b06bf3 | ||
|
|
2805f911c4 | ||
|
|
6551008ad3 | ||
|
|
321537ff0a | ||
|
|
9324a5b8f1 | ||
|
|
b4c4ec0b0b | ||
|
|
07b50f6a48 | ||
|
|
90367c1370 | ||
|
|
2739f10ecd | ||
|
|
0330f17a67 | ||
|
|
d3034bc8ea | ||
|
|
4f3b88db0e |
No files matched your search
@@ -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 }}
|
||||
@@ -1,49 +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", "3.13"]
|
||||
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: Type-check
|
||||
run: |
|
||||
python -m mypy napalm_device_types
|
||||
|
||||
- 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/*
|
||||
@@ -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.
|
||||
@@ -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,8 +29,9 @@ 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
|
||||
@@ -54,10 +55,12 @@ host OS's load; it has no `HealthMetricsMixin` (SNMP) and no command channel.
|
||||
```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]: ...
|
||||
```
|
||||
|
||||
@@ -91,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,
|
||||
@@ -117,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
|
||||
@@ -128,6 +175,40 @@ 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
|
||||
@@ -194,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
|
||||
@@ -213,20 +315,27 @@ generic data shapes belong here. Only the actual device communication belongs in
|
||||
**Worked example — firewall rule diff/apply** (`FirewallRuleMixin`, in `FirewallDriver`):
|
||||
|
||||
```python
|
||||
class FirewallRuleMixin: # mixed into FirewallDriver
|
||||
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 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`/
|
||||
@@ -322,8 +431,8 @@ HTTP_FINGERPRINT = [
|
||||
```python
|
||||
from napalm_device_types import AccessPointDriver
|
||||
|
||||
class OpenWrtDriver(AccessPointDriver):
|
||||
|
||||
class OpenWrtDriver(AccessPointDriver):
|
||||
def get_wireless_clients(self):
|
||||
# return List[WirelessClientDict]
|
||||
...
|
||||
@@ -338,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]
|
||||
...
|
||||
@@ -354,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]
|
||||
...
|
||||
@@ -375,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
|
||||
@@ -406,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]
|
||||
...
|
||||
@@ -434,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]
|
||||
...
|
||||
@@ -461,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]"
|
||||
```
|
||||
@@ -474,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
|
||||
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
# Structure
|
||||
|
||||
Status: agreed plan. Nothing here is implemented yet; the migration at the end says in
|
||||
which order it will be.
|
||||
|
||||
## Principle
|
||||
|
||||
**napalm-device-types is vendor-, OS-, protocol- and product-agnostic.** It holds the
|
||||
contracts every driver implements and the generic logic that works unchanged for every
|
||||
driver of a role. It knows no shell, no paramiko, no package manager, no container CLI,
|
||||
no SNMP OID, no cloud-init and no application that uses the drivers.
|
||||
|
||||
That rules out three kinds of code that are here today:
|
||||
|
||||
- **How a kind of host is read.** Shell commands and their parsers for POSIX hosts move to
|
||||
a new package, **napalm-unix**, which napalm-linux and napalm-bsd depend on.
|
||||
- **What an application does with a device.** Polling a device over SNMP, sweeping a range
|
||||
with pings, scoring discovery evidence, choosing what a provisioned guest installs, and
|
||||
deciding what counts as a problem belong to the application, not to the drivers.
|
||||
- **Product naming.** Files, markers and messages a driver leaves on a device carry the
|
||||
driver's name or none, never the name of the application that called it.
|
||||
|
||||
```
|
||||
napalm-device-types roles, capabilities, templates, models, reconcilers <- every driver
|
||||
^
|
||||
napalm-unix POSIX host readers, SSH exec transport, privilege <- napalm-linux, napalm-bsd
|
||||
^
|
||||
napalm-linux, napalm-bsd connection, platform getters, platform steps
|
||||
|
||||
application SNMP polling, ping sweeps, discovery scoring, guest
|
||||
profiles, warnings, and the values every fix is given
|
||||
```
|
||||
|
||||
## What stays in napalm-device-types
|
||||
|
||||
| Kind | Rule | Modules |
|
||||
|---|---|---|
|
||||
| **Role** | Declares only, under `TYPE_CHECKING`. No runtime method. | `access_point`, `switch`, `firewall`, `hypervisor`, `os`, `storage`, `phone`, `media`, `residential_gateway`, `management_controller` |
|
||||
| **Capability** | Declares only. | `packages`, `services`, `updates`, `nat_vpn`, `mac_acl`, `config_lifecycle`, `interface_filter`, `host_reboot` |
|
||||
| **Template** | A concrete method that runs a fixed sequence of abstract steps. A driver implements the steps, or overrides the whole method when its device does it in one go. All values come from the caller. | the fix routines below |
|
||||
| **Contract types** | Interfaces and data shapes other packages implement or fill. | `CommandChannelMixin`, `CommandResult`, `ByteStream`; the declaration of `ContainerEngineMixin`; the reader mixins' public method declarations; the SNMP profile type; `models` |
|
||||
| **Reconciler** | Concrete and vendor-neutral, without I/O of its own. | `firewall_rules` and `dhcp` diff/apply, `_matching`, `lag` |
|
||||
| **Introspection** | Concrete, without I/O. | `roles`; `base` (`FingerprintRule`, `PortSpec`, the fingerprint attributes) |
|
||||
| **Driver facts** | Class attributes that describe the device or how it is reached, not what an application does with it. | `USES_SSH`, `FULL_UPGRADE`, `platform`, `SNMP_HEALTH_PROFILE` |
|
||||
|
||||
### Fix routines are templates
|
||||
|
||||
A change the application wants made on a device, for example "run an SNMP agent that
|
||||
answers us", is one template per purpose. The template fixes the order of the steps; the
|
||||
driver knows how each step is done on its device; the caller supplies every value.
|
||||
|
||||
```python
|
||||
class SnmpAgentMixin:
|
||||
def configure_snmp_agent(self, settings: SnmpAgentSettingsDict) -> FixResultDict:
|
||||
steps = [self._install_snmp_agent, self._write_snmp_agent_config,
|
||||
self._enable_snmp_agent, self._verify_snmp_agent]
|
||||
... # runs them in order, stops at the first failure
|
||||
|
||||
# declared only; napalm-unix implements them for net-snmp
|
||||
def _install_snmp_agent(self) -> FixStepDict: ...
|
||||
def _write_snmp_agent_config(self, settings: SnmpAgentSettingsDict) -> FixStepDict: ...
|
||||
def _enable_snmp_agent(self) -> FixStepDict: ...
|
||||
def _verify_snmp_agent(self, settings: SnmpAgentSettingsDict) -> FixStepDict: ...
|
||||
```
|
||||
|
||||
napalm-unix implements the four steps for net-snmp, for Linux and the BSDs alike. A REST
|
||||
driver such as OPNsense overrides `configure_snmp_agent` as a whole, because its device
|
||||
takes the whole configuration in one call. The community, the allowed sources, contact
|
||||
and location are the caller's.
|
||||
|
||||
The same shape replaces every action in today's `run_device_action`:
|
||||
|
||||
| Today | Template |
|
||||
|---|---|
|
||||
| `fix_snmp` | `configure_snmp_agent(settings)` |
|
||||
| `fix_apt_proxy` | `configure_package_proxy(url)` |
|
||||
| `fix_docker_permissions` | `grant_container_engine_access(user)` |
|
||||
| `install_lldpd`, `install_auc` | `ensure_package(name)` |
|
||||
|
||||
The mixin is mixed in by the driver, or by the platform package, not by a role base. A
|
||||
driver that inherits a template it cannot run on its device, as a NAS driver built on the
|
||||
Linux driver inherits the package proxy, opts out by setting the method to `None`. So a
|
||||
caller asks `callable(getattr(driver, "configure_snmp_agent", None))`, never `hasattr`.
|
||||
|
||||
The caller verifies the result from its own side where it can; the SNMP agent is asked
|
||||
by the application's SNMP client, not by a probe the driver hand-builds.
|
||||
|
||||
### SNMP: drivers declare data, the application polls
|
||||
|
||||
The drivers do not collect over SNMP. A driver whose device answers the standard UCD-MIB
|
||||
and IF-MIB declares nothing. A driver whose device publishes the same values under
|
||||
proprietary OIDs declares them as data, in a class attribute of a type defined here:
|
||||
|
||||
```python
|
||||
class ProcurveDriver(SwitchDriver):
|
||||
SNMP_HEALTH_PROFILE: ClassVar[SnmpHealthProfile] = {
|
||||
"uptime": {"oids": ["1.3.6.1.4.1.11.2.14.11.1.2.4.1.5.1"], "format": "ticks"},
|
||||
"cpu": {"oids": ["1.3.6.1.4.1.11.2.14.11.5.1.9.6.1.0",
|
||||
"1.3.6.1.4.1.11.2.3.7.8.6.1.0"]},
|
||||
"memory": {"total": "...", "free": "...", "unit": "bytes"},
|
||||
}
|
||||
```
|
||||
|
||||
One OID per value is not enough for the drivers there are. The profile type has to say:
|
||||
|
||||
- **Fallbacks:** an ordered list of OIDs; the first that answers wins (ProCurve's CPU).
|
||||
- **Tables:** an OID to walk and how to fold its rows: first, average or maximum (TP-Link
|
||||
reports CPU and memory per stack unit).
|
||||
- **Text values:** a pattern that takes the number out of a string (Netgear reports CPU
|
||||
as `"5 Secs ( 3.2%)"`).
|
||||
- **Memory:** total with used or free, or a percentage only, and the unit.
|
||||
- **Uptime:** which OID, standard ones included (`sysUpTime` where a device has no
|
||||
`hrSystemUptime`), and its format.
|
||||
- **Interfaces:** a pattern of names to skip, and whether the TX-error counter counts
|
||||
drops. Today the second is `True` on every firewall, because OPNsense counts drops
|
||||
there; it becomes OPNsense's profile entry.
|
||||
|
||||
Polling, the standard OIDs, the counter arithmetic (64-bit counters first, 32-bit as the
|
||||
fallback) and the defaults are the application's.
|
||||
|
||||
### Warnings are facts
|
||||
|
||||
A driver reports facts: the proxy a host is configured with, a DSL line's SNR, the
|
||||
firmware version it runs and the one it is offered. It does not compare them with a
|
||||
setting of the application or with a threshold, and it does not name a warning.
|
||||
|
||||
The same goes for lookups a device does not make itself: what firmware a vendor
|
||||
currently publishes is the application's catalogue, not a getter.
|
||||
|
||||
### Getters read, and say when they cannot
|
||||
|
||||
- A getter changes nothing on the device. It does not start a daemon, rewrite its
|
||||
configuration or install a package on the way to an answer.
|
||||
- A read that fails raises. An empty list means "there is none", never "could not ask";
|
||||
a sync that deletes and re-adds on top of an empty answer would otherwise duplicate
|
||||
everything.
|
||||
- A write reports failure from the device's answer or exit status, never `success: True`
|
||||
by default.
|
||||
|
||||
### The application uses public methods only
|
||||
|
||||
The application never calls a driver's private methods (`_get`, `_post`,
|
||||
`_send_command`, `_action_*`). What it needs from a device becomes a declared method of
|
||||
the driver.
|
||||
|
||||
## What moves to napalm-unix
|
||||
|
||||
| Module | Knows |
|
||||
|---|---|
|
||||
| `host_status` | apt, dnf, dpkg, systemd timers, `df` |
|
||||
| `kernel` | `/proc`, `/lib/modules`, gzip, base64 |
|
||||
| `listening` | `ss`, `netstat`, `/proc/<pid>/cgroup` |
|
||||
| `systemd` | `systemctl` |
|
||||
| `virtual_ips` | vip-manager, keepalived, awk |
|
||||
| `system_identity` | SMBIOS via sysfs and `dmidecode` |
|
||||
| `package_updates` | apt and dnf output |
|
||||
| `ssh_server` | OpenSSH (`sshd -T`, `sshd -t`, `sshd_config`); which algorithms to drop stays the caller's |
|
||||
| `terminal` | ANSI escape codes in shell output |
|
||||
| `channel`: `run_on_transport`, `open_stream_on_transport`, `ParamikoExecStream` | paramiko exec channels |
|
||||
| `container_engine`: `ContainerEngineConnection`, engine probing | the Docker CLI, `system dial-stdio` |
|
||||
|
||||
It also takes what napalm-linux and napalm-bsd each carry today:
|
||||
|
||||
- **Privilege:** one `sudo -S` / `sudo -n` / root layer, including the rule that a
|
||||
privileged command never gets stdin while a sudo password is set, and one way to write a
|
||||
file as root (stage with `mktemp`, then `install`). Two copies of this have already
|
||||
drifted apart.
|
||||
- **Parsers:** `/etc/passwd` with `/etc/group`, crontab lines, `ps` with `lstart`.
|
||||
- **Fix steps:** net-snmp, the package proxy, container engine group membership.
|
||||
|
||||
The FreeBSD part of `host_status` (`freebsd-version -k` against `-r`) moves to napalm-bsd.
|
||||
|
||||
In napalm-unix every reader follows one pattern:
|
||||
|
||||
- **Shape:** `FOO_COMMAND`, `parse_foo(output)`, and `FooMixin.get_foo()`.
|
||||
- **Reaching the host:** the mixin uses `run_command(command, privileged=...)` from
|
||||
`CommandChannelMixin`. The six per-reader `_run_*` hooks, with four different signatures,
|
||||
go away.
|
||||
- **Framing:** one frame helper writes and parses the `BEGIN`/`END` markers for every reader.
|
||||
- **Errors:** one exception for "cannot read" and one for "not on this host". A read that
|
||||
fails or is cut short raises; it never reads as "nothing there".
|
||||
- **Names on the host:** temporary files, markers and drop-ins carry `napalm` or the
|
||||
driver's name. A drop-in written under an older name is still recognised and replaced.
|
||||
|
||||
Layout: `napalm_unix/{posix,linux,freebsd,openssh,transport}/`.
|
||||
|
||||
## What moves to the application
|
||||
|
||||
| Today | Why it is not a driver's |
|
||||
|---|---|
|
||||
| `health_metrics`, `_ucd_metrics` | Polling over SNMP is something the application does to a device; the driver is not even connected while it runs. |
|
||||
| `ping_sweep` | Looping over addresses, capping the targets and reporting progress is a scan. A device with a faster batch keeps it as a capability of its own. |
|
||||
| `rules_max_weight` | Scoring is how one application ranks drivers. The rules themselves stay with the driver. |
|
||||
| `provisioning` (`QEMU_GUEST_AGENTS`, cloud-init network-config, decompressors) | Which agent a guest gets and how its network is configured is a guest profile. The hypervisor driver is handed the packages and commands. |
|
||||
| `TYPE_LABEL`, `REBOOT_SETTLE_SECONDS` | A display name and a polling delay. |
|
||||
| firmware catalogues (napalm-netgear's `netgear_plus`) | What a vendor publishes is not something the device reports. |
|
||||
| `run_device_action` | Replaced by the templates above. |
|
||||
|
||||
`OSDriver.ROLE` stops defaulting to `"linux"`. Each OS driver states its own role.
|
||||
|
||||
## Rules, enforced by tests in this package
|
||||
|
||||
1. Roles and capabilities own no runtime method; a template runs only declared steps.
|
||||
2. No module imports `paramiko`, `socket`, `subprocess` or `asyncio`, and none defines a
|
||||
`*_COMMAND`.
|
||||
3. No module, string or default names an application.
|
||||
4. Role methods return TypedDicts, not `Any`.
|
||||
|
||||
Each rule is added with the step that makes it pass.
|
||||
|
||||
## Migration
|
||||
|
||||
This package cannot keep re-exports pointing at napalm-unix: the contract package would
|
||||
then depend on its implementation. So a moved name is first deprecated here and then
|
||||
removed, and nothing breaks in between.
|
||||
|
||||
| Step | Change |
|
||||
|---|---|
|
||||
| 1 | The application moves to 4.9 and the drivers that need it; napalm-bsd stops passing stdin to a privileged command while a sudo password is set. |
|
||||
| 2 | This document; the application's architecture docs and this README follow it. |
|
||||
| 3 | `SnmpHealthProfile`. The application polls on its own; the four switch and phone drivers declare their OIDs as data. `HealthMetricsMixin` is deprecated. |
|
||||
| 4 | The fix templates. napalm-linux and napalm-bsd implement the steps; the application calls the templates with its values. `run_device_action` is deprecated. The other drivers follow, one issue each. |
|
||||
| 5 | Ping sweep, scoring, provisioning, `TYPE_LABEL` and `REBOOT_SETTLE_SECONDS` move to the application and are deprecated here. Drivers report facts instead of warnings. |
|
||||
| 6 | napalm-unix, with the moved code and its tests, the unified hooks and the frame helper. napalm-linux and napalm-bsd import from it; the moved names are deprecated here. |
|
||||
| 7 | 5.0 removes everything deprecated. It also splits `models.py` by area, types the remaining `Dict[str, Any]` role returns, declares what drivers already offer undeclared (`get_networks`, `search_packages`, `first_boot_pending`, the base-system update entry), and makes `install_package` declare the result drivers return. |
|
||||
| 8 | The README shrinks to the concepts; the reader details move with the readers. |
|
||||
|
||||
## Proposals this replaces
|
||||
|
||||
Several open issues proposed adding shared driver code here. Under this structure:
|
||||
|
||||
| Issue | Goes to |
|
||||
|---|---|
|
||||
| #8 netmiko session | not here: a CLI-driver helper package, or left in the drivers |
|
||||
| #9 config mode for CLI switches | not here: a CLI-driver helper package, or left in the drivers |
|
||||
| #10 LLDP rows | the projection of rows into NAPALM's two shapes stays here; the `lldpctl` parser goes to napalm-unix |
|
||||
| #11 CLI text helpers | not here; the natural port sort, which `lag` already has, may become public |
|
||||
| #12 ping | the reply builder stays here; the BusyBox and iputils parser goes to napalm-unix |
|
||||
| #13 Linux userland | napalm-unix; the snmpd part becomes the SNMP template's steps |
|
||||
| #14 `run_device_action` | replaced by the fix templates |
|
||||
| #22 hooks onto `run_command` | step 6 |
|
||||
+132
-28
@@ -37,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`
|
||||
@@ -46,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`
|
||||
@@ -60,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, rules_max_weight
|
||||
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,
|
||||
@@ -71,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,
|
||||
@@ -97,46 +116,69 @@ 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.system_identity import (
|
||||
SYSTEM_IDENTITY_COMMAND,
|
||||
SystemIdentityMixin,
|
||||
is_placeholder_identity,
|
||||
parse_system_identity,
|
||||
)
|
||||
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,
|
||||
@@ -146,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",
|
||||
@@ -159,6 +243,10 @@ __all__ = [
|
||||
"open_stream_on_transport",
|
||||
"run_on_transport",
|
||||
"ConfigLifecycleMixin",
|
||||
"ConfigSyncDict",
|
||||
"ConfigSyncMixin",
|
||||
"DhcpHaDict",
|
||||
"DhcpHaPeerDict",
|
||||
"DeviceTypeDriver",
|
||||
"DhcpServerMixin",
|
||||
"FingerprintRule",
|
||||
@@ -193,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",
|
||||
@@ -211,6 +305,10 @@ __all__ = [
|
||||
"UpdateMixin",
|
||||
"VIRTUAL_IPS_COMMAND",
|
||||
"VirtualIpsMixin",
|
||||
"SERVICE_CLUSTERS_COMMAND",
|
||||
"ServiceClustersMixin",
|
||||
"parse_service_clusters",
|
||||
"service_clusters_command",
|
||||
"SystemIdentityMixin",
|
||||
"SYSTEM_IDENTITY_COMMAND",
|
||||
"is_placeholder_identity",
|
||||
@@ -220,6 +318,12 @@ __all__ = [
|
||||
"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",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""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
|
||||
@@ -8,18 +7,19 @@ name a single entry, and guessing would overwrite one the caller never meant.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Dict, Iterable, List, Set, Tuple, TypeVar
|
||||
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]]:
|
||||
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()
|
||||
by_key: dict[str, T] = {}
|
||||
shared: set[str] = set()
|
||||
for entry in entries:
|
||||
k = key(entry)
|
||||
if not k:
|
||||
@@ -32,10 +32,10 @@ def index_live(entries: Iterable[T], key: Callable[[T], str]) -> Tuple[Dict[str,
|
||||
return by_key, shared
|
||||
|
||||
|
||||
def check_desired(entries: List[T], key: Callable[[T], str], shared: Set[str], what: str) -> None:
|
||||
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()
|
||||
seen: set[str] = set()
|
||||
for entry in entries:
|
||||
k = key(entry)
|
||||
if not k:
|
||||
|
||||
@@ -1,41 +1,43 @@
|
||||
"""Shared SNMP metric collection helpers for UCD-MIB and IF-MIB."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from typing import Any, 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_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:
|
||||
@@ -48,35 +50,35 @@ def ticks_to_seconds(raw: str | None) -> int | None:
|
||||
|
||||
|
||||
def build_if_metrics(
|
||||
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],
|
||||
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[str] | None = None,
|
||||
tx_err_is_drop: bool = False,
|
||||
hc_in_oct: Optional[Dict[str, str]] = None,
|
||||
hc_out_oct: Optional[Dict[str, str]] = None,
|
||||
high_speed: Optional[Dict[str, str]] = None,
|
||||
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[str, Any] = {}
|
||||
interfaces: dict[str, Any] = {}
|
||||
for idx, name in descr.items():
|
||||
if if_skip.match(name):
|
||||
continue
|
||||
iface: Dict[str, Any] = {"name": name}
|
||||
iface: dict[str, Any] = {"name": name}
|
||||
try:
|
||||
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)
|
||||
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)
|
||||
@@ -98,15 +100,33 @@ async def collect_ucd_metrics(
|
||||
*,
|
||||
tx_err_is_drop: bool = False,
|
||||
if_skip: re.Pattern[str] | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
) -> 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, hc_in_oct, hc_out_oct, high_speed,
|
||||
(
|
||||
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),
|
||||
@@ -134,7 +154,7 @@ async def collect_ucd_metrics(
|
||||
),
|
||||
)
|
||||
|
||||
metrics: Dict[str, Any] = {}
|
||||
metrics: dict[str, Any] = {}
|
||||
|
||||
secs = ticks_to_seconds(uptime_raw)
|
||||
if secs is not None:
|
||||
@@ -149,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
|
||||
|
||||
@@ -164,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
|
||||
|
||||
@@ -178,7 +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,
|
||||
hc_in_oct=hc_in_oct, hc_out_oct=hc_out_oct, high_speed=high_speed)
|
||||
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
|
||||
@@ -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
|
||||
}
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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, Tuple, TYPE_CHECKING, cast
|
||||
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 cast(bytes, self._channel.recv(max_bytes))
|
||||
except socket.timeout:
|
||||
except TimeoutError:
|
||||
raise TimeoutError(f"no data within {timeout}s") from None
|
||||
finally:
|
||||
self._drain_stderr()
|
||||
@@ -127,7 +125,7 @@ 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 cast(int, self._channel.recv_exit_status())
|
||||
@@ -138,7 +136,7 @@ class ParamikoExecStream:
|
||||
return self._stderr.decode("utf-8", "replace")
|
||||
|
||||
|
||||
def _collect(channel: Any, command: str, timeout: float) -> Tuple[bytes, bytes]:
|
||||
def _collect(channel: Any, command: str, timeout: float) -> tuple[bytes, bytes]:
|
||||
out, err = b"", b""
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
@@ -153,7 +151,7 @@ def _collect(channel: Any, command: str, timeout: float) -> Tuple[bytes, bytes]:
|
||||
out += chunk
|
||||
while chunk := channel.recv_stderr(_READ):
|
||||
err += chunk
|
||||
except socket.timeout:
|
||||
except TimeoutError:
|
||||
raise TimeoutError(f"{command!r} did not finish within {timeout}s") from None
|
||||
return out, err
|
||||
elif time.monotonic() > deadline:
|
||||
@@ -163,7 +161,7 @@ def _collect(channel: Any, command: str, timeout: float) -> Tuple[bytes, bytes]:
|
||||
|
||||
|
||||
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()
|
||||
@@ -175,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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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".
|
||||
"""
|
||||
...
|
||||
@@ -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, cast
|
||||
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
|
||||
@@ -68,7 +68,7 @@ class ContainerEngineConnection:
|
||||
self,
|
||||
args: Sequence[str],
|
||||
*,
|
||||
stdin: Optional[bytes] = None,
|
||||
stdin: bytes | None = None,
|
||||
timeout: float = _CLI_TIMEOUT,
|
||||
privileged: bool = False,
|
||||
) -> CommandResult:
|
||||
@@ -77,9 +77,12 @@ 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 cast(CommandResult, 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(
|
||||
self, args: Sequence[str], *, merge_stderr: bool = False, privileged: bool = False
|
||||
@@ -90,9 +93,12 @@ class ContainerEngineConnection:
|
||||
progress there (``compose up``); *privileged* as for :meth:`run_cli`.
|
||||
"""
|
||||
command = self._command(args)
|
||||
return cast(ByteStream, 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
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ContainerEngineMixin:
|
||||
@@ -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)
|
||||
|
||||
+44
-42
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""DHCP desired-state management, shared by firewalls and gateways.
|
||||
|
||||
Covers two independent desired-state sets:
|
||||
@@ -24,15 +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,
|
||||
)
|
||||
|
||||
@@ -57,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 --
|
||||
@@ -79,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
|
||||
@@ -90,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;
|
||||
@@ -104,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:
|
||||
@@ -124,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.
|
||||
@@ -138,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.
|
||||
|
||||
@@ -156,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.
|
||||
@@ -167,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.
|
||||
|
||||
@@ -188,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``).
|
||||
|
||||
@@ -203,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).
|
||||
@@ -219,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.
|
||||
@@ -248,14 +257,15 @@ class DhcpServerMixin:
|
||||
"changed_fields"}, ...]}``.
|
||||
:raises ValueError: When a desired reservation cannot name a single live one.
|
||||
"""
|
||||
|
||||
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] = []
|
||||
add: list[DhcpReservationDict] = []
|
||||
update: list[DhcpReservationUpdateDict] = []
|
||||
|
||||
for desired_reservation in desired:
|
||||
live = live_by_mac.get(key(desired_reservation))
|
||||
@@ -279,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.
|
||||
@@ -304,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"]:
|
||||
@@ -313,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.
|
||||
@@ -347,14 +351,15 @@ class DhcpServerMixin:
|
||||
"changed_fields"}, ...]}``.
|
||||
:raises ValueError: When a desired subnet cannot name a single live one.
|
||||
"""
|
||||
|
||||
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] = []
|
||||
add: list[DhcpSubnetDict] = []
|
||||
update: list[DhcpSubnetUpdateDict] = []
|
||||
|
||||
for desired_subnet in desired:
|
||||
live = live_by_cidr.get(key(desired_subnet))
|
||||
@@ -378,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.
|
||||
@@ -406,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)")
|
||||
@@ -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."""
|
||||
...
|
||||
@@ -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".
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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,7 +13,8 @@ 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 (
|
||||
@@ -43,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.
|
||||
|
||||
@@ -55,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.
|
||||
|
||||
@@ -73,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).
|
||||
@@ -88,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.
|
||||
@@ -113,14 +115,15 @@ class FirewallRuleMixin:
|
||||
"changed_fields"}, ...]}``.
|
||||
:raises ValueError: When a desired rule cannot name a single live one.
|
||||
"""
|
||||
|
||||
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] = []
|
||||
add: list[FirewallRuleDict] = []
|
||||
update: list[FirewallRuleUpdateDict] = []
|
||||
|
||||
for desired_rule in desired:
|
||||
live_rule = live_by_description.get(key(desired_rule))
|
||||
@@ -143,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.
|
||||
|
||||
@@ -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"}
|
||||
"""
|
||||
...
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -59,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,
|
||||
@@ -110,7 +109,7 @@ HOST_STATUS_COMMAND = (
|
||||
"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 "
|
||||
'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 "
|
||||
@@ -147,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], [])
|
||||
@@ -164,12 +163,12 @@ 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.
|
||||
|
||||
@@ -187,7 +186,7 @@ def kernel_reboot_pending(running: str, installed: List[str]) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _kernel_flavour(release: str) -> Tuple[str, 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("-")
|
||||
@@ -199,7 +198,7 @@ def _kernel_flavour(release: str) -> Tuple[str, str]:
|
||||
return base, tail
|
||||
|
||||
|
||||
def _freebsd_reboot(sections: Dict[str, List[str]]) -> Optional[Tuple[bool, Optional[str]]]:
|
||||
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]
|
||||
@@ -210,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")
|
||||
@@ -229,29 +228,29 @@ 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 _dnf_applies(sections: Dict[str, List[str]]) -> 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]]) -> Optional[bool]:
|
||||
def _auto_updates(sections: dict[str, list[str]]) -> bool | None:
|
||||
timers = _timer_states(sections)
|
||||
if timers.get(_DNF_INSTALL_TIMER) == "enabled":
|
||||
return True
|
||||
@@ -265,7 +264,7 @@ def _auto_updates(sections: Dict[str, List[str]]) -> Optional[bool]:
|
||||
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":
|
||||
@@ -276,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]
|
||||
@@ -296,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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -78,9 +78,9 @@ def _report(output: str) -> str:
|
||||
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("]"):
|
||||
@@ -90,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:
|
||||
@@ -112,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]})
|
||||
|
||||
@@ -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,7 +40,7 @@ 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)})",
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
...
|
||||
@@ -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,7 +108,7 @@ def _sections(lines: List[str]) -> Dict[str, List[str]]:
|
||||
return sections
|
||||
|
||||
|
||||
def _split_local(local: str) -> Optional[Tuple[str, Optional[str], int]]:
|
||||
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(":")
|
||||
@@ -122,9 +121,9 @@ def _split_local(local: str) -> Optional[Tuple[str, Optional[str], int]]:
|
||||
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)
|
||||
@@ -136,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])
|
||||
@@ -144,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
|
||||
@@ -170,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
|
||||
@@ -182,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()
|
||||
@@ -204,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.
|
||||
|
||||
@@ -223,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:
|
||||
|
||||
@@ -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,7 +17,7 @@ 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.
|
||||
|
||||
@@ -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", [])
|
||||
"""
|
||||
...
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Abstract base class for server baseboard management controllers (BMCs).
|
||||
|
||||
@@ -10,7 +9,7 @@ Usage::
|
||||
...
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING, List
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from napalm_device_types.base import DeviceTypeDriver
|
||||
|
||||
@@ -107,7 +106,7 @@ class ManagementControllerDriver(DeviceTypeDriver):
|
||||
"""
|
||||
...
|
||||
|
||||
def get_event_log(self, limit: int = 500) -> List[EventLogEntryDict]:
|
||||
def get_event_log(self, limit: int = 500) -> list[EventLogEntryDict]:
|
||||
"""
|
||||
Returns the BMC's system event log (SEL, Lifecycle Log, IML),
|
||||
newest first.
|
||||
@@ -116,7 +115,7 @@ class ManagementControllerDriver(DeviceTypeDriver):
|
||||
"""
|
||||
...
|
||||
|
||||
def get_firmware_inventory(self) -> List[FirmwareDict]:
|
||||
def get_firmware_inventory(self) -> list[FirmwareDict]:
|
||||
"""
|
||||
Returns every firmware component the BMC reports: its own, the
|
||||
BIOS, storage controllers, NICs, power supplies.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+527
-186
File diff suppressed because it is too large.
Load diff
@@ -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.
|
||||
|
||||
|
||||
+17
-15
@@ -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).
|
||||
@@ -44,26 +47,20 @@ class OSDriver(UpdateMixin, ServiceControlMixin, PackageManagementMixin, HealthM
|
||||
# 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_os_users(self) -> List[UserDict]:
|
||||
def get_os_users(self) -> list[UserDict]:
|
||||
"""
|
||||
Returns a list of local OS user accounts.
|
||||
|
||||
@@ -95,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.
|
||||
|
||||
@@ -138,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``.
|
||||
|
||||
@@ -166,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.
|
||||
@@ -201,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.
|
||||
|
||||
@@ -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}}}
|
||||
"""
|
||||
...
|
||||
@@ -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
|
||||
@@ -42,10 +40,10 @@ _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[1:].rstrip()
|
||||
@@ -67,7 +65,7 @@ def _listing(output: str, status: re.Pattern[str], what: str) -> str:
|
||||
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)
|
||||
@@ -75,7 +73,7 @@ def parse_apt_upgradable(output: str) -> List[UpdateDict]:
|
||||
|
||||
:raises ValueError: when apt failed or its exit status never arrived.
|
||||
"""
|
||||
by_name: Dict[str, UpdateDict] = {}
|
||||
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:
|
||||
@@ -103,7 +101,7 @@ 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.
|
||||
@@ -111,7 +109,7 @@ def parse_dnf_security(output: str) -> Set[str]:
|
||||
:raises ValueError: when dnf failed or its exit status never arrived -- every
|
||||
update would otherwise read as not security.
|
||||
"""
|
||||
names: Set[str] = set()
|
||||
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."):
|
||||
|
||||
@@ -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).
|
||||
@@ -97,7 +96,7 @@ 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.
|
||||
@@ -123,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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -151,7 +151,12 @@ class PingSweepMixin:
|
||||
|
||||
success = reply.get("success") or {}
|
||||
if not isinstance(success, dict):
|
||||
return {"ip": destination, "alive": False, "rtt_ms": None, "error": "malformed ping reply"}
|
||||
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
|
||||
@@ -167,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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,7 +35,7 @@ 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
|
||||
@@ -49,12 +48,12 @@ def roles_of(driver_cls: type) -> List[Type[Any]]:
|
||||
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``.
|
||||
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- 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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 --
|
||||
@@ -36,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, Set, 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"),
|
||||
@@ -65,7 +67,7 @@ _HEADER = (
|
||||
|
||||
#: 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, ...]] = {
|
||||
_KEY_TYPE_ALGORITHMS: dict[str, tuple[str, ...]] = {
|
||||
"ED25519": ("ssh-ed25519",),
|
||||
"RSA": ("rsa-sha2-512", "rsa-sha2-256", "ssh-rsa"),
|
||||
"DSA": ("ssh-dss",),
|
||||
@@ -82,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:
|
||||
@@ -94,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:
|
||||
@@ -113,9 +115,9 @@ def parse_sshd_effective(text: str) -> Dict[str, List[str]]:
|
||||
return found
|
||||
|
||||
|
||||
def parse_sshd_hostkeys(text: str) -> List[str]:
|
||||
def parse_sshd_hostkeys(text: str) -> list[str]:
|
||||
"""The ``hostkey`` paths from ``sshd -T`` output, in order."""
|
||||
paths: List[str] = []
|
||||
paths: list[str] = []
|
||||
for raw in text.splitlines():
|
||||
parts = raw.strip().split(None, 1)
|
||||
if len(parts) == 2 and parts[0].lower() == "hostkey":
|
||||
@@ -123,12 +125,12 @@ def parse_sshd_hostkeys(text: str) -> List[str]:
|
||||
return paths
|
||||
|
||||
|
||||
def host_key_algorithms(fingerprints: str) -> Optional[Set[str]]:
|
||||
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()
|
||||
usable: set[str] = set()
|
||||
for raw in fingerprints.splitlines():
|
||||
parts = raw.split()
|
||||
if not parts:
|
||||
@@ -163,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)]
|
||||
|
||||
|
||||
@@ -177,8 +179,8 @@ def plan_ssh_hardening(
|
||||
effective: Mapping[str, Sequence[str]],
|
||||
remove: Mapping[str, Iterable[str]],
|
||||
*,
|
||||
client: Optional[Mapping[str, Iterable[str]]] = None,
|
||||
host_keys: Optional[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*.
|
||||
|
||||
@@ -199,10 +201,10 @@ 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, []))
|
||||
@@ -245,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]
|
||||
|
||||
@@ -268,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
|
||||
@@ -288,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()):
|
||||
@@ -312,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 = ""
|
||||
|
||||
|
||||
@@ -336,19 +338,19 @@ 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, mode_of: Optional[str] = None) -> 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
|
||||
@@ -367,18 +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(
|
||||
# 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'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)} && mv -f -- {quote(part)} {quote(path)};'
|
||||
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)}")
|
||||
@@ -389,7 +397,7 @@ 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.
|
||||
@@ -402,7 +410,7 @@ class OpenSshServerMixin:
|
||||
raise RuntimeError(f"sshd -T failed: {(result.stderr or result.stdout).strip()}")
|
||||
return result.stdout
|
||||
|
||||
def _ssh_host_key_algorithms(self, sshd_t: str) -> Optional[Set[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.
|
||||
@@ -418,7 +426,7 @@ class OpenSshServerMixin:
|
||||
return None
|
||||
return host_key_algorithms(result.stdout)
|
||||
|
||||
def _sshd_accepts(self) -> Optional[str]:
|
||||
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()
|
||||
@@ -429,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).
|
||||
|
||||
@@ -447,11 +455,13 @@ class OpenSshServerMixin:
|
||||
say = log or (lambda _line: None)
|
||||
say("Reading the algorithms sshd offers (sshd -T)")
|
||||
sshd_t = self._sshd_effective_text()
|
||||
host_keys: Optional[Set[str]] = None
|
||||
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)
|
||||
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",
|
||||
@@ -477,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):
|
||||
@@ -509,10 +523,10 @@ 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:
|
||||
|
||||
@@ -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)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""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
|
||||
@@ -32,19 +31,37 @@ _DMI = "/sys/class/dmi/id"
|
||||
#: 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)\""
|
||||
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",
|
||||
})
|
||||
_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*$")
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -51,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
|
||||
@@ -108,7 +108,7 @@ _AWK = " ".join(
|
||||
r" if (!ok(f)) return; seen[f] = 1;"
|
||||
r' while (0 < (r = (getline l < f))) { sub(/[#!].*/, "", l); gsub(/[{}]/, " & ", l);'
|
||||
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" 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);"
|
||||
@@ -157,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)
|
||||
@@ -170,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(" ")
|
||||
@@ -191,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:
|
||||
@@ -205,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
|
||||
@@ -217,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]:
|
||||
@@ -272,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)
|
||||
@@ -298,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
|
||||
@@ -326,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"]
|
||||
|
||||
@@ -346,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
|
||||
@@ -356,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")}
|
||||
|
||||
+30
-11
@@ -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.9.0"
|
||||
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,7 +28,6 @@ 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",
|
||||
@@ -45,16 +44,16 @@ dependencies = [
|
||||
]
|
||||
|
||||
[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]
|
||||
@@ -64,7 +63,27 @@ 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
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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"})
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
@@ -3,21 +3,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from napalm.base import NetworkDriver
|
||||
|
||||
from napalm_device_types import (
|
||||
AccessPointDriver,
|
||||
DeviceTypeDriver,
|
||||
FingerprintRule,
|
||||
FirewallDriver,
|
||||
rules_max_weight,
|
||||
HypervisorDriver,
|
||||
OSDriver,
|
||||
PortSpec,
|
||||
ResidentialGatewayDriver,
|
||||
StorageDriver,
|
||||
SwitchDriver,
|
||||
rules_max_weight,
|
||||
)
|
||||
from napalm.base import NetworkDriver
|
||||
|
||||
|
||||
# ── Hierarchy ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -49,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 == ""
|
||||
@@ -72,6 +73,7 @@ def test_fingerprint_attributes_inherited(cls):
|
||||
|
||||
# ── FingerprintRule ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_fingerprint_rule_defaults():
|
||||
r = FingerprintRule("fritz!box")
|
||||
assert r.pattern == "fritz!box"
|
||||
@@ -102,6 +104,7 @@ def test_fingerprint_rule_group():
|
||||
|
||||
# ── 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
|
||||
@@ -146,6 +149,7 @@ def test_fingerprint_rule_is_immutable():
|
||||
|
||||
# ── PortSpec ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_port_spec_defaults():
|
||||
p = PortSpec("https", 8006)
|
||||
assert p.scheme == "https"
|
||||
@@ -169,6 +173,7 @@ def test_port_spec_is_immutable():
|
||||
|
||||
# ── Concrete driver subclass ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_concrete_driver_overrides_fingerprint():
|
||||
class MyDriver(FirewallDriver):
|
||||
VENDOR = "Acme"
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -452,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]"),
|
||||
]
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -89,7 +89,7 @@ class TestParsing:
|
||||
],
|
||||
)
|
||||
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"))
|
||||
|
||||
@@ -188,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):
|
||||
|
||||
@@ -5,7 +5,6 @@ from napalm.base import NetworkDriver
|
||||
|
||||
from napalm_device_types import DeviceTypeDriver, PingSweepMixin, driver_supports_ping
|
||||
|
||||
|
||||
# ── Fakes ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -225,4 +224,9 @@ def test_ping_sweep_stops_when_stop_requested():
|
||||
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"}
|
||||
assert entry == {
|
||||
"ip": "192.0.2.1",
|
||||
"alive": False,
|
||||
"rtt_ms": None,
|
||||
"error": "malformed ping reply",
|
||||
}
|
||||
@@ -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():
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -9,6 +9,7 @@ sibling base.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_device_types import FirewallDriver
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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]
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
+79
-28
@@ -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
|
||||
|
||||
@@ -69,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"
|
||||
|
||||
|
||||
@@ -82,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"]
|
||||
@@ -113,20 +118,38 @@ 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"]
|
||||
@@ -171,12 +194,18 @@ def test_plan_removes_a_host_key_algorithm_when_a_usable_one_stays():
|
||||
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")]
|
||||
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",
|
||||
"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
|
||||
@@ -204,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("#"))
|
||||
@@ -227,28 +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",
|
||||
sshd_t: str = SSHD_T) -> None:
|
||||
self.files: Dict[str, str] = {MAIN_CONFIG: main}
|
||||
self.modes: Dict[str, str] = {MAIN_CONFIG: "644"}
|
||||
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.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()
|
||||
@@ -269,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
|
||||
@@ -294,14 +339,20 @@ class FakeHost(OpenSshServerMixin):
|
||||
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)
|
||||
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 "))
|
||||
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)
|
||||
@@ -323,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
|
||||
@@ -439,7 +490,7 @@ def test_mixin_reports_a_failed_way_back_instead_of_raising():
|
||||
|
||||
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] = []
|
||||
lines: list[str] = []
|
||||
result = host.harden_ssh_server(
|
||||
{"hostkey": ["ssh-ed25519", "rsa-sha2-256", "rsa-sha2-512"]}, log=lines.append
|
||||
)
|
||||
|
||||
@@ -38,8 +38,20 @@ def test_a_supermicro_placeholder_serial_is_dropped():
|
||||
|
||||
@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"],
|
||||
[
|
||||
"",
|
||||
"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)
|
||||
@@ -47,9 +59,12 @@ def test_placeholders(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"],
|
||||
[
|
||||
"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"] == ""
|
||||
@@ -63,7 +78,10 @@ def test_real_values_are_not_placeholders():
|
||||
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"}
|
||||
"system_uuid": "",
|
||||
"product_serial": "",
|
||||
"board_serial": "BS1",
|
||||
}
|
||||
|
||||
|
||||
def test_output_without_its_frame_is_not_a_reading():
|
||||
|
||||
@@ -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)
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests for TYPE_LABEL class attribute on all base driver classes."""
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_device_types import (
|
||||
AccessPointDriver,
|
||||
FirewallDriver,
|
||||
@@ -12,7 +13,6 @@ from napalm_device_types import (
|
||||
SwitchDriver,
|
||||
)
|
||||
|
||||
|
||||
BASE_CLASSES = [
|
||||
(AccessPointDriver, "Access Point"),
|
||||
(FirewallDriver, "Firewall"),
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Dict, Optional
|
||||
|
||||
from napalm_device_types._ucd_metrics import (
|
||||
OID_IF_DESCR,
|
||||
@@ -24,11 +23,11 @@ LEGACY = {
|
||||
}
|
||||
|
||||
|
||||
def _collect(tables: Dict[str, Dict[str, str]]) -> dict:
|
||||
async def get(oid: str) -> Optional[str]:
|
||||
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]:
|
||||
async def walk(oid: str) -> dict[str, str]:
|
||||
return tables.get(oid, {})
|
||||
|
||||
return asyncio.run(collect_ucd_metrics(get, walk))
|
||||
@@ -44,7 +43,11 @@ def test_the_64_bit_counters_and_high_speed_win():
|
||||
|
||||
eth0 = _collect(tables)["interfaces"]["eth0"]
|
||||
|
||||
assert (eth0["rx_bytes"], eth0["tx_bytes"], eth0["speed_mbps"]) == (8589934592, 17179869184, 10000)
|
||||
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():
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -496,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"),
|
||||
@@ -593,8 +593,10 @@ class TestTheCommandOnAHost:
|
||||
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"]] == [
|
||||
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"),
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
from typing import get_type_hints
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_device_types.models import (
|
||||
VMConfigDict,
|
||||
VMDict,
|
||||
|
||||
Reference in new issue
Block a user