Compare commits

...
10 Commits
Author SHA1 Message Date
Christian Manivong d1db7a452d fix: falsche OUI-Prefixes entfernen (gegen IEEE verifiziert) 2026-06-24 17:06:45 +02:00
Christian ManivongandClaude Sonnet 4.6 ef5e453f5d feat: OUI_PREFIXES für MAC/ARP-Fingerprinting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 15:41:45 +02:00
Christian ManivongandClaude Sonnet 4.6 247964178a 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>
2026-06-24 14:47:08 +02:00
Christian ManivongandClaude Sonnet 4.6 232936162e fix: try 'show system' as fallback for old ProCurve (no system-information)
Firmware J.15.x (2520G-8-PoE) only supports 'show system', not
'show system-information'. Adding it as the third fallback so serial
number and OS version can still be parsed via parse_system_info().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 23:46:58 +02:00
Christian ManivongandClaude Sonnet 4.6 55fe8b28f9 fix: search all lines for HP banner, not just first line
Old ProCurve show version starts with "Image stamp:" — the HP product
banner (e.g. "HP J9298A Switch 2520G-8-PoE") appears on a later line.
Scan all lines and use the first HP/HPE/Aruba banner found.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 23:40:44 +02:00
Christian ManivongandClaude Sonnet 4.6 ff37f3c59f fix: only parse HP/Aruba banner lines in parse_model_from_version
Guard against show version outputs that start with "Image stamp:" or
other non-banner lines — return ("", "") when the first line doesn't
begin with HP/HPE/Aruba.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 23:33:05 +02:00
Christian ManivongandClaude Sonnet 4.6 da5920a9bc fix: parse_model_from_version handles both HP banner orderings
Old switches show: "HP J9298A Switch 2520G-8-PoE" (model after Switch)
Newer switches:    "HP J9565A 2520G-8-PoE Switch" (model before Switch)

New approach: strip vendor prefix + J-code + "Switch" keyword, leaving
just the product name. Returns tuple (model, part_number).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 23:27:15 +02:00
Christian ManivongandClaude Sonnet 4.6 d2915f5829 fix: extract J-code part numbers in REST API get_facts() path
api_client.get_facts() now applies the same J-code extraction as the
CLI path: product_model "HP2530-8G Switch(J9777A)" → model="HP2530-8G
Switch", part_number="J9777A".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 23:10:07 +02:00
Christian ManivongandClaude Sonnet 4.6 2be268a65e fix: extract J-code part numbers from ProCurve model strings
parse_system_info() now extracts HPE/HP J-codes (e.g. J9298A, J9777A)
from the System Model field and returns them as part_number separately.
The model name is cleaned of the J-code and surrounding punctuation:

  "HP J9298A Switch 2520G-8-PoE" → model="HP 2520G-8-PoE Switch", pn="J9298A"
  "HP2530-8G Switch(J9777A)"     → model="HP2530-8G Switch",       pn="J9777A"

get_facts() includes part_number in the returned dict so NetOrk can
store it on Device.part_number and pass it to NetBox device types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 22:57:33 +02:00
Christian ManivongandClaude Sonnet 4.6 f3f70cbf74 fix: normalize MAC address format in get_mac_address_table()
Raw MAC addresses from the ArubaOS-Switch API use a non-standard
format; normalize via napalm.base.helpers.mac() for consistency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 17:17:23 +02:00
10 changed files with 104 additions and 78 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
+23 -2
View File
@@ -13,6 +13,7 @@ from typing import Any, Dict, Optional, Tuple
import requests import requests
import urllib3 import urllib3
from napalm.base import helpers as napalm_helpers
from napalm.base.exceptions import ConnectionException, ConnectAuthError from napalm.base.exceptions import ConnectionException, ConnectAuthError
logger = logging.getLogger("napalm_procurve.api") logger = logging.getLogger("napalm_procurve.api")
@@ -225,9 +226,24 @@ class ProcurveApiClient:
for port in blade.get("data_ports", []): for port in blade.get("data_ports", []):
iface_list.append(port.get("port_name", "")) iface_list.append(port.get("port_name", ""))
model_raw = system.get("product_model", "")
# Extract J-code part numbers (e.g. J9777A) from the model string
import re as _re
_PN_RE = _re.compile(r"\b(J\d{4}[A-Z]{1,2})\b")
pn_match = _PN_RE.search(model_raw)
part_number = pn_match.group(1) if pn_match else ""
if pn_match:
clean = _PN_RE.sub("", model_raw)
clean = _re.sub(r"\(\s*\)", "", clean)
clean = _re.sub(r"\s{2,}", " ", clean).strip(" -()")
model = clean if clean else model_raw
else:
model = model_raw
return { return {
"vendor": "HPE Aruba", "vendor": "HPE Aruba",
"model": system.get("product_model", ""), "model": model,
"part_number": part_number,
"hostname": hostname, "hostname": hostname,
"fqdn": fqdn, "fqdn": fqdn,
"os_version": system.get("firmware_version", ""), "os_version": system.get("firmware_version", ""),
@@ -366,9 +382,14 @@ class ProcurveApiClient:
mac_data = self.get("mac-table") mac_data = self.get("mac-table")
table = [] table = []
for entry in mac_data.get("mac_table_entry_element", []): for entry in mac_data.get("mac_table_entry_element", []):
raw_mac = entry.get("mac_address", "")
try:
mac = napalm_helpers.mac(raw_mac)
except Exception:
mac = raw_mac
table.append( table.append(
{ {
"mac": entry.get("mac_address", ""), "mac": mac,
"interface": entry.get("port_id", ""), "interface": entry.get("port_id", ""),
"vlan": int(entry.get("vlan_id", 0)), "vlan": int(entry.get("vlan_id", 0)),
"static": entry.get("mac_addr_type", "").lower() == "static", "static": entry.get("mac_addr_type", "").lower() == "static",
+50 -13
View File
@@ -89,7 +89,23 @@ def parse_system_info(output: str) -> Dict:
) )
serial = get("Serial Number") or get("Serial Num") serial = get("Serial Number") or get("Serial Num")
base_mac = procurve_mac_to_std(get("Base MAC Addr") or get("MAC Address") or "") base_mac = procurve_mac_to_std(get("Base MAC Addr") or get("MAC Address") or "")
model = get("System Model") or get("product_model") or "" model_raw = get("System Model") or get("product_model") or ""
# Extract HPE/HP J-code part numbers (e.g. "J9298A") from the model string.
# The J-code is the PN; the remaining text is the clean model name.
# Examples:
# "HP J9298A Switch 2520G-8-PoE" → model="HP 2520G-8-PoE Switch", pn="J9298A"
# "HP2530-8G Switch(J9777A)" → model="HP2530-8G Switch", pn="J9777A"
_PN_RE = re.compile(r"\b(J\d{4}[A-Z]{1,2})\b")
pn_match = _PN_RE.search(model_raw)
part_number = pn_match.group(1) if pn_match else ""
if pn_match:
clean = _PN_RE.sub("", model_raw)
clean = re.sub(r"\(\s*\)", "", clean)
clean = re.sub(r"\s{2,}", " ", clean).strip(" -()")
model = clean if clean else model_raw
else:
model = model_raw
# Parse uptime — ProCurve formats: "5 days 3:14" or "0 days 0:05" # Parse uptime — ProCurve formats: "5 days 3:14" or "0 days 0:05"
# Also: "5 day(s), 3:14:00" # Also: "5 day(s), 3:14:00"
@@ -101,6 +117,7 @@ def parse_system_info(output: str) -> Dict:
"serial_number": serial, "serial_number": serial,
"base_mac": base_mac, "base_mac": base_mac,
"model": model, "model": model,
"part_number": part_number,
"uptime_seconds": uptime_seconds, "uptime_seconds": uptime_seconds,
} }
@@ -171,20 +188,40 @@ def parse_version(output: str) -> str:
return "" return ""
def parse_model_from_version(output: str) -> str: def parse_model_from_version(output: str) -> tuple[str, str]:
"""Extract model from ``show version`` first line. """Extract (model, part_number) from ``show version`` first line.
Example: ``HP J9565A 2520G-8-PoE Switch`` → ``2520G-8-PoE`` Handles both orderings of the HP banner:
``HP J9565A 2520G-8-PoE Switch`` → ("2520G-8-PoE", "J9565A")
``HP J9298A Switch 2520G-8-PoE`` → ("2520G-8-PoE", "J9298A")
``Aruba 2530-8-PoE+ Switch`` → ("2530-8-PoE+", "")
Returns ("", "") when the first line is not an HP/Aruba device banner
(e.g. when show version starts with "Image stamp:" on newer Aruba).
""" """
first = output.strip().splitlines()[0] if output.strip() else "" # Find the HP/Aruba product banner line — may not be the first line
m = re.search(r"HP\s+\S+\s+(\S+)\s+Switch", first, re.I) # (older ProCurve shows "Image stamp:" first, banner follows later)
if m: banner = ""
return m.group(1) for line in output.splitlines():
# Aruba format: "Aruba 2530-8-PoE+ Switch" if re.match(r"\s*(?:HP|HPE|Aruba)\b", line, re.I):
m = re.search(r"(?:Aruba|HP)\s+(\d\S+)\s+(?:Switch|)", first, re.I) banner = line.strip()
if m: break
return m.group(1) if not banner:
return "" return "", ""
first = banner
# Extract J-code part number
_PN_RE_V = re.compile(r"\b(J\d{4}[A-Z]{1,2})\b")
pn_match = _PN_RE_V.search(first)
part_number = pn_match.group(1) if pn_match else ""
# Strip vendor prefix, J-code and "Switch" keyword to get the product name
cleaned = re.sub(r"\b(?:HP|HPE|Aruba)\b", "", first, flags=re.I)
cleaned = _PN_RE_V.sub("", cleaned)
cleaned = re.sub(r"\bSwitch\b", "", cleaned, flags=re.I)
cleaned = re.sub(r"\s{2,}", " ", cleaned).strip(" -()")
return cleaned, part_number
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+31 -63
View File
@@ -27,7 +27,7 @@ from netmiko.exceptions import (
NetmikoAuthenticationException, NetmikoAuthenticationException,
NetmikoTimeoutException, NetmikoTimeoutException,
) )
from napalm_device_types import SwitchDriver from napalm_device_types import ConfigLifecycleMixin, FingerprintRule, SwitchDriver
from napalm_device_types.models import InterfaceConfigDict, VlanConfigDict from napalm_device_types.models import InterfaceConfigDict, VlanConfigDict
from napalm.base import helpers as napalm_helpers from napalm.base import helpers as napalm_helpers
from napalm.base.exceptions import ( from napalm.base.exceptions import (
@@ -71,13 +71,31 @@ _SSH_DISABLED_LEGACY = {
} }
class ProcurveDriver(SwitchDriver): class ProcurveDriver(ConfigLifecycleMixin, SwitchDriver):
"""NAPALM driver for HPE/Aruba ProCurve switches. """NAPALM driver for HPE/Aruba ProCurve switches.
Auto-detects transport on ``open()``. See module docstring for details. Auto-detects transport on ``open()``. See module docstring for details.
""" """
VENDOR = "HPE" VENDOR = "HPE"
DRIVER_NAME = "procurve"
SNMP_OBJECT_ID_PREFIX = "1.3.6.1.4.1.11"
OUI_PREFIXES = [
"3C:D9:2B", # Hewlett Packard — IEEE
"3C:52:82", # Hewlett Packard — IEEE
"44:31:92", # Hewlett Packard — IEEE
"00:17:A4", # Hewlett Packard — IEEE
]
SSH_FINGERPRINT = [
FingerprintRule("hp", weight=5.0),
FingerprintRule("aruba", weight=4.0),
]
HTTP_FINGERPRINT = [
FingerprintRule("hp procurve", weight=8.0, mandatory=True),
FingerprintRule("procurve", weight=6.0),
FingerprintRule("ehttp", weight=5.0),
FingerprintRule("comware", weight=5.0),
]
NETMIKO_DEVICE_TYPE = "hp_procurve" NETMIKO_DEVICE_TYPE = "hp_procurve"
NETMIKO_DEVICE_TYPE_TELNET = "hp_procurve_telnet" NETMIKO_DEVICE_TYPE_TELNET = "hp_procurve_telnet"
@@ -409,14 +427,16 @@ class ProcurveDriver(SwitchDriver):
# CLI path # CLI path
sysinfo = self._send_command( sysinfo = self._send_command(
["show system-information", "show system information"] ["show system-information", "show system information", "show system"]
) )
parsed = parsers.parse_system_info(sysinfo) parsed = parsers.parse_system_info(sysinfo)
# get model from show version if not in system-info # get model (and part_number) from show version if not in system-info
if not parsed["model"]: if not parsed["model"]:
ver_out = self._send_command("show version") ver_out = self._send_command("show version")
parsed["model"] = parsers.parse_model_from_version(ver_out) parsed["model"], ver_pn = parsers.parse_model_from_version(ver_out)
if ver_pn and not parsed.get("part_number"):
parsed["part_number"] = ver_pn
if not parsed["os_version"]: if not parsed["os_version"]:
parsed["os_version"] = parsers.parse_version(ver_out) parsed["os_version"] = parsers.parse_version(ver_out)
@@ -425,6 +445,7 @@ class ProcurveDriver(SwitchDriver):
return { return {
"vendor": self.VENDOR, "vendor": self.VENDOR,
"model": parsed["model"], "model": parsed["model"],
"part_number": parsed.get("part_number", ""),
"hostname": parsed["hostname"], "hostname": parsed["hostname"],
"fqdn": parsed["hostname"], "fqdn": parsed["hostname"],
"os_version": parsed["os_version"], "os_version": parsed["os_version"],
@@ -1033,61 +1054,26 @@ class ProcurveDriver(SwitchDriver):
# NAPALM: Configuration management (CLI only) # NAPALM: Configuration management (CLI only)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _get_running_config(self) -> str:
return self._send_command("show running-config")
def load_merge_candidate( def load_merge_candidate(
self, filename: Optional[str] = None, config: Optional[str] = None self, filename: Optional[str] = None, config: Optional[str] = None
) -> None: ) -> None:
"""Load a candidate configuration for merging."""
if self._transport == "api": if self._transport == "api":
raise MergeConfigException( raise MergeConfigException(
"Merge config is not supported via REST API transport. Use SSH." "Merge config is not supported via REST API transport. Use SSH."
) )
if filename and config: super().load_merge_candidate(filename, config)
raise MergeConfigException("Provide either filename or config, not both.")
if filename:
with open(filename) as f:
config = f.read()
if config is None:
raise MergeConfigException("No configuration provided.")
self._candidate_config = config
self._candidate_mode = "merge"
def load_replace_candidate( def load_replace_candidate(
self, filename: Optional[str] = None, config: Optional[str] = None self, filename: Optional[str] = None, config: Optional[str] = None
) -> None: ) -> None:
"""Load a candidate configuration for replacing.
Note: ProCurve does not natively support atomic config replace.
The candidate is applied line-by-line (same as merge) when committed.
"""
if self._transport == "api": if self._transport == "api":
raise ReplaceConfigException( raise ReplaceConfigException(
"Replace config is not supported via REST API transport. Use SSH." "Replace config is not supported via REST API transport. Use SSH."
) )
if filename and config: super().load_replace_candidate(filename, config)
raise ReplaceConfigException("Provide either filename or config, not both.")
if filename:
with open(filename) as f:
config = f.read()
if config is None:
raise ReplaceConfigException("No configuration provided.")
self._candidate_config = config
self._candidate_mode = "replace"
def compare_config(self) -> str:
"""Return a diff between the running config and the candidate config."""
if self._candidate_config is None:
return ""
import difflib
running = self._send_command("show running-config")
diff = difflib.unified_diff(
running.splitlines(),
self._candidate_config.splitlines(),
fromfile="running-config",
tofile="candidate-config",
lineterm="",
)
return "\n".join(diff)
def commit_config(self, message: str = "", revert_in: Optional[int] = None) -> None: def commit_config(self, message: str = "", revert_in: Optional[int] = None) -> None:
"""Apply the candidate configuration to the device.""" """Apply the candidate configuration to the device."""
@@ -1114,24 +1100,6 @@ class ProcurveDriver(SwitchDriver):
self._candidate_config = None self._candidate_config = None
self._candidate_mode = None self._candidate_mode = None
def discard_config(self) -> None:
"""Discard the loaded candidate configuration."""
self._candidate_config = None
self._candidate_mode = None
def rollback(self) -> None:
"""Re-apply the backup configuration saved before the last commit."""
if self._backup_config is None:
raise MergeConfigException("No backup config available for rollback.")
self._candidate_config = self._backup_config
self._candidate_mode = "merge"
self.commit_config()
self._backup_config = None
def has_pending_commit(self) -> bool:
"""Return True if a candidate config is loaded."""
return self._candidate_config is not None
# ── SNMP / Health ────────────────────────────────────────────────────────── # ── SNMP / Health ──────────────────────────────────────────────────────────
def get_device_warnings(self) -> list: def get_device_warnings(self) -> list:
Binary file not shown.
Binary file not shown.