Files
napalm-hpe-aruba-procurve/napalm_procurve/procurve.py
T
Christian ManivongandClaude Sonnet 4.6 d7ed4fda1f fix: accept verify_ssl and verify aliases alongside ssl_verify
Mirrors the robust fallback pattern from napalm-opnsense so the driver
works regardless of which key name the caller passes in optional_args.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 22:41:27 +02:00

1164 lines
43 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 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(SwitchDriver):
"""NAPALM driver for HPE/Aruba ProCurve switches.
Auto-detects transport on ``open()``. See module docstring for details.
"""
VENDOR = "HPE"
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
# 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
tried: List[str] = []
# --- 1. REST API ---
if not forced or forced == "api":
if self._try_api():
return
tried.append("api")
# --- 2. SSH standard ---
if not forced or forced == "ssh":
if self._try_ssh(legacy=False):
return
tried.append("ssh")
# --- 3. SSH legacy KEX ---
if not forced or forced == "ssh_legacy":
if self._try_ssh(legacy=True):
return
tried.append("ssh_legacy")
# --- 4. Telnet ---
if not forced or forced == "telnet":
if self._try_telnet():
return
tried.append("telnet")
raise ConnectionException(
f"Cannot connect to {self.hostname}. "
f"Tried transports: {', '.join(tried)}. "
"Check connectivity, credentials and whether SSH/Telnet/API is enabled."
)
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."""
logger.debug("Trying REST API for %s", self.hostname)
ver, proto = ProcurveApiClient.probe(
self.hostname, timeout=5, ssl_verify=self.ssl_verify
)
# 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.debug("REST API not available on %s", self.hostname)
return False
client = ProcurveApiClient(
hostname=self.hostname,
username=self.username,
password=self.password,
timeout=self.timeout,
ssl_verify=self.ssl_verify,
api_version=ver,
)
client.setup(ver, proto)
try:
client.connect()
except Exception as exc:
logger.debug("REST API connect failed: %s", exc)
return False
self._api = client
self._transport = "api"
logger.info("Connected to %s via REST API (%s %s)", self.hostname, proto, ver)
return True
def _try_ssh(self, legacy: bool = False) -> bool:
"""Probe and connect via SSH. Returns True on success."""
label = "SSH-legacy" if legacy else "SSH"
logger.debug("Trying %s for %s", label, self.hostname)
disabled = _SSH_DISABLED_LEGACY if legacy else _SSH_DISABLED_STANDARD
try:
conn = ConnectHandler(
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=disabled,
**self.netmiko_optional_args,
)
self._device = conn
self._transport = "ssh_legacy" if legacy else "ssh"
logger.info("Connected to %s via %s", self.hostname, label)
return True
except NetmikoAuthenticationException as exc:
raise ConnectionException(
f"Authentication failed for {self.hostname}: {exc}"
) from exc
except NetmikoTimeoutException:
logger.debug("%s timeout for %s", label, self.hostname)
return False
except Exception as exc:
logger.debug("%s failed for %s: %s", label, self.hostname, 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 ConnectionException(
f"Authentication failed for {self.hostname}: {exc}"
) from exc
except Exception as exc:
logger.debug("Telnet failed for %s: %s", self.hostname, 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)
if "% 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``)."""
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"]
)
parsed = parsers.parse_system_info(sysinfo)
# get model from show version if not in system-info
if not parsed["model"]:
ver_out = self._send_command("show version")
parsed["model"] = parsers.parse_model_from_version(ver_out)
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"],
"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":
return self._api.get_config()
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}
# ------------------------------------------------------------------
# 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 — update name via PUT
resp2 = self._api.put(f"vlans/{vlan_id}", json={"name": name})
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()
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:
mode = config.get("mode")
if mode == "trunk":
for vid in config.get("trunk_vlans", []):
payload = {
"vlan_id": vid,
"port_id": interface,
"port_mode": "POM_TAGGED_STATIC",
}
resp = self._api.post("vlans-ports", json=payload)
if not resp.ok and resp.status_code != 409:
raise ConnectionException(
f"set_interface({interface}): vlans-ports POST HTTP {resp.status_code}"
)
elif mode == "access":
if "access_vlan" in config:
payload = {
"vlan_id": config["access_vlan"],
"port_id": interface,
"port_mode": "POM_UNTAGGED",
}
resp = self._api.post("vlans-ports", json=payload)
if not resp.ok and resp.status_code != 409:
raise ConnectionException(
f"set_interface({interface}): vlans-ports POST HTTP {resp.status_code}"
)
def _cli_set_interface(self, interface: str, config: InterfaceConfigDict) -> None:
mode = config.get("mode")
self._enter_config_mode()
try:
lines: List[str] = []
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()
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 load_merge_candidate(
self, filename: Optional[str] = None, config: Optional[str] = None
) -> None:
"""Load a candidate configuration for merging."""
if self._transport == "api":
raise MergeConfigException(
"Merge config is not supported via REST API transport. Use SSH."
)
if filename and 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(
self, filename: Optional[str] = None, config: Optional[str] = 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":
raise ReplaceConfigException(
"Replace config is not supported via REST API transport. Use SSH."
)
if filename and 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:
"""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
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 ──────────────────────────────────────────────────────────
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
"""
lines: list = []
self._enter_config_mode()
# Enable SNMP with read-only community 'public'
# ProCurve: manager = standard access level, restricted = read-only
self._send_command('snmp-server community "public" manager restricted')
lines.append("[config] SNMP community 'public' (manager restricted / read-only) configured.")
self._exit_config_mode()
self._save_config()
lines.append("[config] Configuration saved.")
# Verify
out = self._send_command("show snmp-server")
success = "public" in out
if success:
lines.append("[ok] SNMP is active with community 'public'.")
else:
lines.append(f"[warn] Verification — community not found: {out[:200]}")
return {"success": success, "output": "\n".join(lines)}
# ---------------------------------------------------------------------------
# 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,
}
}