feat: Fingerprint-Attribute für Discovery-Scoring

Ergänzt DRIVER_NAME, HTTP_FINGERPRINT, SNMP_FINGERPRINT, SSH_FINGERPRINT,
PORT_SPECS und SNMP_OBJECT_ID_PREFIX gemäß docs/DISCOVERY_FINGERPRINTING.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Christian Manivong
2026-06-24 14:47:08 +02:00
co-authored by Claude Sonnet 4.6
parent aef5eb1487
commit 36a239692b
2 changed files with 86 additions and 133 deletions
+61 -47
View File
@@ -20,11 +20,13 @@ These switches use password-only web authentication (no username).
The ``username`` parameter is accepted but ignored.
"""
from __future__ import annotations
import threading
import time
from typing import Any, Dict, List, Optional, Tuple
from typing import Any
from napalm_device_types import SwitchDriver
from napalm_device_types import FingerprintRule, SwitchDriver
from napalm.base.exceptions import ConnectionException
# ---------------------------------------------------------------------------
@@ -38,7 +40,7 @@ _FW_CDN_BASE = "https://www.downloads.netgear.com/files/GDC"
# Known-latest firmware versions per CDN model ID.
# Verified via HEAD requests against downloads.netgear.com on 2026-05-24.
# Update this table when Netgear releases new firmware.
_FW_KNOWN_LATEST: Dict[str, str] = {
_FW_KNOWN_LATEST: dict[str, str] = {
"GS305EP": "V2.0.0.10",
"GS305EPP": "V2.0.0.10",
"GS308EP": "V2.0.0.11",
@@ -46,14 +48,14 @@ _FW_KNOWN_LATEST: Dict[str, str] = {
}
# In-memory cache: model_id -> (latest_version, unix_timestamp_of_check)
_fw_cache: Dict[str, Tuple[str, float]] = {}
_fw_cache: dict[str, tuple[str, float]] = {}
_fw_cache_lock = threading.Lock()
# How long (seconds) to reuse a cached firmware-version result (24 hours).
_FW_CACHE_TTL = 86400
def _fw_version_tuple(version: str) -> Tuple[int, ...]:
def _fw_version_tuple(version: str) -> tuple[int, ...]:
"""Parse 'V1.0.1.4' or '1.0.1.4' into (1, 0, 1, 4). Raises ValueError on bad input."""
v = version.strip().lstrip("Vv")
return tuple(int(x) for x in v.split("."))
@@ -94,7 +96,7 @@ def _fw_probe_newer(model: str, baseline: str) -> str:
best_t = base_t
best_v = baseline
def _try(candidate: Tuple[int, ...]) -> None:
def _try(candidate: tuple[int, ...]) -> None:
nonlocal best_t, best_v
v_str = "V" + ".".join(str(x) for x in candidate)
if _fw_cdn_exists(model, v_str) and candidate > best_t:
@@ -114,7 +116,7 @@ def _fw_probe_newer(model: str, baseline: str) -> str:
return best_v
def _fw_get_latest(model: str) -> Optional[str]:
def _fw_get_latest(model: str) -> str | None:
"""Return the latest known firmware version for *model*, using a 24-hour
in-memory cache. Returns ``None`` when the model is not in the known table.
"""
@@ -146,6 +148,18 @@ class NetgearPlusDriver(SwitchDriver):
"""HTTP-based NAPALM driver for Netgear Plus Smart Managed switches."""
VENDOR = "Netgear"
DRIVER_NAME = "netgear_plus"
SNMP_OBJECT_ID_PREFIX = "1.3.6.1.4.1.4526"
HTTP_FINGERPRINT = [
FingerprintRule("netgear gs1", weight=8.0),
FingerprintRule("netgear gs2", weight=8.0),
FingerprintRule("netgear gs3", weight=8.0),
FingerprintRule("netgear ms", weight=8.0),
FingerprintRule("netgear", weight=3.0),
]
SNMP_FINGERPRINT = [
FingerprintRule("netgear", weight=4.0),
]
SNMP_CAPABLE = False # HTTP-only device — no SNMP daemon
def __init__(
@@ -154,14 +168,14 @@ class NetgearPlusDriver(SwitchDriver):
username: str,
password: str,
timeout: int = 60,
optional_args: Optional[Dict] = None,
optional_args: dict | None = None,
) -> None:
# username is accepted but ignored — these switches have no username
self.hostname = hostname
self.password = password
self.timeout = timeout
self._connector = None
self._switch_infos_cache: Optional[Dict[str, Any]] = None
self._switch_infos_cache: dict[str, Any] | None = None
if optional_args is None:
optional_args = {}
@@ -206,20 +220,20 @@ class NetgearPlusDriver(SwitchDriver):
self._connector = None
self._switch_infos_cache = None
def is_alive(self) -> Dict[str, bool]:
def is_alive(self) -> dict[str, bool]:
return {"is_alive": self._connector is not None}
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _get_switch_infos(self) -> Dict[str, Any]:
def _get_switch_infos(self) -> dict[str, Any]:
"""Fetch and cache switch infos for this poll cycle."""
if self._switch_infos_cache is None:
self._switch_infos_cache = self._connector.get_switch_infos()
return self._switch_infos_cache
def _get_port_names(self) -> Dict[int, str]:
def _get_port_names(self) -> dict[int, str]:
"""Fetch port names/descriptions from dashboard.cgi.
The GS30x series embeds ``<input class="portName" value="...">``
@@ -263,7 +277,7 @@ class NetgearPlusDriver(SwitchDriver):
# NAPALM getters
# ------------------------------------------------------------------
def get_facts(self) -> Dict[str, Any]:
def get_facts(self) -> dict[str, Any]:
"""Return a dictionary of general device facts."""
infos = self._get_switch_infos()
model = getattr(self._connector.switch_model, "MODEL_NAME", "")
@@ -284,7 +298,7 @@ class NetgearPlusDriver(SwitchDriver):
"interface_list": interface_list,
}
def get_interfaces(self) -> Dict[str, Dict]:
def get_interfaces(self) -> dict[str, dict]:
"""Return a dictionary of interface details.
Each interface entry contains:
@@ -293,7 +307,7 @@ class NetgearPlusDriver(SwitchDriver):
"""
infos = self._get_switch_infos()
port_names = self._get_port_names()
interfaces: Dict[str, Dict] = {}
interfaces: dict[str, dict] = {}
for port in range(1, self._connector.ports + 1):
name = self._port_name(port)
status = infos.get(f"port_{port}_status", "off") == "on"
@@ -310,7 +324,7 @@ class NetgearPlusDriver(SwitchDriver):
}
return interfaces
def get_interfaces_ip(self) -> Dict[str, Dict]:
def get_interfaces_ip(self) -> dict[str, dict]:
"""Return IP addresses per interface.
Netgear Plus switches are L2 only — no routed interfaces.
@@ -318,14 +332,14 @@ class NetgearPlusDriver(SwitchDriver):
"""
return {}
def get_interfaces_counters(self) -> Dict[str, Dict]:
def get_interfaces_counters(self) -> dict[str, dict]:
"""Return interface traffic counters.
Uses cumulative RX/TX totals (``sum_rx_mbytes`` / ``sum_tx_mbytes``)
from ``get_switch_infos()`` converted to octets, plus CRC errors.
"""
infos = self._get_switch_infos()
counters: Dict[str, Dict] = {}
counters: dict[str, dict] = {}
for port in range(1, self._connector.ports + 1):
name = self._port_name(port)
# sum_*_mbytes are lifetime totals in megabytes; convert to bytes
@@ -348,7 +362,7 @@ class NetgearPlusDriver(SwitchDriver):
}
return counters
def get_environment(self) -> Dict[str, Any]:
def get_environment(self) -> dict[str, Any]:
"""Return environment data.
Reports PoE power status per PoE port. Temperature, fans, CPU and
@@ -356,7 +370,7 @@ class NetgearPlusDriver(SwitchDriver):
dicts.
"""
infos = self._get_switch_infos()
power: Dict[str, Any] = {}
power: dict[str, Any] = {}
poe_max_single = getattr(
self._connector.switch_model, "POE_MAX_POWER_SINGLE_PORT", None
)
@@ -376,7 +390,7 @@ class NetgearPlusDriver(SwitchDriver):
"memory": {},
}
def get_mac_address_table(self) -> List[Dict]:
def get_mac_address_table(self) -> list[dict]:
"""Return the MAC address table.
Not available via the HTTP web interface of Plus switches.
@@ -384,7 +398,7 @@ class NetgearPlusDriver(SwitchDriver):
"""
return []
def get_arp_table(self, vrf: str = "") -> List[Dict]:
def get_arp_table(self, vrf: str = "") -> list[dict]:
"""Return the ARP table.
Netgear Plus switches are L2 only and have no ARP table.
@@ -392,7 +406,7 @@ class NetgearPlusDriver(SwitchDriver):
"""
return []
def get_lldp_neighbors(self) -> Dict[str, Any]:
def get_lldp_neighbors(self) -> dict[str, Any]:
"""Return LLDP neighbors.
Not supported on Netgear Plus switches.
@@ -400,7 +414,7 @@ class NetgearPlusDriver(SwitchDriver):
"""
return {}
def get_lldp_neighbors_detail(self, interface: str = "") -> Dict[str, Any]:
def get_lldp_neighbors_detail(self, interface: str = "") -> dict[str, Any]:
"""Return detailed LLDP neighbor info.
Not supported on Netgear Plus switches.
@@ -408,7 +422,7 @@ class NetgearPlusDriver(SwitchDriver):
"""
return {}
def get_vlans_detail(self) -> Dict[str, Any]:
def get_vlans_detail(self) -> dict[str, Any]:
"""Return VLAN configuration by scraping the switch's /vlan.cgi page.
Returns a dict keyed by VLAN ID string:
@@ -433,7 +447,7 @@ class NetgearPlusDriver(SwitchDriver):
# Parse VLAN IDs and names from vid-4 / vnm-4 span pairs.
# Example: <span vid-4>...<i ...></i>24</span><span vnm-4 ...>Guest</span>
vlan_names: Dict[str, str] = {}
vlan_names: dict[str, str] = {}
vlan_entry_re = re.compile(
r'<span\s+vid-4[^>]*>.*?(\d+)\s*</span>\s*'
r'<span\s+vnm-4[^>]*>([^<]*)</span>',
@@ -447,7 +461,7 @@ class NetgearPlusDriver(SwitchDriver):
if not vlan_names:
return {}
vlans: Dict[str, Any] = {
vlans: dict[str, Any] = {
vid: {"name": name, "tagged": [], "untagged": []}
for vid, name in vlan_names.items()
}
@@ -477,7 +491,7 @@ class NetgearPlusDriver(SwitchDriver):
return vlans
def get_vlans(self) -> Dict[str, Any]:
def get_vlans(self) -> dict[str, Any]:
"""Return VLAN information (standard NAPALM format).
Delegates to get_vlans_detail() and merges tagged and untagged ports
@@ -492,7 +506,7 @@ class NetgearPlusDriver(SwitchDriver):
for vid, info in detail.items()
}
def set_vlan(self, vlan_id: int, config: Dict) -> None:
def set_vlan(self, vlan_id: int, config: dict) -> None:
"""Create or update a VLAN.
Not supported via HTTP API.
@@ -516,7 +530,7 @@ class NetgearPlusDriver(SwitchDriver):
full: bool = False,
sanitized: bool = False,
format: str = "text",
) -> Dict[str, str]:
) -> dict[str, str]:
"""Return device configuration.
Netgear Plus switches do not expose running/startup config via HTTP.
@@ -524,12 +538,12 @@ class NetgearPlusDriver(SwitchDriver):
"""
return {"running": "", "startup": "", "candidate": ""}
def load_merge_candidate(self, filename: Optional[str] = None, config: Optional[str] = None) -> None:
def load_merge_candidate(self, filename: str | None = None, config: str | None = None) -> None:
raise NotImplementedError(
"Config management is not supported for Netgear Plus switches."
)
def load_replace_candidate(self, filename: Optional[str] = None, config: Optional[str] = None) -> None:
def load_replace_candidate(self, filename: str | None = None, config: str | None = None) -> None:
raise NotImplementedError(
"Config management is not supported for Netgear Plus switches."
)
@@ -567,7 +581,7 @@ class NetgearPlusDriver(SwitchDriver):
count: int = 5,
vrf: str = "",
source_interface: str = "",
) -> Dict[str, Any]:
) -> dict[str, Any]:
"""Execute ping.
Not supported on Netgear Plus switches via HTTP.
@@ -583,7 +597,7 @@ class NetgearPlusDriver(SwitchDriver):
ttl: int = 255,
timeout: int = 2,
vrf: str = "",
) -> Dict[str, Any]:
) -> dict[str, Any]:
"""Execute traceroute.
Not supported on Netgear Plus switches via HTTP.
@@ -592,7 +606,7 @@ class NetgearPlusDriver(SwitchDriver):
"traceroute() is not supported for Netgear Plus switches."
)
def cli(self, commands: List[str], encoding: str = "text") -> Dict[str, Any]:
def cli(self, commands: list[str], encoding: str = "text") -> dict[str, Any]:
"""Send CLI commands.
Netgear Plus switches have no SSH CLI — only HTTP.
@@ -601,35 +615,35 @@ class NetgearPlusDriver(SwitchDriver):
"cli() is not supported for Netgear Plus switches (HTTP-only device)."
)
def get_users(self) -> Dict[str, Any]:
def get_users(self) -> dict[str, Any]:
"""Return user accounts.
Not available via HTTP API. Returns an empty dict.
"""
return {}
def get_snmp_information(self) -> Dict[str, Any]:
def get_snmp_information(self) -> dict[str, Any]:
"""Return SNMP configuration.
Not available via HTTP API. Returns an empty dict.
"""
return {}
def get_ntp_servers(self) -> Dict[str, Any]:
def get_ntp_servers(self) -> dict[str, Any]:
"""Return configured NTP servers.
Not available via HTTP API. Returns an empty dict.
"""
return {}
def get_ntp_peers(self) -> Dict[str, Any]:
def get_ntp_peers(self) -> dict[str, Any]:
"""Return NTP peers.
Not available via HTTP API. Returns an empty dict.
"""
return {}
def get_ntp_stats(self) -> List[Dict]:
def get_ntp_stats(self) -> list[dict]:
"""Return NTP stats.
Not available via HTTP API. Returns an empty list.
@@ -640,35 +654,35 @@ class NetgearPlusDriver(SwitchDriver):
# SwitchDriver abstract methods (not applicable for this device)
# ------------------------------------------------------------------
def get_spanning_tree(self) -> Dict[str, Any]:
def get_spanning_tree(self) -> dict[str, Any]:
"""Return spanning tree status.
Not available via HTTP API. Returns an empty dict.
"""
return {}
def get_port_channels(self) -> Dict[str, Any]:
def get_port_channels(self) -> dict[str, Any]:
"""Return port-channel information.
Not available via HTTP API. Returns an empty dict.
"""
return {}
def get_dot1x_config(self) -> Dict[str, Any]:
def get_dot1x_config(self) -> dict[str, Any]:
"""Return 802.1X port configuration.
Not available via HTTP API. Returns an empty dict.
"""
return {}
def get_mac_acl(self) -> Dict[str, Any]:
def get_mac_acl(self) -> dict[str, Any]:
"""Return MAC ACL information.
Not available via HTTP API. Returns an empty dict.
"""
return {}
def get_poe_status(self) -> Dict[str, Any]:
def get_poe_status(self) -> dict[str, Any]:
"""Return PoE status.
Reports PoE power status from switch infos.
@@ -680,7 +694,7 @@ class NetgearPlusDriver(SwitchDriver):
poe_max_single = getattr(
self._connector.switch_model, "POE_MAX_POWER_SINGLE_PORT", None
)
ports: Dict[str, Any] = {}
ports: dict[str, Any] = {}
for p in self._connector.poe_ports:
poe_active = infos.get(f"port_{p}_poe_power_active")
is_delivering = bool(poe_active) if poe_active is not None else False
@@ -722,7 +736,7 @@ class NetgearPlusDriver(SwitchDriver):
raise ValueError(f"{interface} is not a PoE port on this switch.")
self._connector.power_cycle_poe_port(port_number)
def set_interface(self, interface: str, config: Dict) -> None:
def set_interface(self, interface: str, config: dict) -> None:
"""Configure an interface.
Not supported via HTTP API.