A 2800-series switch on J.15.09 polled over CLI came back with no
interfaces and an empty OS version, while VLANs and ARP parsed fine.
`show interfaces brief` on that firmware has an Intrusion Alert column
between the `|` and Enabled, and puts Mode before MDI rather than after:
Port Type | Alert Enabled Status Mode Mode ...
3-Trk3 100/1000T | No Yes Down 1000FDx MDI ...
The regex read Alert as Enabled, then failed on Yes where it wanted
Up|Down, so no line matched. It now skips the Alert column where there is
one and takes the speed from either position. Trunk members are listed as
`<port>-Trk<n>`; the port is `<port>` and the suffix becomes its
trunk_group, so the per-port `show interfaces 3` is a command the switch
knows.
The empty OS version was the alternatives list in _send_command. It moved
to the next command on "% Invalid" or "Error", but ProCurve rejects an
unknown command with "Invalid input: system-information" -- so that line
was parsed as system information. "Invalid input" now counts as failure.
Getting there took longer than it should have, because the first symptom
was "Authentication failed: Login failed". That was Telnet's error, the
last transport tried; the REST probe and both SSH attempts had failed
before it and said nothing above debug level. In fact the switch had run
out of CLI sessions and closed SSH straight after the password. open()
now records why each transport failed, and both the auth error and the
final "Cannot connect" carry that list.
Fixtures are that switch's output, with hostname, serial and MAC
replaced.
Closes #2
Closes #3
Closes #4
1425 lines
54 KiB
Python
1425 lines
54 KiB
Python
# -*- coding: utf-8 -*-
|
||
# Licensed under the Apache License, Version 2.0
|
||
|
||
"""NAPALM driver for HPE/Aruba ProCurve switches.
|
||
|
||
Supports legacy ProCurve (2520G-8-PoE etc.) through to modern Aruba 2530/2540
|
||
by auto-detecting the best available transport:
|
||
|
||
1. REST API (HTTPS then HTTP, API v7 → v6 → v3) — newer Aruba 2530/2540
|
||
2. SSH — standard netmiko hp_procurve
|
||
3. SSH legacy KEX — forces older algorithms for 2520G etc.
|
||
4. Telnet — oldest devices
|
||
|
||
A specific transport can be forced with ``optional_args={"transport": "ssh"}``.
|
||
|
||
Netmiko device type: ``hp_procurve`` / ``hp_procurve_telnet``
|
||
"""
|
||
|
||
import logging
|
||
import re
|
||
import socket
|
||
from typing import Any, Dict, List, Optional, Union
|
||
|
||
import netaddr
|
||
from netmiko import ConnectHandler
|
||
from netmiko.exceptions import (
|
||
NetmikoAuthenticationException,
|
||
NetmikoTimeoutException,
|
||
)
|
||
from napalm_device_types import ConfigLifecycleMixin, FingerprintRule, SwitchDriver
|
||
from napalm_device_types.models import InterfaceConfigDict, VlanConfigDict
|
||
from napalm.base import helpers as napalm_helpers
|
||
from napalm.base.exceptions import (
|
||
CommandErrorException,
|
||
ConnectionException,
|
||
ConnectionClosedException,
|
||
MergeConfigException,
|
||
ReplaceConfigException,
|
||
)
|
||
from napalm.base.netmiko_helpers import netmiko_args
|
||
|
||
from napalm_procurve.api_client import ProcurveApiClient
|
||
from napalm_procurve import parsers
|
||
|
||
logger = logging.getLogger("napalm_procurve")
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# KEX / cipher profiles for legacy SSH
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# Standard: netmiko's HPProcurveBase already disables rsa-sha2-* pubkeys which
|
||
# is needed for any ProCurve running SSH (they use plain ssh-rsa host keys).
|
||
_SSH_DISABLED_STANDARD = {
|
||
"pubkeys": ["rsa-sha2-256", "rsa-sha2-512"],
|
||
}
|
||
|
||
# Legacy: additionally remove modern kex algorithms so the negotiation is
|
||
# forced down to diffie-hellman-group14-sha1 / diffie-hellman-group1-sha1,
|
||
# which very old switches (2520G etc.) may require.
|
||
_SSH_DISABLED_LEGACY = {
|
||
"pubkeys": ["rsa-sha2-256", "rsa-sha2-512"],
|
||
"kex": [
|
||
"curve25519-sha256@libssh.org",
|
||
"ecdh-sha2-nistp256",
|
||
"ecdh-sha2-nistp384",
|
||
"ecdh-sha2-nistp521",
|
||
"diffie-hellman-group16-sha512",
|
||
"diffie-hellman-group-exchange-sha256",
|
||
"diffie-hellman-group14-sha256",
|
||
],
|
||
}
|
||
|
||
|
||
class ProcurveDriver(ConfigLifecycleMixin, SwitchDriver):
|
||
"""NAPALM driver for HPE/Aruba ProCurve switches.
|
||
|
||
Auto-detects transport on ``open()``. See module docstring for details.
|
||
"""
|
||
|
||
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_TELNET = "hp_procurve_telnet"
|
||
|
||
def __init__(
|
||
self,
|
||
hostname: str,
|
||
username: str,
|
||
password: str,
|
||
timeout: int = 60,
|
||
optional_args: Optional[Dict] = None,
|
||
) -> None:
|
||
self.hostname = hostname
|
||
self.username = username
|
||
self.password = password
|
||
self.timeout = timeout
|
||
|
||
if optional_args is None:
|
||
optional_args = {}
|
||
|
||
# --- optional_args ---
|
||
self.port: int = optional_args.get("port", 22)
|
||
self.telnet_port: int = optional_args.get("telnet_port", 23)
|
||
self.force_transport: Optional[str] = optional_args.get("transport")
|
||
self.ssl_verify: bool = optional_args.get(
|
||
"ssl_verify", optional_args.get("verify_ssl", optional_args.get("verify", False))
|
||
)
|
||
self.api_version_hint: Optional[str] = optional_args.get("api_version")
|
||
# Enable password for CLI (defaults to login password)
|
||
self._secret: str = optional_args.get("secret", password)
|
||
|
||
if optional_args.get("debugging"):
|
||
logger.setLevel(logging.DEBUG)
|
||
|
||
self.netmiko_optional_args = netmiko_args(optional_args)
|
||
|
||
# --- runtime state ---
|
||
# Active transport: "api" | "ssh" | "ssh_legacy" | "telnet"
|
||
self._transport: Optional[str] = None
|
||
# CLI backend (SSH or Telnet)
|
||
self._device: Optional[ConnectHandler] = None
|
||
# REST API backend
|
||
self._api: Optional[ProcurveApiClient] = None
|
||
# Why each transport failed during the current open(), as "<transport>: <reason>"
|
||
self._attempts: List[str] = []
|
||
|
||
# Config management state (CLI only)
|
||
self._candidate_config: Optional[str] = None
|
||
self._candidate_mode: Optional[str] = None # "merge" | "replace"
|
||
self._backup_config: Optional[str] = None
|
||
|
||
# ------------------------------------------------------------------
|
||
# Connection management
|
||
# ------------------------------------------------------------------
|
||
|
||
def open(self) -> None:
|
||
"""Open connection, auto-detecting the best available transport.
|
||
|
||
Detection order (unless overridden by ``optional_args["transport"]``):
|
||
|
||
1. REST API
|
||
2. SSH (standard)
|
||
3. SSH (legacy KEX — old ProCurve like 2520G-8-PoE)
|
||
4. Telnet
|
||
"""
|
||
forced = self.force_transport
|
||
self._attempts = []
|
||
|
||
# --- 1. REST API ---
|
||
if not forced or forced == "api":
|
||
if self._try_api():
|
||
return
|
||
|
||
# --- 2. SSH standard ---
|
||
if not forced or forced == "ssh":
|
||
if self._try_ssh(legacy=False):
|
||
return
|
||
|
||
# --- 3. SSH legacy KEX ---
|
||
if not forced or forced == "ssh_legacy":
|
||
if self._try_ssh(legacy=True):
|
||
return
|
||
|
||
# --- 4. Telnet ---
|
||
if not forced or forced == "telnet":
|
||
if self._try_telnet():
|
||
return
|
||
|
||
raise ConnectionException(
|
||
f"Cannot connect to {self.hostname}. "
|
||
f"Tried transports: {'; '.join(self._attempts)}. "
|
||
"Check connectivity, credentials and whether SSH/Telnet/API is enabled."
|
||
)
|
||
|
||
def _auth_failure(self, transport: str, exc: Exception) -> ConnectionException:
|
||
"""An authentication error that also says why the earlier transports failed.
|
||
|
||
Without them, a Telnet "Login failed" reads as a wrong password when SSH
|
||
was merely refused and Telnet was the only transport left to answer (#2).
|
||
"""
|
||
msg = f"Authentication failed for {self.hostname} via {transport}: {exc}"
|
||
if self._attempts:
|
||
msg += f" (earlier: {'; '.join(self._attempts)})"
|
||
return ConnectionException(msg)
|
||
|
||
def close(self) -> None:
|
||
"""Close the active connection."""
|
||
if self._api:
|
||
self._api.disconnect()
|
||
self._api = None
|
||
if self._device:
|
||
try:
|
||
self._device.disconnect()
|
||
except Exception:
|
||
pass
|
||
self._device = None
|
||
self._transport = None
|
||
|
||
def is_alive(self) -> Dict[str, bool]:
|
||
"""Return transport-level liveness without writing to the channel."""
|
||
if self._api:
|
||
return {"is_alive": self._api.is_alive()}
|
||
if self._device:
|
||
try:
|
||
return {"is_alive": self._device.remote_conn.transport.is_active()}
|
||
except (AttributeError, socket.error, EOFError):
|
||
return {"is_alive": False}
|
||
return {"is_alive": False}
|
||
|
||
# ------------------------------------------------------------------
|
||
# Transport probes
|
||
# ------------------------------------------------------------------
|
||
|
||
def _try_api(self) -> bool:
|
||
"""Probe and connect via REST API. Returns True on success."""
|
||
probe_timeout = min(self.timeout, 15)
|
||
logger.info("Probing REST API for %s (timeout=%ds)", self.hostname, probe_timeout)
|
||
# Probe without SSL verification — no credentials are sent during probing,
|
||
# so this is safe and avoids failing on self-signed certificates.
|
||
ver, proto = ProcurveApiClient.probe(
|
||
self.hostname, timeout=probe_timeout, ssl_verify=False
|
||
)
|
||
# Allow hint override (e.g. user knows the API version)
|
||
if self.api_version_hint and proto:
|
||
ver = self.api_version_hint
|
||
|
||
if not ver:
|
||
logger.warning("REST API not detected on %s — falling back to CLI", self.hostname)
|
||
self._attempts.append("api: not detected")
|
||
return False
|
||
|
||
# Try connecting with the requested SSL setting first; if it fails due to a
|
||
# self-signed certificate (ssl_verify=True), transparently retry unverified.
|
||
error: Optional[Exception] = None
|
||
for ssl_verify in ([self.ssl_verify] if not self.ssl_verify else [True, False]):
|
||
client = ProcurveApiClient(
|
||
hostname=self.hostname,
|
||
username=self.username,
|
||
password=self.password,
|
||
timeout=self.timeout,
|
||
ssl_verify=ssl_verify,
|
||
api_version=ver,
|
||
)
|
||
client.setup(ver, proto)
|
||
try:
|
||
client.connect()
|
||
except Exception as exc:
|
||
logger.debug("REST API connect failed (ssl_verify=%s): %s", ssl_verify, exc)
|
||
error = exc
|
||
continue
|
||
self._api = client
|
||
self._transport = "api"
|
||
logger.info("Connected to %s via REST API (%s %s, ssl_verify=%s)",
|
||
self.hostname, proto, ver, ssl_verify)
|
||
return True
|
||
|
||
logger.warning("REST API connect failed for %s — falling back to CLI", self.hostname)
|
||
self._attempts.append(f"api: {error}")
|
||
return False
|
||
|
||
def _netmiko_kwargs(self, legacy: bool = False) -> dict:
|
||
"""Return kwargs for ConnectHandler, merging explicit fields with netmiko_optional_args.
|
||
|
||
Explicit fields (port, timeout, disabled_algorithms) take precedence and are
|
||
excluded from the spread to avoid 'multiple values for keyword argument' errors.
|
||
"""
|
||
extra = {k: v for k, v in self.netmiko_optional_args.items()
|
||
if k not in ("port", "timeout", "disabled_algorithms")}
|
||
return dict(
|
||
device_type=self.NETMIKO_DEVICE_TYPE,
|
||
host=self.hostname,
|
||
username=self.username,
|
||
password=self.password,
|
||
secret=self._secret,
|
||
port=self.port,
|
||
timeout=self.timeout,
|
||
disabled_algorithms=_SSH_DISABLED_LEGACY if legacy else _SSH_DISABLED_STANDARD,
|
||
**extra,
|
||
)
|
||
|
||
def _try_ssh(self, legacy: bool = False) -> bool:
|
||
"""Probe and connect via SSH. Returns True on success."""
|
||
label = "SSH-legacy" if legacy else "SSH"
|
||
transport = "ssh_legacy" if legacy else "ssh"
|
||
logger.debug("Trying %s for %s", label, self.hostname)
|
||
try:
|
||
conn = ConnectHandler(**self._netmiko_kwargs(legacy))
|
||
self._device = conn
|
||
self._transport = transport
|
||
logger.info("Connected to %s via %s", self.hostname, label)
|
||
return True
|
||
except NetmikoAuthenticationException as exc:
|
||
raise self._auth_failure(transport, exc) from exc
|
||
except NetmikoTimeoutException:
|
||
logger.debug("%s timeout for %s", label, self.hostname)
|
||
self._attempts.append(f"{transport}: timed out")
|
||
return False
|
||
except Exception as exc:
|
||
logger.debug("%s failed for %s: %s", label, self.hostname, exc)
|
||
self._attempts.append(f"{transport}: {exc}")
|
||
return False
|
||
|
||
def _try_telnet(self) -> bool:
|
||
"""Probe and connect via Telnet. Returns True on success."""
|
||
logger.debug("Trying Telnet for %s", self.hostname)
|
||
try:
|
||
conn = ConnectHandler(
|
||
device_type=self.NETMIKO_DEVICE_TYPE_TELNET,
|
||
host=self.hostname,
|
||
username=self.username,
|
||
password=self.password,
|
||
secret=self._secret,
|
||
port=self.telnet_port,
|
||
timeout=self.timeout,
|
||
)
|
||
self._device = conn
|
||
self._transport = "telnet"
|
||
logger.info("Connected to %s via Telnet", self.hostname)
|
||
return True
|
||
except NetmikoAuthenticationException as exc:
|
||
raise self._auth_failure("telnet", exc) from exc
|
||
except Exception as exc:
|
||
logger.debug("Telnet failed for %s: %s", self.hostname, exc)
|
||
self._attempts.append(f"telnet: {exc}")
|
||
return False
|
||
|
||
# ------------------------------------------------------------------
|
||
# CLI helpers
|
||
# ------------------------------------------------------------------
|
||
|
||
def _send_command(self, command: Union[str, List[str]]) -> str:
|
||
"""Send a CLI command and return stripped output.
|
||
|
||
When a list is supplied, commands are tried in order and the first
|
||
non-error result is returned.
|
||
"""
|
||
if self._device is None:
|
||
raise ConnectionClosedException("Device connection is not open.")
|
||
|
||
prompt_pattern = rf"{re.escape(self._device.base_prompt)}[>#]"
|
||
|
||
def _do(cmd: str) -> str:
|
||
return self._device.send_command(
|
||
cmd,
|
||
expect_string=prompt_pattern,
|
||
read_timeout=self.timeout,
|
||
).strip()
|
||
|
||
try:
|
||
if isinstance(command, list):
|
||
last = ""
|
||
for cmd in command:
|
||
last = _do(cmd)
|
||
# ProCurve says "Invalid input: …"; other firmware "% Invalid …"
|
||
if "Invalid input" not in last and "% Invalid" not in last and "Error" not in last:
|
||
return last
|
||
return last
|
||
return _do(command)
|
||
except (socket.error, EOFError) as exc:
|
||
raise ConnectionClosedException(str(exc)) from exc
|
||
|
||
def _exec_prompt(self) -> str:
|
||
return rf"{re.escape(self._device.base_prompt)}[>#]"
|
||
|
||
def _conf_prompt(self) -> str:
|
||
return rf"{re.escape(self._device.base_prompt)}\(config[^)]*\)[>#]"
|
||
|
||
def _any_prompt(self) -> str:
|
||
return rf"{re.escape(self._device.base_prompt)}(?:\([^)]*\))?[>#]"
|
||
|
||
def _enter_config_mode(self) -> None:
|
||
self._device.send_command(
|
||
"configure",
|
||
expect_string=self._conf_prompt(),
|
||
read_timeout=self.timeout,
|
||
)
|
||
|
||
def _exit_config_mode(self) -> None:
|
||
self._device.send_command(
|
||
"end",
|
||
expect_string=self._exec_prompt(),
|
||
read_timeout=self.timeout,
|
||
)
|
||
|
||
def _save_config(self) -> None:
|
||
"""Save running configuration to startup (``write memory``)."""
|
||
if self._transport == "api":
|
||
self._api.post("cli", json={"cmd": "write memory"})
|
||
return
|
||
self._device.send_command(
|
||
"write memory",
|
||
expect_string=self._exec_prompt(),
|
||
read_timeout=self.timeout,
|
||
)
|
||
|
||
def _apply_config_lines(self, config_text: str) -> List[str]:
|
||
"""Push config lines to the device while in config mode.
|
||
|
||
Returns a list of error messages for rejected lines.
|
||
"""
|
||
ep = self._any_prompt()
|
||
errors: List[str] = []
|
||
for line in config_text.splitlines():
|
||
stripped = line.strip()
|
||
if not stripped or stripped.startswith(("!", "#", ";")):
|
||
continue
|
||
out = self._device.send_command(
|
||
stripped,
|
||
expect_string=ep,
|
||
read_timeout=self.timeout,
|
||
).strip()
|
||
if out and re.search(r"(error|invalid|unknown|unrecog)", out, re.I):
|
||
errors.append(f" {stripped!r}: {out}")
|
||
return errors
|
||
|
||
# ------------------------------------------------------------------
|
||
# NAPALM: get_facts
|
||
# ------------------------------------------------------------------
|
||
|
||
def get_facts(self) -> Dict:
|
||
"""Return general device information."""
|
||
if self._transport == "api":
|
||
return self._api.get_facts()
|
||
|
||
# CLI path
|
||
sysinfo = self._send_command(
|
||
["show system-information", "show system information", "show system"]
|
||
)
|
||
parsed = parsers.parse_system_info(sysinfo)
|
||
|
||
# get model (and part_number) from show version if not in system-info
|
||
if not parsed["model"]:
|
||
ver_out = self._send_command("show version")
|
||
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"]:
|
||
parsed["os_version"] = parsers.parse_version(ver_out)
|
||
|
||
iface_list = self._get_interface_list()
|
||
|
||
return {
|
||
"vendor": self.VENDOR,
|
||
"model": parsed["model"],
|
||
"part_number": parsed.get("part_number", ""),
|
||
"hostname": parsed["hostname"],
|
||
"fqdn": parsed["hostname"],
|
||
"os_version": parsed["os_version"],
|
||
"serial_number": parsed["serial_number"],
|
||
"uptime": parsed["uptime_seconds"],
|
||
"interface_list": iface_list,
|
||
}
|
||
|
||
def _get_interface_list(self) -> List[str]:
|
||
"""Return sorted list of interface names from ``show interfaces brief``."""
|
||
out = self._send_command("show interfaces brief")
|
||
ifaces = parsers.parse_interfaces_brief(out)
|
||
return sorted(
|
||
ifaces.keys(),
|
||
key=lambda s: [
|
||
int(x) if x.isdigit() else x for x in re.split(r"(\d+)", s)
|
||
],
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# NAPALM: get_interfaces
|
||
# ------------------------------------------------------------------
|
||
|
||
def get_interfaces(self) -> Dict[str, Dict]:
|
||
"""Return a dictionary of interface details."""
|
||
if self._transport == "api":
|
||
return self._api.get_interfaces()
|
||
|
||
out = self._send_command("show interfaces brief")
|
||
ifaces = parsers.parse_interfaces_brief(out)
|
||
|
||
# Enrich with MAC address from per-port detail (best-effort)
|
||
for port in list(ifaces.keys()):
|
||
try:
|
||
detail_out = self._send_command(f"show interfaces {port}")
|
||
detail = parsers.parse_interface_detail(detail_out, port)
|
||
ifaces[port]["mac_address"] = detail.get("mac_address", "")
|
||
if detail.get("description"):
|
||
ifaces[port]["description"] = detail["description"]
|
||
except Exception:
|
||
pass
|
||
|
||
return ifaces
|
||
|
||
# ------------------------------------------------------------------
|
||
# NAPALM: get_interfaces_ip
|
||
# ------------------------------------------------------------------
|
||
|
||
def get_interfaces_ip(self) -> Dict[str, Dict]:
|
||
"""Return IP addresses assigned to interfaces."""
|
||
if self._transport == "api":
|
||
return self._api.get_interfaces_ip()
|
||
|
||
out = self._send_command("show ip")
|
||
return parsers.parse_ip_interfaces(out)
|
||
|
||
# ------------------------------------------------------------------
|
||
# NAPALM: get_arp_table
|
||
# ------------------------------------------------------------------
|
||
|
||
def get_arp_table(self, vrf: str = "") -> List[Dict]:
|
||
"""Return the ARP table."""
|
||
if self._transport == "api":
|
||
return self._api.get_arp_table(vrf)
|
||
|
||
out = self._send_command("show arp")
|
||
return parsers.parse_arp_table(out)
|
||
|
||
# ------------------------------------------------------------------
|
||
# NAPALM: get_mac_address_table
|
||
# ------------------------------------------------------------------
|
||
|
||
def get_mac_address_table(self) -> List[Dict]:
|
||
"""Return the MAC address table."""
|
||
if self._transport == "api":
|
||
return self._api.get_mac_address_table()
|
||
|
||
out = self._send_command(["show mac-address", "show mac address"])
|
||
return parsers.parse_mac_table(out)
|
||
|
||
# ------------------------------------------------------------------
|
||
# NAPALM: get_lldp_neighbors
|
||
# ------------------------------------------------------------------
|
||
|
||
def get_lldp_neighbors(self) -> Dict[str, List[Dict]]:
|
||
"""Return LLDP neighbor information."""
|
||
if self._transport == "api":
|
||
return self._api.get_lldp_neighbors()
|
||
|
||
out = self._send_command(
|
||
["show lldp info remote-device", "show lldp neighbors"]
|
||
)
|
||
return parsers.parse_lldp_neighbors(out)
|
||
|
||
# ------------------------------------------------------------------
|
||
# NAPALM: get_lldp_neighbors_detail
|
||
# ------------------------------------------------------------------
|
||
|
||
def get_lldp_neighbors_detail(self, interface: str = "") -> Dict[str, List[Dict]]:
|
||
"""Return detailed LLDP neighbor information."""
|
||
if self._transport == "api":
|
||
return self._api.get_lldp_neighbors_detail()
|
||
|
||
if interface:
|
||
cmd = f"show lldp info remote-device {interface}"
|
||
else:
|
||
cmd = "show lldp info remote-device detail"
|
||
out = self._send_command(cmd)
|
||
return parsers.parse_lldp_neighbors_detail(out)
|
||
|
||
# ------------------------------------------------------------------
|
||
# NAPALM: get_config
|
||
# ------------------------------------------------------------------
|
||
|
||
def get_config(
|
||
self, retrieve: str = "all", full: bool = False, sanitized: bool = False
|
||
) -> Dict[str, str]:
|
||
"""Return device configuration."""
|
||
if self._transport == "api":
|
||
result = self._api.get_config()
|
||
if result.get("running"):
|
||
return result
|
||
# REST endpoint not available on this firmware (e.g. HP 2530 / YA series).
|
||
# HP switches allow only one session per user — close the REST session
|
||
# first so the switch accepts the SSH connection.
|
||
try:
|
||
self._api._session.close()
|
||
except Exception:
|
||
pass
|
||
running = self._get_config_via_ssh()
|
||
return {"running": running, "startup": "", "candidate": ""}
|
||
|
||
running = ""
|
||
startup = ""
|
||
candidate = self._candidate_config or ""
|
||
|
||
if retrieve in ("all", "running"):
|
||
running = self._send_command("show running-config")
|
||
if retrieve in ("all", "startup"):
|
||
startup = self._send_command(
|
||
["show startup-config", "show config"]
|
||
)
|
||
|
||
return {"running": running, "startup": startup, "candidate": candidate}
|
||
|
||
def _get_config_via_ssh(self) -> str:
|
||
"""Retrieve running-config via netmiko on port 22.
|
||
|
||
HP ProCurve switches do not support exec_channel SSH; they require
|
||
an interactive shell (invoke_shell), which netmiko's hp_procurve
|
||
device type handles correctly. Port 22 is used explicitly because
|
||
self.port is the REST API port (443) in API transport mode.
|
||
"""
|
||
try:
|
||
kwargs = self._netmiko_kwargs()
|
||
kwargs["port"] = 22 # always SSH — self.port is the REST API port
|
||
conn = ConnectHandler(**kwargs)
|
||
try:
|
||
return conn.send_command("show running-config")
|
||
finally:
|
||
conn.disconnect()
|
||
except Exception as exc:
|
||
logger.warning("SSH config fallback failed for %s: %s", self.hostname, exc)
|
||
return ""
|
||
|
||
# ------------------------------------------------------------------
|
||
# NAPALM: get_environment
|
||
# ------------------------------------------------------------------
|
||
|
||
def get_environment(self) -> Dict:
|
||
"""Return environment information (temperature, fans, CPU, memory)."""
|
||
if self._transport == "api":
|
||
raise NotImplementedError("Environment data not available via REST API")
|
||
|
||
sysinfo = self._send_command(
|
||
["show system-information", "show system information"]
|
||
)
|
||
env_out = self._send_command(["show environment", "show system"])
|
||
combined = sysinfo + "\n" + env_out
|
||
return parsers.parse_environment(combined)
|
||
|
||
# ------------------------------------------------------------------
|
||
# NAPALM: get_ntp_servers
|
||
# ------------------------------------------------------------------
|
||
|
||
def get_ntp_servers(self) -> Dict[str, Dict]:
|
||
"""Return configured NTP servers."""
|
||
if self._transport == "api":
|
||
return self._api.get_ntp_servers()
|
||
|
||
out = self._send_command(
|
||
["show ntp associations", "show ntp status", "show timesync"]
|
||
)
|
||
return parsers.parse_ntp_servers(out)
|
||
|
||
# ------------------------------------------------------------------
|
||
# NAPALM: get_ntp_stats
|
||
# ------------------------------------------------------------------
|
||
|
||
def get_ntp_stats(self) -> List[Dict]:
|
||
"""Return NTP peer statistics."""
|
||
out = self._send_command(
|
||
["show ntp associations", "show ntp status"]
|
||
)
|
||
stats = []
|
||
for line in out.splitlines():
|
||
m = re.match(
|
||
r"\s*([*+]?)\s*(\d{1,3}(?:\.\d{1,3}){3})"
|
||
r"\s+(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)",
|
||
line,
|
||
)
|
||
if not m:
|
||
continue
|
||
synced = m.group(1) == "*"
|
||
stats.append(
|
||
{
|
||
"remote": m.group(2),
|
||
"referenceid": m.group(3),
|
||
"synchronized": synced,
|
||
"stratum": int(m.group(4)),
|
||
"type": "-",
|
||
"when": m.group(5),
|
||
"hostpoll": int(m.group(6)) if m.group(6).isdigit() else -1,
|
||
"reachability": int(m.group(7), 8) if m.group(7).isdigit() else -1,
|
||
"delay": float(m.group(8)) if _is_float(m.group(8)) else 0.0,
|
||
"offset": float(m.group(9)) if _is_float(m.group(9)) else 0.0,
|
||
"jitter": 0.0,
|
||
}
|
||
)
|
||
return stats
|
||
|
||
# ------------------------------------------------------------------
|
||
# NAPALM: get_users
|
||
# ------------------------------------------------------------------
|
||
|
||
def get_users(self) -> Dict[str, Dict]:
|
||
"""Return local users."""
|
||
out = self._send_command(["show local-users", "show users"])
|
||
return parsers.parse_users(out)
|
||
|
||
# ------------------------------------------------------------------
|
||
# NAPALM: get_snmp_information
|
||
# ------------------------------------------------------------------
|
||
|
||
def get_snmp_information(self) -> Dict:
|
||
"""Return SNMP configuration."""
|
||
out = self._send_command(["show snmp-server", "show snmp"])
|
||
return parsers.parse_snmp_info(out)
|
||
|
||
# ------------------------------------------------------------------
|
||
# NAPALM: get_vlans (extended: tagged + untagged per VLAN)
|
||
# ------------------------------------------------------------------
|
||
|
||
def get_vlans(self) -> Dict[int, Dict]:
|
||
"""Return VLAN information with tagged/untagged port separation.
|
||
|
||
This method extends the standard NAPALM ``get_vlans()`` return with
|
||
two extra keys per VLAN:
|
||
|
||
- ``tagged`` — ports where this VLAN is carried tagged (802.1Q trunk)
|
||
- ``untagged`` — ports where this VLAN is the native/access VLAN (PVID)
|
||
|
||
The standard ``interfaces`` key remains the union of both lists,
|
||
keeping backward compatibility with NAPALM tooling.
|
||
|
||
Example return::
|
||
|
||
{
|
||
1: {
|
||
"name": "DEFAULT_VLAN",
|
||
"interfaces": ["1", "2", "3", "4", "5"],
|
||
"tagged": ["5"],
|
||
"untagged": ["1", "2", "3", "4"],
|
||
},
|
||
10: {
|
||
"name": "MGMT",
|
||
"interfaces": ["1", "2", "5"],
|
||
"tagged": ["1", "2", "5"],
|
||
"untagged": [],
|
||
},
|
||
}
|
||
|
||
Use :meth:`get_port_pvids` to get the per-port PVID mapping derived
|
||
from the ``untagged`` lists.
|
||
"""
|
||
if self._transport == "api":
|
||
return self._api.get_vlans()
|
||
|
||
# --- CLI path ---
|
||
# Primary: parse VLAN blocks from running-config (single command,
|
||
# reliable across all ProCurve firmware, contains untagged/tagged lines)
|
||
running = self._send_command("show running-config")
|
||
vlans = parsers.parse_vlans_from_running_config(running)
|
||
|
||
if vlans:
|
||
return vlans
|
||
|
||
# Fallback: show vlans (name discovery) + show vlans <id> per VLAN
|
||
return self._get_vlans_via_show_vlans()
|
||
|
||
def _get_vlans_via_show_vlans(self) -> Dict[int, Dict]:
|
||
"""Fallback VLAN discovery via ``show vlans`` + per-VLAN detail queries."""
|
||
brief_out = self._send_command("show vlans")
|
||
vlan_names = parsers.parse_vlans_brief(brief_out)
|
||
|
||
result: Dict[int, Dict] = {}
|
||
for vid, name in vlan_names.items():
|
||
detail_out = self._send_command(f"show vlans {vid}")
|
||
ports = parsers.parse_vlan_ports_detail(detail_out)
|
||
tagged = ports["tagged"]
|
||
untagged = ports["untagged"]
|
||
interfaces: List[str] = []
|
||
for p in untagged + tagged:
|
||
if p not in interfaces:
|
||
interfaces.append(p)
|
||
result[vid] = {
|
||
"name": name,
|
||
"interfaces": interfaces,
|
||
"tagged": tagged,
|
||
"untagged": untagged,
|
||
}
|
||
|
||
return result
|
||
|
||
# ------------------------------------------------------------------
|
||
# Custom: get_port_pvids (PVID per port, derived from get_vlans)
|
||
# ------------------------------------------------------------------
|
||
|
||
def get_port_pvids(self) -> Dict[str, int]:
|
||
"""Return the PVID (native / access VLAN) for every switch port.
|
||
|
||
On ProCurve, a port is untagged in exactly **one** VLAN — that VLAN's
|
||
ID is the port's PVID. Ports that only appear in tagged lists have
|
||
PVID 1 (the default).
|
||
|
||
This method calls :meth:`get_vlans` internally and derives PVIDs from
|
||
the ``untagged`` lists.
|
||
|
||
Example return::
|
||
|
||
{
|
||
"1": 1, # port 1 is untagged in VLAN 1 → PVID 1
|
||
"2": 1,
|
||
"3": 10, # port 3 is untagged in VLAN 10 → PVID 10
|
||
"Trk1": 1, # Trk1 only tagged → PVID 1 (default)
|
||
}
|
||
"""
|
||
vlans = self.get_vlans()
|
||
return parsers.derive_port_pvids(vlans)
|
||
|
||
# ------------------------------------------------------------------
|
||
# NAPALM: ping
|
||
# ------------------------------------------------------------------
|
||
|
||
# ------------------------------------------------------------------
|
||
# SwitchDriver: write methods
|
||
# ------------------------------------------------------------------
|
||
|
||
def set_vlan(self, vlan_id: int, config: VlanConfigDict) -> None:
|
||
"""Create or update a VLAN on the switch."""
|
||
if self._transport == "api":
|
||
self._api_set_vlan(vlan_id, config)
|
||
else:
|
||
self._cli_set_vlan(vlan_id, config)
|
||
|
||
def _api_set_vlan(self, vlan_id: int, config: VlanConfigDict) -> None:
|
||
name = config.get("name", f"VLAN{vlan_id}")
|
||
payload = {"vlan_id": vlan_id, "name": name}
|
||
resp = self._api.post("vlans", json=payload)
|
||
if not resp.ok:
|
||
if resp.status_code in (400, 409):
|
||
# VLAN already exists — PUT requires the full VLAN object
|
||
existing = self._api.get(f"vlans/{vlan_id}")
|
||
existing.pop("uri", None)
|
||
existing["name"] = name
|
||
resp2 = self._api.put(f"vlans/{vlan_id}", json=existing)
|
||
if not resp2.ok:
|
||
raise ConnectionException(
|
||
f"set_vlan({vlan_id}): API PUT returned HTTP {resp2.status_code}"
|
||
)
|
||
else:
|
||
raise ConnectionException(
|
||
f"set_vlan({vlan_id}): API POST returned HTTP {resp.status_code}"
|
||
)
|
||
|
||
def _cli_set_vlan(self, vlan_id: int, config: VlanConfigDict) -> None:
|
||
self._enter_config_mode()
|
||
try:
|
||
lines = [f"vlan {vlan_id}"]
|
||
if "name" in config:
|
||
# Quote the name to handle spaces
|
||
lines.append(f' name "{config["name"]}"')
|
||
lines.append("exit")
|
||
errors = self._apply_config_lines("\n".join(lines))
|
||
if errors:
|
||
raise CommandErrorException(
|
||
f"set_vlan({vlan_id}) errors: {errors}"
|
||
)
|
||
finally:
|
||
self._exit_config_mode()
|
||
self._save_config()
|
||
|
||
# ------------------------------------------------------------------
|
||
# LAG / trunk membership
|
||
# ------------------------------------------------------------------
|
||
|
||
def set_lag_members(self, lag_name: str, members: List[str]) -> None:
|
||
"""Set the full member-port list of a LAG/trunk group.
|
||
|
||
Diffs ``members`` against the trunk's current members (as reported
|
||
by ``get_interfaces()``) and issues ``trunk``/``no trunk`` commands
|
||
for the difference. Available on both REST API and CLI transports —
|
||
ArubaOS-Switch only exposes trunk membership via the CLI, including
|
||
through the REST API's ``/cli`` passthrough endpoint (``PUT /ports``
|
||
does not apply ``trunk_group``/``trunk_mode`` changes).
|
||
"""
|
||
current = self.get_interfaces().get(lag_name, {})
|
||
current_members = set(current.get("lag_members") or [])
|
||
desired = set(members)
|
||
to_remove = sorted(current_members - desired, key=lambda s: int(s) if s.isdigit() else 0)
|
||
to_add = sorted(desired - current_members, key=lambda s: int(s) if s.isdigit() else 0)
|
||
|
||
if not to_remove and not to_add:
|
||
return
|
||
|
||
mode = current.get("lag_mode") or "trunk"
|
||
trk_name = lag_name.lower()
|
||
|
||
commands: List[str] = []
|
||
if to_remove:
|
||
commands.append(f"no trunk {','.join(to_remove)}")
|
||
if to_add:
|
||
commands.append(f"trunk {','.join(to_add)} {trk_name} {mode}")
|
||
|
||
if self._transport == "api":
|
||
for cmd in ["configure terminal", *commands]:
|
||
resp = self._api.post("cli", json={"cmd": cmd})
|
||
if not resp.ok or resp.json().get("status") == "CCS_FAILURE":
|
||
raise CommandErrorException(
|
||
f"set_lag_members({lag_name}): '{cmd}' -> {resp.text[:200]}"
|
||
)
|
||
else:
|
||
self._enter_config_mode()
|
||
try:
|
||
errors = self._apply_config_lines("\n".join(commands))
|
||
if errors:
|
||
raise CommandErrorException(f"set_lag_members({lag_name}) errors: {errors}")
|
||
finally:
|
||
self._exit_config_mode()
|
||
self._save_config()
|
||
|
||
def set_interface(self, interface: str, config: InterfaceConfigDict) -> None:
|
||
"""Configure a switch interface (mode, VLAN membership)."""
|
||
if self._transport == "api":
|
||
self._api_set_interface(interface, config)
|
||
else:
|
||
self._cli_set_interface(interface, config)
|
||
|
||
def _api_set_interface(self, interface: str, config: InterfaceConfigDict) -> None:
|
||
patch: dict = {"id": interface}
|
||
if "enabled" in config:
|
||
patch["is_port_enabled"] = bool(config["enabled"])
|
||
if "description" in config:
|
||
patch["name"] = config["description"]
|
||
if len(patch) > 1: # more than just the id field
|
||
resp = self._api.put(f"ports/{interface}", json=patch)
|
||
if not resp.ok:
|
||
raise ConnectionException(
|
||
f"set_interface({interface}): ports PUT HTTP {resp.status_code} – {resp.text[:200]}"
|
||
)
|
||
mode = config.get("mode")
|
||
if mode == "trunk":
|
||
for vid in config.get("trunk_vlans", []):
|
||
self._api_set_port_vlan(interface, vid, "POM_TAGGED_STATIC")
|
||
elif mode == "access":
|
||
if "access_vlan" in config:
|
||
self._api_set_port_vlan(interface, config["access_vlan"], "POM_UNTAGGED")
|
||
|
||
# Statuses the switch uses to say "that association is already there".
|
||
# v7 firmware answers 400 with {"message":"Association exists"}; others
|
||
# use the more conventional 409.
|
||
_ASSOCIATION_EXISTS = (400, 409)
|
||
|
||
def _api_set_port_vlan(self, interface: str, vid: int, port_mode: str) -> None:
|
||
"""Put *interface* into VLAN *vid* with *port_mode*.
|
||
|
||
Creating the membership and changing an existing one are different
|
||
operations here. A port that already carries the VLAN — untagged, say,
|
||
while it is meant to become tagged — cannot be POSTed again: the switch
|
||
refuses the duplicate. The membership is its own resource, named
|
||
``{vlan_id}-{port_id}``, and changing it is a PUT.
|
||
"""
|
||
payload = {"vlan_id": vid, "port_id": interface, "port_mode": port_mode}
|
||
resp = self._api.post("vlans-ports", json=payload)
|
||
if resp.ok:
|
||
return
|
||
if resp.status_code not in self._ASSOCIATION_EXISTS:
|
||
raise ConnectionException(
|
||
f"set_interface({interface}): vlans-ports POST HTTP {resp.status_code}"
|
||
f" – {resp.text[:200]}"
|
||
)
|
||
|
||
resp = self._api.put(f"vlans-ports/{vid}-{interface}", json=payload)
|
||
if not resp.ok:
|
||
raise ConnectionException(
|
||
f"set_interface({interface}): vlans-ports/{vid}-{interface} PUT HTTP "
|
||
f"{resp.status_code} – {resp.text[:200]}"
|
||
)
|
||
|
||
def _cli_set_interface(self, interface: str, config: InterfaceConfigDict) -> None:
|
||
mode = config.get("mode")
|
||
self._enter_config_mode()
|
||
try:
|
||
lines: List[str] = []
|
||
if "enabled" in config or "description" in config:
|
||
lines.append(f"interface {interface}")
|
||
if "enabled" in config:
|
||
lines.append(" enable" if config["enabled"] else " disable")
|
||
if "description" in config:
|
||
desc = config["description"]
|
||
if desc:
|
||
lines.append(f' name "{desc}"')
|
||
else:
|
||
lines.append(" no name")
|
||
lines.append("exit")
|
||
if mode == "trunk":
|
||
for vid in config.get("trunk_vlans", []):
|
||
lines.append(f"vlan {vid}")
|
||
lines.append(f" tagged {interface}")
|
||
lines.append("exit")
|
||
elif mode == "access":
|
||
if "access_vlan" in config:
|
||
lines.append(f"vlan {config['access_vlan']}")
|
||
lines.append(f" untagged {interface}")
|
||
lines.append("exit")
|
||
if lines:
|
||
errors = self._apply_config_lines("\n".join(lines))
|
||
if errors:
|
||
raise CommandErrorException(
|
||
f"set_interface({interface}) errors: {errors}"
|
||
)
|
||
finally:
|
||
self._exit_config_mode()
|
||
self._save_config()
|
||
|
||
def delete_vlan(self, vlan_id: int) -> None:
|
||
"""Remove a VLAN from the switch."""
|
||
if self._transport == "api":
|
||
self._api.delete_vlan(vlan_id)
|
||
else:
|
||
self._cli_delete_vlan(vlan_id)
|
||
|
||
def _cli_delete_vlan(self, vlan_id: int) -> None:
|
||
self._enter_config_mode()
|
||
try:
|
||
errors = self._apply_config_lines(f"no vlan {vlan_id}")
|
||
if errors:
|
||
raise CommandErrorException(
|
||
f"delete_vlan({vlan_id}) errors: {errors}"
|
||
)
|
||
finally:
|
||
self._exit_config_mode()
|
||
self._save_config()
|
||
|
||
# ------------------------------------------------------------------
|
||
# PoE
|
||
# ------------------------------------------------------------------
|
||
|
||
def get_poe_status(self) -> Dict[str, Dict]:
|
||
"""Return PoE configuration for all ports, keyed by interface name.
|
||
|
||
Only available via the REST API transport.
|
||
"""
|
||
if self._transport == "api":
|
||
return self._api.get_poe_ports()
|
||
raise NotImplementedError("PoE status is only available via the REST API")
|
||
|
||
def set_poe(self, interface: str, config: Dict) -> None:
|
||
"""Update PoE configuration for a single interface."""
|
||
if self._transport != "api":
|
||
raise NotImplementedError("PoE configuration is only available via the REST API")
|
||
|
||
payload: dict = {}
|
||
for key in (
|
||
"is_poe_enabled",
|
||
"poe_priority",
|
||
"poe_allocation_method",
|
||
"pre_standard_detect_enabled",
|
||
):
|
||
if key in config:
|
||
payload[key] = config[key]
|
||
if "allocated_power_in_watts" in config:
|
||
method = config.get("poe_allocation_method") or payload.get("poe_allocation_method")
|
||
if method == "PPAM_VALUE":
|
||
payload["allocated_power_in_watts"] = config["allocated_power_in_watts"]
|
||
|
||
if not payload:
|
||
return
|
||
|
||
resp = self._api.set_port_poe(interface, payload)
|
||
if not resp.ok:
|
||
raise ConnectionException(
|
||
f"set_poe({interface}): ports/poe PUT HTTP {resp.status_code} – {resp.text[:200]}"
|
||
)
|
||
|
||
def ping(
|
||
self,
|
||
destination: str,
|
||
source: str = "",
|
||
ttl: int = 255,
|
||
timeout: int = 2,
|
||
size: int = 100,
|
||
count: int = 5,
|
||
vrf: str = "",
|
||
) -> Dict:
|
||
"""Execute a ping from the device."""
|
||
if self._transport == "api":
|
||
return self._api.ping(
|
||
destination, source=source, ttl=ttl,
|
||
timeout=timeout, size=size, count=count, vrf=vrf,
|
||
)
|
||
|
||
# ProCurve CLI ping: "ping <ip> repetitions <n>"
|
||
cmd = f"ping {destination} repetitions {count} timeout {timeout}"
|
||
if source:
|
||
cmd += f" source {source}"
|
||
out = self._send_command(cmd)
|
||
return _parse_ping_output(out, count)
|
||
|
||
# ------------------------------------------------------------------
|
||
# NAPALM: cli
|
||
# ------------------------------------------------------------------
|
||
|
||
def cli(self, commands: List[str], encoding: str = "text") -> Dict[str, str]:
|
||
"""Execute raw CLI commands and return output dict."""
|
||
if self._transport == "api":
|
||
# ArubaOS REST API supports CLI via POST /rest/<ver>/cli
|
||
results: Dict[str, str] = {}
|
||
for cmd in commands:
|
||
resp = self._api.post("cli", json={"cmd": cmd})
|
||
if resp.ok:
|
||
results[cmd] = resp.json().get("result_base64_encoded", "")
|
||
else:
|
||
results[cmd] = f"Error: HTTP {resp.status_code}"
|
||
return results
|
||
|
||
return {cmd: self._send_command(cmd) for cmd in commands}
|
||
|
||
# ------------------------------------------------------------------
|
||
# NAPALM: Configuration management (CLI only)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _get_running_config(self) -> str:
|
||
return self._send_command("show running-config")
|
||
|
||
def load_merge_candidate(
|
||
self, filename: Optional[str] = None, config: Optional[str] = None
|
||
) -> None:
|
||
if self._transport == "api":
|
||
raise MergeConfigException(
|
||
"Merge config is not supported via REST API transport. Use SSH."
|
||
)
|
||
super().load_merge_candidate(filename, config)
|
||
|
||
def load_replace_candidate(
|
||
self, filename: Optional[str] = None, config: Optional[str] = None
|
||
) -> None:
|
||
if self._transport == "api":
|
||
raise ReplaceConfigException(
|
||
"Replace config is not supported via REST API transport. Use SSH."
|
||
)
|
||
super().load_replace_candidate(filename, config)
|
||
|
||
def commit_config(self, message: str = "", revert_in: Optional[int] = None) -> None:
|
||
"""Apply the candidate configuration to the device."""
|
||
if self._candidate_config is None:
|
||
raise MergeConfigException("No candidate config loaded.")
|
||
|
||
# Save running config as backup before applying
|
||
self._backup_config = self._send_command("show running-config")
|
||
|
||
try:
|
||
self._enter_config_mode()
|
||
errors = self._apply_config_lines(self._candidate_config)
|
||
self._exit_config_mode()
|
||
except Exception as exc:
|
||
self._exit_config_mode()
|
||
raise MergeConfigException(f"Error applying config: {exc}") from exc
|
||
|
||
if errors:
|
||
raise MergeConfigException(
|
||
"The following lines were rejected:\n" + "\n".join(errors)
|
||
)
|
||
|
||
self._save_config()
|
||
self._candidate_config = None
|
||
self._candidate_mode = None
|
||
|
||
# ── SNMP / Health ──────────────────────────────────────────────────────────
|
||
|
||
def get_device_warnings(self) -> list:
|
||
"""Return device warnings. SNMP detection handled by poll task."""
|
||
return []
|
||
|
||
def get_snmp_config(self):
|
||
"""Return SNMP config if a community is configured on the switch."""
|
||
try:
|
||
from napalm_device_types.models import SNMPConfigDict
|
||
except ImportError:
|
||
return None
|
||
|
||
# Use a single command with short read_timeout to avoid blocking the poll.
|
||
try:
|
||
out = self._device.send_command(
|
||
"show snmp-server",
|
||
expect_string=rf"{re.escape(self._device.base_prompt)}[>#]",
|
||
read_timeout=10,
|
||
).strip() if self._device else ""
|
||
except Exception:
|
||
return None
|
||
if not out:
|
||
return None
|
||
|
||
# Parse communities from output
|
||
community = None
|
||
for line in out.splitlines():
|
||
m = re.match(r"\s+(\S+)\s+(Manager|Operator)\s+(Restricted|Unrestricted)", line, re.I)
|
||
if m:
|
||
comm = m.group(1)
|
||
access = m.group(3).lower()
|
||
if access == "restricted": # read-only — prefer this
|
||
community = comm
|
||
break
|
||
elif community is None:
|
||
community = comm # fallback to first found
|
||
|
||
if not community:
|
||
return None
|
||
|
||
return SNMPConfigDict(running=True, community=community, port=161, version="2c")
|
||
|
||
def run_device_action(self, action: str) -> Dict:
|
||
"""Execute a named action on the switch."""
|
||
if action == "fix_snmp":
|
||
return self._action_fix_snmp()
|
||
raise NotImplementedError(f"Unknown action: {action!r}")
|
||
|
||
def _detect_management_vlan(self) -> Optional[int]:
|
||
"""Find the VLAN that carries this switch's management IP."""
|
||
try:
|
||
out = self._send_command("show ip")
|
||
for line in out.splitlines():
|
||
# " VLAN8 | 172.22.8.x ..." or similar
|
||
m = re.search(r"VLAN\s*(\d+)[^|]*\|\s*" + re.escape(self.hostname), line, re.I)
|
||
if m:
|
||
return int(m.group(1))
|
||
# Alternative: "VLAN 8 172.22.8.x"
|
||
m2 = re.search(r"\bVLAN\s+(\d+)\b.*\b" + re.escape(self.hostname) + r"\b", line, re.I)
|
||
if m2:
|
||
return int(m2.group(1))
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
def _action_fix_snmp(self) -> Dict:
|
||
"""Enable SNMP with community 'public' (read-only) on the ProCurve switch.
|
||
|
||
ProCurve/Aruba syntax:
|
||
snmp-server community "public" manager restricted
|
||
|
||
When connected via REST API, opens a temporary SSH session for the
|
||
config-mode CLI commands (REST API has no writable SNMP endpoint).
|
||
"""
|
||
if self._transport == "api":
|
||
# REST API cannot run config-mode CLI. Temporarily open SSH.
|
||
old_api, old_device, old_transport = self._api, self._device, self._transport
|
||
self._api = None
|
||
self._device = None
|
||
self._transport = None
|
||
|
||
ssh_error: str = ""
|
||
for legacy in (False, True):
|
||
try:
|
||
conn = ConnectHandler(**self._netmiko_kwargs(legacy))
|
||
self._device = conn
|
||
self._transport = "ssh_legacy" if legacy else "ssh"
|
||
break
|
||
except Exception as exc:
|
||
ssh_error = str(exc)
|
||
|
||
if self._device is None:
|
||
self._api, self._device, self._transport = old_api, old_device, old_transport
|
||
return {
|
||
"success": False,
|
||
"output": f"REST transport active; SSH also failed: {ssh_error}",
|
||
}
|
||
try:
|
||
return self._action_fix_snmp_cli()
|
||
finally:
|
||
try:
|
||
self._device.disconnect()
|
||
except Exception:
|
||
pass
|
||
self._api, self._device, self._transport = old_api, old_device, old_transport
|
||
|
||
return self._action_fix_snmp_cli()
|
||
|
||
def _action_fix_snmp_cli(self) -> Dict:
|
||
"""Run the SNMP fix via CLI (SSH / Telnet, self._device must be open)."""
|
||
lines: list = []
|
||
self._enter_config_mode()
|
||
try:
|
||
errors = self._apply_config_lines('snmp-server community "public" manager restricted')
|
||
if errors:
|
||
lines.append(f"[warn] Config errors: {errors}")
|
||
return {"success": False, "output": "\n".join(lines)}
|
||
lines.append("[config] SNMP community 'public' (manager restricted / read-only) configured.")
|
||
finally:
|
||
self._exit_config_mode()
|
||
|
||
self._save_config()
|
||
lines.append("[config] Configuration saved.")
|
||
|
||
out = self._send_command("show snmp-server")
|
||
success = "public" in out
|
||
lines.append("[ok] SNMP is active with community 'public'." if success
|
||
else f"[warn] Verification — community not found: {out[:200]}")
|
||
return {"success": success, "output": "\n".join(lines)}
|
||
|
||
# ------------------------------------------------------------------
|
||
# Health metrics (SNMP)
|
||
# ------------------------------------------------------------------
|
||
|
||
@classmethod
|
||
async def get_health_metrics(cls, snmp_get, snmp_walk) -> dict:
|
||
import asyncio
|
||
from napalm_device_types._ucd_metrics import build_if_metrics, ticks_to_seconds
|
||
|
||
_HP_CPU_STAT = "1.3.6.1.4.1.11.2.14.11.5.1.9.6.1.0"
|
||
_HP_CPU_STAT_OLD = "1.3.6.1.4.1.11.2.3.7.8.6.1.0"
|
||
_HP_MEM_TOTAL = "1.3.6.1.4.1.11.2.14.11.5.1.1.2.1.1.1.5.1"
|
||
_HP_MEM_USED = "1.3.6.1.4.1.11.2.14.11.5.1.1.2.1.1.1.6.1"
|
||
_HP_UPTIME = "1.3.6.1.4.1.11.2.14.11.1.2.4.1.5.1"
|
||
_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_OUT_ERR = "1.3.6.1.2.1.2.2.1.20"
|
||
|
||
(hp_uptime, cpu_raw, cpu_old, mem_t, mem_u,
|
||
descr, speed, in_oct, out_oct, in_err, out_err) = await asyncio.gather(
|
||
snmp_get(_HP_UPTIME),
|
||
snmp_get(_HP_CPU_STAT),
|
||
snmp_get(_HP_CPU_STAT_OLD),
|
||
snmp_get(_HP_MEM_TOTAL),
|
||
snmp_get(_HP_MEM_USED),
|
||
snmp_walk(_OID_IF_DESCR),
|
||
snmp_walk(_OID_IF_SPEED),
|
||
snmp_walk(_OID_IF_IN_OCT),
|
||
snmp_walk(_OID_IF_OUT_OCT),
|
||
snmp_walk(_OID_IF_IN_ERR),
|
||
snmp_walk(_OID_IF_OUT_ERR),
|
||
)
|
||
|
||
metrics: dict = {}
|
||
|
||
secs = ticks_to_seconds(hp_uptime)
|
||
if secs is not None:
|
||
metrics["uptime_seconds"] = secs
|
||
|
||
cpu = cpu_raw or cpu_old
|
||
if cpu is not None:
|
||
try:
|
||
metrics["cpu_percent"] = float(cpu)
|
||
except ValueError:
|
||
pass
|
||
|
||
if mem_t and mem_u:
|
||
try:
|
||
total = int(mem_t)
|
||
used = int(mem_u)
|
||
metrics["memory_total_bytes"] = total
|
||
metrics["memory_used_bytes"] = used
|
||
metrics["memory_percent"] = round(used / total * 100, 1) if total else 0.0
|
||
except ValueError:
|
||
pass
|
||
|
||
build_if_metrics(metrics, descr, speed, in_oct, out_oct, in_err, out_err)
|
||
return metrics
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _is_float(s: str) -> bool:
|
||
try:
|
||
float(s)
|
||
return True
|
||
except (ValueError, TypeError):
|
||
return False
|
||
|
||
|
||
def _parse_ping_output(output: str, count: int) -> Dict:
|
||
"""Parse ProCurve CLI ping output into NAPALM ping result dict.
|
||
|
||
ProCurve ping output::
|
||
|
||
PING 10.0.0.1: 100 data bytes
|
||
!!!!
|
||
----10.0.0.1 PING Statistics----
|
||
5 packets transmitted, 5 packets received, 0% packet loss
|
||
round-trip (ms) min/avg/max = 0/0/1
|
||
"""
|
||
# Packet statistics line
|
||
m = re.search(
|
||
r"(\d+)\s+packets\s+transmitted,\s+(\d+)\s+packets\s+received",
|
||
output, re.I,
|
||
)
|
||
if not m:
|
||
# Check for total failure
|
||
if "unreachable" in output.lower() or "no route" in output.lower():
|
||
return {
|
||
"success": {
|
||
"probes_sent": count,
|
||
"packet_loss": count,
|
||
"rtt_min": 0.0,
|
||
"rtt_max": 0.0,
|
||
"rtt_avg": 0.0,
|
||
"rtt_stddev": 0.0,
|
||
"results": [],
|
||
}
|
||
}
|
||
return {"error": "Could not parse ping output"}
|
||
|
||
sent = int(m.group(1))
|
||
received = int(m.group(2))
|
||
loss = sent - received
|
||
|
||
# RTT line: "round-trip (ms) min/avg/max = 0/0/1"
|
||
rtt_min = rtt_avg = rtt_max = 0.0
|
||
rtt_m = re.search(r"min/avg/max\s*=\s*([\d.]+)/([\d.]+)/([\d.]+)", output, re.I)
|
||
if rtt_m:
|
||
rtt_min = float(rtt_m.group(1))
|
||
rtt_avg = float(rtt_m.group(2))
|
||
rtt_max = float(rtt_m.group(3))
|
||
|
||
results = [{"ip_address": "", "rtt": rtt_avg}] * received
|
||
|
||
return {
|
||
"success": {
|
||
"probes_sent": sent,
|
||
"packet_loss": loss,
|
||
"rtt_min": rtt_min,
|
||
"rtt_max": rtt_max,
|
||
"rtt_avg": rtt_avg,
|
||
"rtt_stddev": 0.0,
|
||
"results": results,
|
||
}
|
||
}
|
||
|