get_port_forwards was declared on ResidentialGatewayDriver alone, as if a port forward were a home-router feature. A firewall forwards ports just the same (OPNsense calls it destination NAT), and netOrk asks both: is this host reachable from the internet, which CVEs are exposed. The declaration moves to NatVpnMixin, where the two roles already overlap, and PortForwardDict next to NATTranslationDict. The contract now says what counts. Destination NAT between internal networks and rules that only exempt traffic are not port forwards: callers read every entry as "reachable from outside". "ANY" forwards every protocol and an external port of 0 every port -- a whole host forwarded is the most exposed case and must not fall out for lack of a port number. Declaration only, under TYPE_CHECKING: nothing changes at runtime.
333 lines
12 KiB
Markdown
333 lines
12 KiB
Markdown
# napalm-device-types
|
||
|
||
Abstract intermediate device-type base classes for [NAPALM](https://napalm.readthedocs.io/) drivers.
|
||
|
||
Instead of inheriting directly from `napalm.base.NetworkDriver`, a driver can inherit from one of the device-type classes here to gain a richer, type-specific contract:
|
||
|
||
```python
|
||
# without napalm-device-types
|
||
class OpenWrtDriver(NetworkDriver):
|
||
...
|
||
|
||
# with napalm-device-types
|
||
from napalm_device_types import AccessPointDriver
|
||
|
||
class OpenWrtDriver(AccessPointDriver):
|
||
...
|
||
```
|
||
|
||
## Why?
|
||
|
||
NAPALM's `NetworkDriver` defines a common interface for all network devices. In practice, devices fall into distinct categories with very different capabilities. A switch exposes spanning-tree and PoE data; a firewall exposes NAT tables and VPN tunnels; a NAS exposes disk pools and shares. Writing these methods directly in a concrete driver mixes concerns and makes drivers harder to discover and compare.
|
||
|
||
`napalm-device-types` sits in between: it adds one well-typed layer of abstract methods per device category, so every driver for the same category exposes the same interface.
|
||
|
||
## Roles: what a device *is*
|
||
|
||
A device is often several things at once. A QNAP NAS runs VMs on a Linux userland; an
|
||
OpenMediaVault box is a NAS built on Debian. So a driver inherits **one role base per
|
||
role its device fills**, and **the order it lists them in is the ranking**:
|
||
|
||
```python
|
||
class QnapQtsDriver(StorageDriver, HypervisorDriver, LinuxDriver):
|
||
... # primary_role_of(...) == "storage"
|
||
```
|
||
|
||
`roles_of(cls)`, `role_keys_of(cls)` and `primary_role_of(cls)` read that back. Nothing
|
||
restates the ranking: there is no precedence table and no attribute to override.
|
||
|
||
### Role bases declare; they never implement
|
||
|
||
**A role base must not contain a single runtime method — not even a
|
||
`NotImplementedError` placeholder.** Its methods are declared under `if TYPE_CHECKING`:
|
||
|
||
```python
|
||
class StorageDriver(DeviceTypeDriver):
|
||
"""Contract, not code."""
|
||
ROLE: str = "storage"
|
||
TYPE_LABEL: str = "Storage"
|
||
|
||
if TYPE_CHECKING: # nothing exists at runtime
|
||
def get_disks(self) -> List[PhysicalDiskDict]: ...
|
||
```
|
||
|
||
This is not a style preference. A placeholder on a base class is not neutral under
|
||
multiple inheritance: it wins the MRO against a sibling base's *working* implementation
|
||
and silently replaces it. Adding one stub to a base is therefore a breaking change for
|
||
every driver that mixes that base with another. It happened three times here before the
|
||
rule existed, and each time the fix was hand-written forwarding methods in the driver.
|
||
|
||
Two things follow, and both are improvements:
|
||
|
||
- **`hasattr` is truthful again.** A method exists on a driver class exactly when that
|
||
driver implemented it, which is how netOrk asks "can this driver list disks".
|
||
- **A method that was never implemented raises `AttributeError`, not
|
||
`NotImplementedError`.** Ask before calling.
|
||
|
||
## Function classes: what a device *can do*
|
||
|
||
Behaviour shared across roles lives in a function class, exactly once, and a role base
|
||
is a thin bundle over them — `PackageManagementMixin`, `HealthMetricsMixin`,
|
||
`ServiceControlMixin`, `UpdateMixin`, `NatVpnMixin`, `MacAclMixin`, `FirewallRuleMixin`,
|
||
`DhcpServerMixin`, `PingSweepMixin`, `ConfigLifecycleMixin`, `InterfaceFilterMixin`.
|
||
`HostRebootMixin` (`reboot_host`) is mixed into `DeviceTypeDriver` itself, since any
|
||
device may be restartable; like the others it only declares.
|
||
|
||
A function class may use the **template form** — public method concrete, the
|
||
device-specific part a `_hook` declared under `if TYPE_CHECKING` — *when the base
|
||
genuinely does work* on the result: normalising, sorting, validating, or orchestrating
|
||
several hooks. `ConfigLifecycleMixin.compare_config` over `_get_running_config` is the
|
||
model. Where the base would only pass the call through, declare the method directly;
|
||
two names for one pass-through is ceremony, not design.
|
||
|
||
NAPALM's own getters (`get_facts`, `get_interfaces`, `ping`, `get_config`) are never
|
||
wrapped in a template — they belong to NAPALM, and code outside this repo relies on
|
||
their contract.
|
||
|
||
## Design principle: generic vs. device-specific logic
|
||
|
||
When adding behavior to a device-type base class, split it along one line: **would
|
||
this exact logic work unchanged for a different vendor's driver of the same
|
||
device-type, if that driver only implemented the same abstract methods?**
|
||
|
||
- If yes, it's generic — implement it once as a **concrete** method on the
|
||
device-type base class (here, in this repo).
|
||
- If no — it talks to the device itself (a specific REST endpoint, a CLI command,
|
||
a vendor-specific payload format) — it belongs in the concrete driver as the
|
||
implementation of an **abstract** method the base class declares.
|
||
|
||
Concretely: matching/comparison/reconciliation algorithms, orchestration flows, and
|
||
generic data shapes belong here. Only the actual device communication belongs in
|
||
`vendor/napalm-<name>`.
|
||
|
||
**Worked example — firewall rule diff/apply** (`FirewallDriver`):
|
||
|
||
```python
|
||
class FirewallDriver(DeviceTypeDriver):
|
||
# Abstract — every driver implements its own device communication.
|
||
def get_firewall_rules(self) -> List[FirewallRuleDict]: raise NotImplementedError
|
||
def apply_firewall_rule(self, rule: FirewallRuleDict, *, uuid: Optional[str] = None) -> Dict[str, Any]: raise NotImplementedError
|
||
def commit_firewall_rules(self) -> Dict[str, Any]: raise NotImplementedError
|
||
|
||
# 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 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`/
|
||
`apply_dhcp_reservation`/`commit_dhcp_reservations` are abstract (Kea REST on
|
||
OPNsense, dnsmasq/odhcpd UCI on OpenWrt), while `diff_dhcp_reservations` and
|
||
`apply_dhcp_reservationset` are concrete — matching by normalised MAC and the
|
||
apply-then-commit orchestration are identical for every DHCP server.
|
||
|
||
A new driver (FortiGate, pfSense, …) gets `diff_firewall_rules`/
|
||
`apply_firewall_ruleset` for free the moment it implements the three abstract
|
||
methods — it never needs to reimplement the reconciliation logic itself.
|
||
|
||
**Second worked example — ping sweeps** (`PingSweepMixin`, mixed into
|
||
`DeviceTypeDriver`, so *every* device-type driver has it):
|
||
|
||
```python
|
||
class PingSweepMixin:
|
||
# Concrete — the loop, the reply parsing, the target cap and the progress
|
||
# reporting are the same for every device that can ping at all.
|
||
def ping_sweep(self, destinations, *, count=1, timeout=1, …) -> PingSweepResultDict:
|
||
... # calls NAPALM's standard ping() once per destination
|
||
```
|
||
|
||
A driver becomes a usable sweep source the moment it implements NAPALM's
|
||
`ping()` — nothing else is required, and `driver_supports_ping(cls)` reports
|
||
whether it did (introspection, not a hand-maintained list). A driver whose
|
||
device offers something genuinely faster overrides `ping_sweep` and keeps the
|
||
return shape: `napalm-opnsense` starts a batch of ping jobs over the
|
||
diagnostics API, waits once for all of them, and reads every result with a
|
||
single request — a per-host loop would be unusable there.
|
||
|
||
This mirrors a similar split already documented on the consumer side, in NetOrk's
|
||
`docs/ARCHITECTURE.md` ("Device Warnings — Trennung von Erkennung und
|
||
Präsentation"): drivers return raw signals, the higher layer gives them meaning.
|
||
Same shape of separation, different axis — device-specific vs. generic here,
|
||
detection vs. presentation there.
|
||
|
||
## Installation
|
||
|
||
```bash
|
||
pip install napalm-device-types
|
||
```
|
||
|
||
Requires Python ≥ 3.9 and NAPALM ≥ 4.0.
|
||
|
||
## Available base classes
|
||
|
||
| Class | Target devices | Example implementations |
|
||
|---|---|---|
|
||
| `AccessPointDriver` | Wireless access points | OpenWrt, Ubiquiti UniFi, Cisco Meraki AP |
|
||
| `SwitchDriver` | Ethernet switches | Cisco IOS, Arista EOS, Juniper EX |
|
||
| `FirewallDriver` | Firewalls & UTM appliances | pfSense, Fortinet FortiOS, Cisco ASA |
|
||
| `HypervisorDriver` | Hypervisors & virtualisation platforms | Proxmox VE, VMware ESXi, KVM/libvirt |
|
||
| `OSDriver` | General-purpose operating systems | Linux, BSD, macOS |
|
||
| `StorageDriver` | Storage appliances & NAS/SAN | TrueNAS, Synology DSM, QNAP QTS |
|
||
| `ResidentialGatewayDriver` | Router + firewall + AP in one box | OpenWrt, FritzBox |
|
||
|
||
Mixins mixed into the classes above rather than used on their own:
|
||
`ConfigLifecycleMixin` (config load/compare/commit/rollback), `PingSweepMixin`
|
||
(subnet sweeps), and `DhcpServerMixin` (static DHCP reservations — mixed into
|
||
`FirewallDriver` and `ResidentialGatewayDriver`, since both commonly run the
|
||
DHCP server for their networks).
|
||
|
||
## Usage
|
||
|
||
### Access Point
|
||
|
||
```python
|
||
from napalm_device_types import AccessPointDriver
|
||
|
||
class OpenWrtDriver(AccessPointDriver):
|
||
|
||
def get_wireless_clients(self):
|
||
# return List[WirelessClientDict]
|
||
...
|
||
|
||
def get_ssids(self):
|
||
# return Dict[str, SSIDDict]
|
||
...
|
||
```
|
||
|
||
### Switch
|
||
|
||
```python
|
||
from napalm_device_types import SwitchDriver
|
||
|
||
class CiscoIOSDriver(SwitchDriver):
|
||
|
||
def get_spanning_tree(self):
|
||
# return Dict[str, SpanningTreeDict]
|
||
...
|
||
|
||
def get_poe_status(self):
|
||
# return PoESummaryDict
|
||
...
|
||
```
|
||
|
||
### Firewall
|
||
|
||
```python
|
||
from napalm_device_types import FirewallDriver
|
||
|
||
class PfSenseDriver(FirewallDriver):
|
||
|
||
def get_nat_translations(self):
|
||
# return List[NATTranslationDict]
|
||
...
|
||
|
||
def get_vpn_tunnels(self):
|
||
# return Dict[str, VPNTunnelDict]
|
||
...
|
||
|
||
def get_port_forwards(self):
|
||
# return List[PortForwardDict] — forwards from the WAN only, never a
|
||
# redirect between internal networks (shared with home gateways)
|
||
...
|
||
```
|
||
|
||
### Hypervisor
|
||
|
||
```python
|
||
from napalm_device_types import HypervisorDriver
|
||
|
||
class ProxmoxDriver(HypervisorDriver):
|
||
|
||
def get_vms(self):
|
||
# return List[VMDict]
|
||
...
|
||
|
||
def create_vm_snapshot(self, name, snapshot, description="", include_memory=False):
|
||
...
|
||
```
|
||
|
||
### OS / Linux
|
||
|
||
```python
|
||
from napalm_device_types import OSDriver
|
||
|
||
class LinuxDriver(OSDriver):
|
||
|
||
def get_packages(self):
|
||
# return List[PackageDict]
|
||
...
|
||
|
||
def get_services(self):
|
||
# return List[ServiceDict]
|
||
...
|
||
|
||
def get_users(self):
|
||
# return List[UserDict]
|
||
...
|
||
|
||
def get_processes(self):
|
||
# return List[ProcessDict]
|
||
...
|
||
|
||
def get_cron_jobs(self):
|
||
# return List[CronJobDict]
|
||
...
|
||
```
|
||
|
||
### Storage / NAS
|
||
|
||
```python
|
||
from napalm_device_types import StorageDriver
|
||
|
||
class TrueNASDriver(StorageDriver):
|
||
|
||
def get_disks(self):
|
||
# return List[PhysicalDiskDict]
|
||
...
|
||
|
||
def get_shares(self):
|
||
# return Dict[str, NASShareDict]
|
||
...
|
||
```
|
||
|
||
## Return types
|
||
|
||
All return types are `TypedDict` classes defined in `napalm_device_types.models`. Import them directly for type annotations in your driver:
|
||
|
||
```python
|
||
from napalm_device_types.models import (
|
||
PhysicalDiskDict,
|
||
DiskPoolDict,
|
||
NASShareDict,
|
||
VolumeSnapshotDict,
|
||
)
|
||
```
|
||
|
||
## Development
|
||
|
||
```bash
|
||
git clone https://github.com/chrismanivong/napalm-device-types.git
|
||
cd napalm-device-types
|
||
pip install -e ".[dev]"
|
||
```
|
||
|
||
Run type-checking:
|
||
|
||
```bash
|
||
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
|
||
|
||
## License
|
||
|
||
Apache-2.0 – see [LICENSE](LICENSE) for details.
|