From 0f6734e6dae2ca2fe526da03f4e96fffc172c1a6 Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Wed, 24 Jun 2026 14:29:41 +0200 Subject: [PATCH] feat: DeviceTypeDriver base class mit FingerprintRule und PortSpec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neue Zwischenschicht zwischen NetworkDriver und den typ-spezifischen Basisklassen (FirewallDriver, SwitchDriver, …). Definiert das Fingerprinting-Interface für den Discovery-Subsystem: - FingerprintRule (NamedTuple): pattern, weight, mandatory, negative - PortSpec (NamedTuple): scheme, port, paths, weight, mandatory - DeviceTypeDriver: VENDOR, DRIVER_NAME, PORT_SPECS, SNMP_OBJECT_ID_PREFIX, SNMP_FINGERPRINT, SSH_FINGERPRINT, HTTP_FINGERPRINT Alle *Driver-Klassen erben jetzt von DeviceTypeDriver statt NetworkDriver. Transitiv ist NetworkDriver weiterhin in der MRO (keine Breaking Change). Co-Authored-By: Claude Sonnet 4.6 --- napalm_device_types/__init__.py | 4 + napalm_device_types/access_point.py | 4 +- napalm_device_types/base.py | 77 +++++++++++ napalm_device_types/firewall.py | 4 +- napalm_device_types/hypervisor.py | 4 +- napalm_device_types/os.py | 4 +- napalm_device_types/residential_gateway.py | 4 +- napalm_device_types/storage.py | 4 +- napalm_device_types/switch.py | 4 +- tests/test_fingerprint_base.py | 145 +++++++++++++++++++++ 10 files changed, 240 insertions(+), 14 deletions(-) create mode 100644 napalm_device_types/base.py create mode 100644 tests/test_fingerprint_base.py diff --git a/napalm_device_types/__init__.py b/napalm_device_types/__init__.py index 3a8fb37..c717fb7 100644 --- a/napalm_device_types/__init__.py +++ b/napalm_device_types/__init__.py @@ -30,6 +30,7 @@ Also provided: drivers. """ +from napalm_device_types.base import DeviceTypeDriver, FingerprintRule, PortSpec from napalm_device_types.access_point import AccessPointDriver from napalm_device_types.config_lifecycle import ConfigLifecycleMixin from napalm_device_types.firewall import FirewallDriver @@ -42,9 +43,12 @@ from napalm_device_types.switch import SwitchDriver __all__ = [ "AccessPointDriver", "ConfigLifecycleMixin", + "DeviceTypeDriver", + "FingerprintRule", "FirewallDriver", "HypervisorDriver", "OSDriver", + "PortSpec", "ResidentialGatewayDriver", "StorageDriver", "SwitchDriver", diff --git a/napalm_device_types/access_point.py b/napalm_device_types/access_point.py index ab338b8..ac0c8b8 100644 --- a/napalm_device_types/access_point.py +++ b/napalm_device_types/access_point.py @@ -11,7 +11,7 @@ Usage:: """ from typing import Any, Dict, List -from napalm.base import NetworkDriver +from napalm_device_types.base import DeviceTypeDriver from napalm_device_types._ucd_metrics import IF_SKIP_DEFAULT, collect_ucd_metrics from napalm_device_types.models import ( Dot1XConfigDict, @@ -31,7 +31,7 @@ from napalm_device_types.models import ( ) -class AccessPointDriver(NetworkDriver): +class AccessPointDriver(DeviceTypeDriver): TYPE_LABEL: str = "Access Point" """ Abstract intermediate driver for wireless access points. diff --git a/napalm_device_types/base.py b/napalm_device_types/base.py new file mode 100644 index 0000000..ffeff66 --- /dev/null +++ b/napalm_device_types/base.py @@ -0,0 +1,77 @@ +""" +DeviceTypeDriver — common base for all netOrk device-type drivers. + +Provides the fingerprinting contract used by the discovery subsystem to +identify devices from SNMP / SSH / HTTP probe data without requiring a +live connection. +""" + +from __future__ import annotations + +from typing import NamedTuple + +from napalm.base import NetworkDriver + + +class FingerprintRule(NamedTuple): + """Single pattern-matching rule for device fingerprinting. + + pattern — substring matched against lowercased probe data + weight — evidence added to the driver's score on match + mandatory — pattern absent → driver immediately disqualified + negative — pattern present → weight subtracted instead of added + """ + + pattern: str + weight: float = 1.0 + mandatory: bool = False + negative: bool = False + + +class PortSpec(NamedTuple): + """Port to probe during discovery and its fingerprinting contribution. + + scheme — "http", "https", or "ssh" + port — TCP port number + paths — URL paths to try (http/https only; ignored for ssh) + weight — evidence added to score when this port responds + mandatory — port silent → driver immediately disqualified + """ + + scheme: str + port: int + paths: tuple[str, ...] = ("/",) + weight: float = 5.0 + mandatory: bool = False + + +class DeviceTypeDriver(NetworkDriver): + """Common base for all netOrk device-type drivers. + + Sits between napalm.base.NetworkDriver and the type-specific abstract + classes (FirewallDriver, SwitchDriver, …). Adds the fingerprinting + interface consumed by the discovery subsystem; does not implement any + NAPALM abstract methods. + + Override these class attributes in each concrete driver: + + VENDOR Human-readable vendor name ("AVM", "OPNsense", …). + DRIVER_NAME NAPALM entry-point key ("fritzbox", "opnsense", …). + PORT_SPECS Non-standard ports to probe beyond 80/443/22. + None = no driver-specific ports expected. + SNMP_OBJECT_ID_PREFIX OID prefix for sysObjectID (1.3.6.1.2.1.1.2.0). + Match → +15.0 score; set but no match → disqualified. + SNMP_FINGERPRINT Rules matched against sysDescr (lowercased). + SSH_FINGERPRINT Rules matched against SSH version string + + pre-auth banner (lowercased, space-joined). + HTTP_FINGERPRINT Rules matched against page title + Server header + + body snippet (lowercased, whitespace-normalised). + """ + + VENDOR: str = "" + DRIVER_NAME: str = "" + PORT_SPECS: list[PortSpec] | None = None + SNMP_OBJECT_ID_PREFIX: str | None = None + SNMP_FINGERPRINT: list[FingerprintRule] = [] + SSH_FINGERPRINT: list[FingerprintRule] = [] + HTTP_FINGERPRINT: list[FingerprintRule] = [] diff --git a/napalm_device_types/firewall.py b/napalm_device_types/firewall.py index a251b1c..e6a8552 100644 --- a/napalm_device_types/firewall.py +++ b/napalm_device_types/firewall.py @@ -11,7 +11,7 @@ Usage:: """ from typing import Any, Dict, List -from napalm.base import NetworkDriver +from napalm_device_types.base import DeviceTypeDriver from napalm_device_types._ucd_metrics import IF_SKIP_DEFAULT, collect_ucd_metrics from napalm_device_types.models import ( HealthMetricsDict, @@ -23,7 +23,7 @@ from napalm_device_types.models import ( ) -class FirewallDriver(NetworkDriver): +class FirewallDriver(DeviceTypeDriver): TYPE_LABEL: str = "Firewall" """ Abstract intermediate driver for firewall/security devices. diff --git a/napalm_device_types/hypervisor.py b/napalm_device_types/hypervisor.py index 024844a..6979471 100644 --- a/napalm_device_types/hypervisor.py +++ b/napalm_device_types/hypervisor.py @@ -11,7 +11,7 @@ Usage:: """ from typing import Any, Dict, List -from napalm.base import NetworkDriver +from napalm_device_types.base import DeviceTypeDriver from napalm_device_types._ucd_metrics import IF_SKIP_DEFAULT, collect_ucd_metrics from napalm_device_types.models import ( HealthMetricsDict, @@ -24,7 +24,7 @@ from napalm_device_types.models import ( ) -class HypervisorDriver(NetworkDriver): +class HypervisorDriver(DeviceTypeDriver): TYPE_LABEL: str = "Hypervisor" """ Abstract intermediate driver for hypervisors and virtualisation platforms diff --git a/napalm_device_types/os.py b/napalm_device_types/os.py index ca3102d..ef01ca8 100644 --- a/napalm_device_types/os.py +++ b/napalm_device_types/os.py @@ -12,7 +12,7 @@ Usage:: import re from typing import List, Optional -from napalm.base import NetworkDriver +from napalm_device_types.base import DeviceTypeDriver from napalm_device_types._ucd_metrics import IF_SKIP_DEFAULT, collect_ucd_metrics from napalm_device_types.models import ( ApplyUpdatesResultDict, @@ -29,7 +29,7 @@ from napalm_device_types.models import ( ) -class OSDriver(NetworkDriver): +class OSDriver(DeviceTypeDriver): TYPE_LABEL: str = "OS" """ Abstract intermediate driver for general-purpose operating systems diff --git a/napalm_device_types/residential_gateway.py b/napalm_device_types/residential_gateway.py index e27b3ef..ff42431 100644 --- a/napalm_device_types/residential_gateway.py +++ b/napalm_device_types/residential_gateway.py @@ -19,7 +19,7 @@ Usage:: """ from typing import Dict, List -from napalm.base import NetworkDriver +from napalm_device_types.base import DeviceTypeDriver from napalm_device_types._ucd_metrics import IF_SKIP_DEFAULT, collect_ucd_metrics from napalm_device_types.models import ( HealthMetricsDict, @@ -34,7 +34,7 @@ from napalm_device_types.models import ( ) -class ResidentialGatewayDriver(NetworkDriver): +class ResidentialGatewayDriver(DeviceTypeDriver): TYPE_LABEL: str = "Gateway" """ Abstract intermediate driver for residential gateways (router + firewall + AP). diff --git a/napalm_device_types/storage.py b/napalm_device_types/storage.py index 36c7887..a3e2ec3 100644 --- a/napalm_device_types/storage.py +++ b/napalm_device_types/storage.py @@ -11,7 +11,7 @@ Usage:: """ from typing import Any, Dict, List -from napalm.base import NetworkDriver +from napalm_device_types.base import DeviceTypeDriver from napalm_device_types.models import ( DiskPoolDict, LogicalVolumeDict, @@ -25,7 +25,7 @@ from napalm_device_types.models import ( ) -class StorageDriver(NetworkDriver): +class StorageDriver(DeviceTypeDriver): TYPE_LABEL: str = "Storage" """ Abstract intermediate driver for storage appliances and NAS/SAN devices diff --git a/napalm_device_types/switch.py b/napalm_device_types/switch.py index a5d0659..a1e605d 100644 --- a/napalm_device_types/switch.py +++ b/napalm_device_types/switch.py @@ -11,7 +11,7 @@ Usage:: """ from typing import Dict, List -from napalm.base import NetworkDriver +from napalm_device_types.base import DeviceTypeDriver from napalm_device_types.models import ( Dot1XPortDict, HealthMetricsDict, @@ -24,7 +24,7 @@ from napalm_device_types.models import ( ) -class SwitchDriver(NetworkDriver): +class SwitchDriver(DeviceTypeDriver): TYPE_LABEL: str = "Switch" """ Abstract intermediate driver for Ethernet switches. diff --git a/tests/test_fingerprint_base.py b/tests/test_fingerprint_base.py new file mode 100644 index 0000000..c060307 --- /dev/null +++ b/tests/test_fingerprint_base.py @@ -0,0 +1,145 @@ +"""Tests for DeviceTypeDriver, FingerprintRule, and PortSpec.""" + +from __future__ import annotations + +import pytest +from napalm_device_types import ( + AccessPointDriver, + DeviceTypeDriver, + FingerprintRule, + FirewallDriver, + HypervisorDriver, + OSDriver, + PortSpec, + ResidentialGatewayDriver, + StorageDriver, + SwitchDriver, +) +from napalm.base import NetworkDriver + + +# ── Hierarchy ───────────────────────────────────────────────────────────────── + +TYPE_DRIVERS = [ + AccessPointDriver, + FirewallDriver, + HypervisorDriver, + OSDriver, + ResidentialGatewayDriver, + StorageDriver, + SwitchDriver, +] + + +@pytest.mark.parametrize("cls", TYPE_DRIVERS) +def test_type_driver_inherits_device_type_driver(cls): + assert issubclass(cls, DeviceTypeDriver) + + +@pytest.mark.parametrize("cls", TYPE_DRIVERS) +def test_type_driver_inherits_network_driver(cls): + """Transitiv: DeviceTypeDriver → NetworkDriver muss erhalten bleiben.""" + assert issubclass(cls, NetworkDriver) + + +def test_device_type_driver_inherits_network_driver(): + assert issubclass(DeviceTypeDriver, NetworkDriver) + + +# ── Default attributes ──────────────────────────────────────────────────────── + +def test_defaults_on_device_type_driver(): + assert DeviceTypeDriver.VENDOR == "" + assert DeviceTypeDriver.DRIVER_NAME == "" + assert DeviceTypeDriver.PORT_SPECS is None + assert DeviceTypeDriver.SNMP_OBJECT_ID_PREFIX is None + assert DeviceTypeDriver.SNMP_FINGERPRINT == [] + assert DeviceTypeDriver.SSH_FINGERPRINT == [] + assert DeviceTypeDriver.HTTP_FINGERPRINT == [] + + +@pytest.mark.parametrize("cls", TYPE_DRIVERS) +def test_fingerprint_attributes_inherited(cls): + assert hasattr(cls, "HTTP_FINGERPRINT") + assert hasattr(cls, "SNMP_FINGERPRINT") + assert hasattr(cls, "SSH_FINGERPRINT") + assert hasattr(cls, "PORT_SPECS") + assert hasattr(cls, "SNMP_OBJECT_ID_PREFIX") + + +# ── FingerprintRule ─────────────────────────────────────────────────────────── + +def test_fingerprint_rule_defaults(): + r = FingerprintRule("fritz!box") + assert r.pattern == "fritz!box" + assert r.weight == 1.0 + assert r.mandatory is False + assert r.negative is False + + +def test_fingerprint_rule_mandatory(): + r = FingerprintRule("opnsense", weight=8.0, mandatory=True) + assert r.mandatory is True + assert r.weight == 8.0 + + +def test_fingerprint_rule_negative(): + r = FingerprintRule("pfsense", weight=5.0, negative=True) + assert r.negative is True + + +def test_fingerprint_rule_is_immutable(): + r = FingerprintRule("test", weight=3.0) + with pytest.raises(AttributeError): + r.weight = 99.0 # type: ignore[misc] + + +# ── PortSpec ────────────────────────────────────────────────────────────────── + +def test_port_spec_defaults(): + p = PortSpec("https", 8006) + assert p.scheme == "https" + assert p.port == 8006 + assert p.paths == ("/",) + assert p.weight == 5.0 + assert p.mandatory is False + + +def test_port_spec_mandatory(): + p = PortSpec("http", 1400, ("/xml/device_description.xml",), weight=9.0, mandatory=True) + assert p.mandatory is True + assert p.paths == ("/xml/device_description.xml",) + + +def test_port_spec_is_immutable(): + p = PortSpec("http", 80) + with pytest.raises(AttributeError): + p.port = 8080 # type: ignore[misc] + + +# ── Concrete driver subclass ────────────────────────────────────────────────── + +def test_concrete_driver_overrides_fingerprint(): + class MyDriver(FirewallDriver): + VENDOR = "Acme" + DRIVER_NAME = "acme" + HTTP_FINGERPRINT = [FingerprintRule("acme portal", weight=9.0, mandatory=True)] + PORT_SPECS = [PortSpec("https", 9443, weight=7.0)] + + assert MyDriver.VENDOR == "Acme" + assert MyDriver.HTTP_FINGERPRINT[0].pattern == "acme portal" + assert MyDriver.PORT_SPECS[0].port == 9443 + # base class unaffected + assert FirewallDriver.HTTP_FINGERPRINT == [] + assert FirewallDriver.PORT_SPECS is None + + +def test_type_label_unaffected_by_refactor(): + """TYPE_LABEL muss nach dem Refactoring noch korrekt sein.""" + assert FirewallDriver.TYPE_LABEL == "Firewall" + assert SwitchDriver.TYPE_LABEL == "Switch" + assert ResidentialGatewayDriver.TYPE_LABEL == "Gateway" + assert HypervisorDriver.TYPE_LABEL == "Hypervisor" + assert OSDriver.TYPE_LABEL == "OS" + assert StorageDriver.TYPE_LABEL == "Storage" + assert AccessPointDriver.TYPE_LABEL == "Access Point"