initial commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
"""NAPALM driver for HPE/Aruba ProCurve switches."""
|
||||
|
||||
from napalm_procurve.procurve import ProcurveDriver
|
||||
|
||||
__all__ = ["ProcurveDriver"]
|
||||
@@ -0,0 +1,524 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Licensed under the Apache License, Version 2.0
|
||||
|
||||
"""REST API client for HPE/Aruba ProCurve switches (ArubaOS REST interface).
|
||||
|
||||
Compatible with ArubaOS REST API versions v3, v6, and v7.
|
||||
Used automatically by ProcurveDriver when the REST API is reachable.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
import requests
|
||||
import urllib3
|
||||
|
||||
from napalm.base.exceptions import ConnectionException, ConnectAuthError
|
||||
|
||||
logger = logging.getLogger("napalm_procurve.api")
|
||||
|
||||
|
||||
class ProcurveApiClient:
|
||||
"""Thin wrapper around ArubaOS REST API sessions.
|
||||
|
||||
Detection approach: probe ``GET /rest/<version>/system/status`` without
|
||||
authentication. A ``401`` response confirms the API is available.
|
||||
"""
|
||||
|
||||
# Ordered list of API versions to probe (newest first)
|
||||
API_VERSIONS = ["v7", "v6", "v3"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hostname: str,
|
||||
username: str,
|
||||
password: str,
|
||||
timeout: int = 60,
|
||||
ssl_verify: bool = False,
|
||||
api_version: Optional[str] = None,
|
||||
) -> None:
|
||||
self.hostname = hostname
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.timeout = timeout
|
||||
self.ssl_verify = ssl_verify
|
||||
self._api_version = api_version # None = auto-detect
|
||||
self._proto: str = "https"
|
||||
self._base_url: str = ""
|
||||
self._session: Optional[requests.Session] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Detection
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def probe(
|
||||
cls,
|
||||
hostname: str,
|
||||
timeout: int = 5,
|
||||
ssl_verify: bool = False,
|
||||
) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""Probe the device for an ArubaOS REST API.
|
||||
|
||||
Returns ``(api_version, proto)`` on success, ``(None, None)`` if the
|
||||
API is unreachable or the device is not an ArubaOS switch.
|
||||
"""
|
||||
if not ssl_verify:
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
for proto in ("https", "http"):
|
||||
for ver in cls.API_VERSIONS:
|
||||
url = f"{proto}://{hostname}/rest/{ver}/system/status"
|
||||
try:
|
||||
resp = requests.get(
|
||||
url,
|
||||
verify=ssl_verify,
|
||||
timeout=timeout,
|
||||
allow_redirects=False,
|
||||
)
|
||||
# 401 = API exists but not authenticated (most common)
|
||||
# 200 = API exists and accessible without auth (unusual)
|
||||
# 405 = wrong HTTP verb but API is there
|
||||
if resp.status_code in (200, 401, 405):
|
||||
logger.debug(
|
||||
"API detected at %s (HTTP %s, %s %s)",
|
||||
hostname, resp.status_code, proto, ver,
|
||||
)
|
||||
return ver, proto
|
||||
except requests.exceptions.SSLError:
|
||||
# SSL error on https → try http next
|
||||
continue
|
||||
except requests.exceptions.ConnectionError:
|
||||
# No route to host / port closed → next proto/ver
|
||||
break
|
||||
except requests.exceptions.Timeout:
|
||||
break
|
||||
return None, None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Session management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Open an authenticated REST API session."""
|
||||
if not self._base_url:
|
||||
raise ConnectionException(
|
||||
"ProcurveApiClient.connect() called before api_version/proto set. "
|
||||
"Use ProcurveApiClient.probe() first."
|
||||
)
|
||||
if not self.ssl_verify:
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
self._session = requests.Session()
|
||||
self._session.verify = self.ssl_verify
|
||||
self._session.headers.update(
|
||||
{"Content-Type": "application/json", "Connection": "close"}
|
||||
)
|
||||
|
||||
url = self._base_url + "login-sessions"
|
||||
resp = self._session.post(
|
||||
url,
|
||||
json={"userName": self.username, "password": self.password},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
if resp.status_code != 201:
|
||||
raise ConnectAuthError(
|
||||
f"REST API login failed (HTTP {resp.status_code}) for {self.hostname}"
|
||||
)
|
||||
logger.debug("REST API login OK for %s", self.hostname)
|
||||
|
||||
def setup(self, api_version: str, proto: str) -> None:
|
||||
"""Set the API version and protocol (called after probe())."""
|
||||
self._api_version = api_version
|
||||
self._proto = proto
|
||||
self._base_url = f"{proto}://{self.hostname}/rest/{api_version}/"
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Log out from the REST API session."""
|
||||
if self._session and self._base_url:
|
||||
try:
|
||||
self._session.delete(
|
||||
self._base_url + "login-sessions", timeout=self.timeout
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if self._session:
|
||||
self._session.close()
|
||||
self._session = None
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
"""Check if the API session is still usable."""
|
||||
if not self._session:
|
||||
return False
|
||||
try:
|
||||
resp = self._session.get(
|
||||
self._base_url + "system/status", timeout=5
|
||||
)
|
||||
return resp.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# HTTP helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get(self, endpoint: str) -> Dict[str, Any]:
|
||||
"""GET ``{base_url}{endpoint}`` and return parsed JSON (or empty dict)."""
|
||||
url = self._base_url + endpoint
|
||||
try:
|
||||
resp = self._session.get(url, timeout=self.timeout)
|
||||
if resp.ok:
|
||||
return resp.json()
|
||||
logger.debug("GET %s returned HTTP %s", url, resp.status_code)
|
||||
except Exception as exc:
|
||||
logger.debug("GET %s error: %s", url, exc)
|
||||
return {}
|
||||
|
||||
def post(self, endpoint: str, **kwargs: Any) -> requests.Response:
|
||||
"""POST ``{base_url}{endpoint}``."""
|
||||
return self._session.post(self._base_url + endpoint, timeout=self.timeout, **kwargs)
|
||||
|
||||
def put(self, endpoint: str, **kwargs: Any) -> requests.Response:
|
||||
"""PUT ``{base_url}{endpoint}``."""
|
||||
return self._session.put(self._base_url + endpoint, timeout=self.timeout, **kwargs)
|
||||
|
||||
def delete(self, endpoint: str, **kwargs: Any) -> requests.Response:
|
||||
"""DELETE ``{base_url}{endpoint}``."""
|
||||
return self._session.delete(self._base_url + endpoint, timeout=self.timeout, **kwargs)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# NAPALM data getters (API-backed)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_facts(self) -> Dict:
|
||||
"""Return NAPALM facts from the REST API."""
|
||||
system = self.get("system/status")
|
||||
if not system:
|
||||
# Fallback endpoint for stacked switches
|
||||
system = self.get("system/status/global_info")
|
||||
|
||||
dns = self.get("dns")
|
||||
domain = ""
|
||||
if dns:
|
||||
domains = dns.get("dns_domain_names", [])
|
||||
domain = f".{domains[0]}" if domains else ""
|
||||
|
||||
hostname = system.get("name", "")
|
||||
fqdn = f"{hostname}{domain}" if domain else hostname
|
||||
|
||||
# Interface list from blades
|
||||
iface_list = []
|
||||
sw_status = self.get("system/status/switch")
|
||||
for blade in sw_status.get("blades", []):
|
||||
for port in blade.get("data_ports", []):
|
||||
iface_list.append(port.get("port_name", ""))
|
||||
|
||||
return {
|
||||
"vendor": "HPE Aruba",
|
||||
"model": system.get("product_model", ""),
|
||||
"hostname": hostname,
|
||||
"fqdn": fqdn,
|
||||
"os_version": system.get("firmware_version", ""),
|
||||
"serial_number": system.get("serial_number", ""),
|
||||
"uptime": float(system.get("uptime_seconds", -1)),
|
||||
"interface_list": iface_list,
|
||||
}
|
||||
|
||||
def get_interfaces(self) -> Dict[str, Dict]:
|
||||
"""Return NAPALM interfaces from the REST API."""
|
||||
ports_data = self.get("ports")
|
||||
stats_data = self.get("port-statistics")
|
||||
|
||||
output: Dict[str, Dict] = {}
|
||||
|
||||
for port in ports_data.get("port_element", []):
|
||||
pid = port.get("id", "")
|
||||
output[pid] = {
|
||||
"is_up": bool(port.get("is_port_up", False)),
|
||||
"is_enabled": bool(port.get("is_port_enabled", False)),
|
||||
"description": port.get("name", ""),
|
||||
"last_flapped": -1.0,
|
||||
"speed": 0.0,
|
||||
"mtu": -1,
|
||||
"mac_address": "",
|
||||
}
|
||||
|
||||
for stat in stats_data.get("port_statistics_element", []):
|
||||
pid = stat.get("id", "")
|
||||
if pid in output:
|
||||
output[pid]["speed"] = float(stat.get("port_speed_mbps", 0))
|
||||
|
||||
return output
|
||||
|
||||
def get_interfaces_ip(self) -> Dict[str, Dict]:
|
||||
"""Return NAPALM interfaces IP from the REST API."""
|
||||
vlans_data = self.get("vlans")
|
||||
result: Dict[str, Dict] = {}
|
||||
|
||||
for vlan in vlans_data.get("vlan_element", []):
|
||||
vlan_id = str(vlan.get("vlan_id", ""))
|
||||
vlan_name = f"VLAN{vlan_id}"
|
||||
# IPv4 from /vlans/{id}/ipv4
|
||||
ipv4_data = self.get(f"vlans/{vlan_id}/ipv4")
|
||||
for entry in ipv4_data.get("ipv4_element", []):
|
||||
addr = entry.get("address", "")
|
||||
mask = entry.get("mask", "")
|
||||
if not addr or addr == "0.0.0.0":
|
||||
continue
|
||||
try:
|
||||
import netaddr
|
||||
plen = netaddr.IPNetwork(f"{addr}/{mask}").prefixlen
|
||||
except Exception:
|
||||
plen = 24
|
||||
result.setdefault(vlan_name, {"ipv4": {}, "ipv6": {}})
|
||||
result[vlan_name]["ipv4"][addr] = {"prefix_length": plen}
|
||||
|
||||
return {k: v for k, v in result.items() if v["ipv4"] or v["ipv6"]}
|
||||
|
||||
def get_arp_table(self, vrf: str = "") -> list:
|
||||
"""Return NAPALM ARP table from the REST API."""
|
||||
arp_data = self.get("arp")
|
||||
table = []
|
||||
for entry in arp_data.get("arp_entry", []):
|
||||
table.append(
|
||||
{
|
||||
"interface": entry.get("port_id", ""),
|
||||
"mac": entry.get("mac_address", ""),
|
||||
"ip": entry.get("ip_address", ""),
|
||||
"age": float(entry.get("age", -1)),
|
||||
}
|
||||
)
|
||||
return table
|
||||
|
||||
def get_mac_address_table(self) -> list:
|
||||
"""Return NAPALM MAC address table from the REST API."""
|
||||
mac_data = self.get("mac-table")
|
||||
table = []
|
||||
for entry in mac_data.get("mac_table_entry_element", []):
|
||||
table.append(
|
||||
{
|
||||
"mac": entry.get("mac_address", ""),
|
||||
"interface": entry.get("port_id", ""),
|
||||
"vlan": int(entry.get("vlan_id", 0)),
|
||||
"static": entry.get("mac_addr_type", "").lower() == "static",
|
||||
"active": True,
|
||||
"moves": -1,
|
||||
"last_move": -1.0,
|
||||
}
|
||||
)
|
||||
return table
|
||||
|
||||
def get_lldp_neighbors(self) -> Dict[str, list]:
|
||||
"""Return NAPALM LLDP neighbors from the REST API."""
|
||||
result: Dict[str, list] = {}
|
||||
data = self.get("lldp/remote-device")
|
||||
for nbr in data.get("lldp_remote_device_element", []):
|
||||
local_port = nbr.get("local_port", "")
|
||||
result.setdefault(local_port, []).append(
|
||||
{
|
||||
"hostname": nbr.get("system_name", ""),
|
||||
"port": nbr.get("port_id", ""),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
def get_lldp_neighbors_detail(self) -> Dict[str, list]:
|
||||
"""Return NAPALM LLDP neighbor details from the REST API."""
|
||||
result: Dict[str, list] = {}
|
||||
data = self.get("lldp/remote-device")
|
||||
for nbr in data.get("lldp_remote_device_element", []):
|
||||
local_port = nbr.get("local_port", "")
|
||||
result.setdefault(local_port, []).append(
|
||||
{
|
||||
"parent_interface": "",
|
||||
"remote_chassis_id": nbr.get("chassis_id", ""),
|
||||
"remote_port": nbr.get("port_id", ""),
|
||||
"remote_port_description": nbr.get("port_description", ""),
|
||||
"remote_system_name": nbr.get("system_name", ""),
|
||||
"remote_system_description": nbr.get("system_description", ""),
|
||||
"remote_system_capab": [],
|
||||
"remote_system_enable_capab": [],
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
def get_config(self) -> Dict[str, str]:
|
||||
"""Return running and startup configuration via REST API."""
|
||||
running = self.get("running-config")
|
||||
startup = self.get("startup-config")
|
||||
return {
|
||||
"running": running.get("config", ""),
|
||||
"startup": startup.get("config", ""),
|
||||
"candidate": "",
|
||||
}
|
||||
|
||||
def get_ntp_servers(self) -> Dict[str, Dict]:
|
||||
"""Return NTP servers from REST API."""
|
||||
data = self.get("ntp/server")
|
||||
servers: Dict[str, Dict] = {}
|
||||
for entry in data.get("ntp_server_element", []):
|
||||
addr = entry.get("server_address", "")
|
||||
if addr:
|
||||
servers[addr] = {}
|
||||
return servers
|
||||
|
||||
def get_vlans(self) -> Dict[int, Dict]:
|
||||
"""Return VLAN information with proper tagged/untagged separation.
|
||||
|
||||
Fetches ``/vlans`` for VLAN names and ``/vlans-ports`` for port membership.
|
||||
|
||||
The ``port_mode`` field in ``/vlans-ports`` varies by firmware:
|
||||
|
||||
======================== ========
|
||||
Firmware ``port_mode`` Meaning
|
||||
======================== ========
|
||||
``POM_UNTAGGED`` Untagged (access/native)
|
||||
``POM_TAGGED_STATIC`` Tagged (trunk)
|
||||
``POM_TAGGED`` Tagged (some older v3 API)
|
||||
``POM_NATIVE_UNTAGGED`` Native untagged (also PVID)
|
||||
``POM_NATIVE_TAGGED`` Native tagged (unusual)
|
||||
======================== ========
|
||||
|
||||
Returns extended NAPALM-compatible dict::
|
||||
|
||||
{
|
||||
1: {"name": "DEFAULT_VLAN",
|
||||
"interfaces": ["1","2","3"],
|
||||
"tagged": [],
|
||||
"untagged": ["1","2","3"]},
|
||||
10: {"name": "MGMT",
|
||||
"interfaces": ["1","2","3"],
|
||||
"tagged": ["1","2","3"],
|
||||
"untagged": []},
|
||||
}
|
||||
"""
|
||||
vlans_data = self.get("vlans")
|
||||
ports_data = self.get("vlans-ports")
|
||||
|
||||
# Build VLAN skeleton from /vlans
|
||||
result: Dict[int, Dict] = {}
|
||||
for vlan in vlans_data.get("vlan_element", []):
|
||||
try:
|
||||
vid = int(vlan["vlan_id"])
|
||||
except (KeyError, ValueError, TypeError):
|
||||
continue
|
||||
result[vid] = {
|
||||
"name": vlan.get("name", f"VLAN{vid}"),
|
||||
"interfaces": [],
|
||||
"tagged": [],
|
||||
"untagged": [],
|
||||
}
|
||||
|
||||
# Populate tagged/untagged from /vlans-ports
|
||||
for entry in ports_data.get("vlan_port_element", []):
|
||||
try:
|
||||
vid = int(entry["vlan_id"])
|
||||
except (KeyError, ValueError, TypeError):
|
||||
continue
|
||||
|
||||
port = entry.get("port_id", "")
|
||||
if not port:
|
||||
continue
|
||||
|
||||
mode = entry.get("port_mode", "")
|
||||
|
||||
# Normalise to lower-case for comparison
|
||||
mode_low = mode.lower()
|
||||
|
||||
# Untagged variants: POM_UNTAGGED, POM_NATIVE_UNTAGGED, "Untagged"
|
||||
is_untagged = any(
|
||||
kw in mode_low
|
||||
for kw in ("untagged", "native_untagged", "pom_untagged")
|
||||
) and "tagged" not in mode_low.replace("untagged", "")
|
||||
|
||||
# More precise: contains "untagged" anywhere → untagged
|
||||
# Contains "tagged" but NOT "untagged" → tagged
|
||||
if "untagged" in mode_low:
|
||||
is_untagged = True
|
||||
elif "tagged" in mode_low:
|
||||
is_untagged = False
|
||||
else:
|
||||
# Unknown mode: skip
|
||||
logger.debug("Unknown port_mode %r for port %s VLAN %s", mode, port, vid)
|
||||
continue
|
||||
|
||||
if vid not in result:
|
||||
result[vid] = {
|
||||
"name": f"VLAN{vid}",
|
||||
"interfaces": [],
|
||||
"tagged": [],
|
||||
"untagged": [],
|
||||
}
|
||||
|
||||
if is_untagged:
|
||||
result[vid]["untagged"].append(port)
|
||||
else:
|
||||
result[vid]["tagged"].append(port)
|
||||
|
||||
# Build "interfaces" as ordered union (untagged first, then tagged)
|
||||
for vdata in result.values():
|
||||
seen: list = []
|
||||
for p in vdata["untagged"] + vdata["tagged"]:
|
||||
if p not in seen:
|
||||
seen.append(p)
|
||||
vdata["interfaces"] = seen
|
||||
|
||||
return result
|
||||
|
||||
def delete_vlan(self, vlan_id: int) -> None:
|
||||
"""Remove a VLAN via the REST API (``DELETE /vlans/{id}``).
|
||||
|
||||
:param vlan_id: VLAN ID (1–4094) to delete.
|
||||
:raises ValueError: If *vlan_id* is out of range.
|
||||
:raises ConnectionException: If the API returns a non-success status.
|
||||
"""
|
||||
if not 1 <= vlan_id <= 4094:
|
||||
raise ValueError(f"VLAN ID {vlan_id} is out of range (1–4094)")
|
||||
|
||||
resp = self.delete(f"vlans/{vlan_id}")
|
||||
if not resp.ok and resp.status_code != 404:
|
||||
raise ConnectionException(
|
||||
f"delete_vlan({vlan_id}): REST API returned HTTP {resp.status_code}"
|
||||
)
|
||||
|
||||
def ping(
|
||||
self,
|
||||
destination: str,
|
||||
source: str = "",
|
||||
ttl: int = 255,
|
||||
timeout: int = 2,
|
||||
size: int = 100,
|
||||
count: int = 5,
|
||||
vrf: str = "",
|
||||
) -> Dict:
|
||||
"""Execute ping via REST API (if supported) or return not-implemented."""
|
||||
payload: Dict = {"destination": destination, "count": count, "timeout": timeout}
|
||||
if source:
|
||||
payload["source"] = source
|
||||
|
||||
resp = self.post("ping", json=payload)
|
||||
if not resp.ok:
|
||||
return {"error": f"Ping API returned HTTP {resp.status_code}"}
|
||||
|
||||
data = resp.json()
|
||||
sent = data.get("sent", count)
|
||||
received = data.get("received", 0)
|
||||
rtt_min = float(data.get("min_rtt", 0))
|
||||
rtt_max = float(data.get("max_rtt", 0))
|
||||
rtt_avg = float(data.get("avg_rtt", 0))
|
||||
|
||||
results = []
|
||||
for _ in range(received):
|
||||
results.append({"ip_address": destination, "rtt": rtt_avg})
|
||||
|
||||
return {
|
||||
"success": {
|
||||
"probes_sent": sent,
|
||||
"packet_loss": sent - received,
|
||||
"rtt_min": rtt_min,
|
||||
"rtt_max": rtt_max,
|
||||
"rtt_avg": rtt_avg,
|
||||
"rtt_stddev": 0.0,
|
||||
"results": results,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,886 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Licensed under the Apache License, Version 2.0
|
||||
|
||||
"""CLI output parsers for HPE/Aruba ProCurve switches.
|
||||
|
||||
All functions are pure (no side effects) and operate on pre-collected
|
||||
command output strings. The same parsers are shared by the SSH and Telnet
|
||||
backends.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MAC address helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def procurve_mac_to_std(mac: str) -> str:
|
||||
"""Convert ProCurve MAC format ``a1b2c3-d4e5f6`` to ``a1:b2:c3:d4:e5:f6``."""
|
||||
clean = mac.replace("-", "").replace(":", "").replace(".", "").lower()
|
||||
if len(clean) != 12:
|
||||
return mac
|
||||
return ":".join(clean[i : i + 2] for i in range(0, 12, 2))
|
||||
|
||||
|
||||
def _kv(output: str, key: str) -> str:
|
||||
"""Extract value from ``key : value`` style ProCurve output (case-insensitive)."""
|
||||
for line in output.splitlines():
|
||||
if re.search(re.escape(key), line, re.IGNORECASE):
|
||||
parts = line.split(":", 1)
|
||||
if len(parts) == 2:
|
||||
return parts[1].strip()
|
||||
return ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# show system-information
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_system_info(output: str) -> Dict:
|
||||
"""Parse ``show system-information`` output.
|
||||
|
||||
Returns a dict with keys: hostname, os_version, serial_number, model,
|
||||
base_mac, uptime_seconds.
|
||||
|
||||
Handles both older ProCurve style (two-column layout) and newer Aruba style.
|
||||
|
||||
Example older output::
|
||||
|
||||
System Name : myswitch
|
||||
Software revision : R.11.27 Base MAC Addr : a1b2c3-d4e5f6
|
||||
Serial Number : SGXXXXXXXXXX Up Time : 5 days 3:14
|
||||
|
||||
Example newer output::
|
||||
|
||||
System Name : ArubaSW
|
||||
Firmware revision: YA.16.04.0006
|
||||
Serial Number : SGXXXXXXXXXX
|
||||
"""
|
||||
# Use a regex to extract key:value pairs from each line.
|
||||
# ProCurve CLI may pack two k/v pairs on the same line with many spaces
|
||||
# between them, e.g.:
|
||||
# " Software revision : R.11.27 Base MAC Addr : a1b2c3-d4e5f6"
|
||||
# We capture all k:v pairs on every line.
|
||||
_KV_RE = re.compile(
|
||||
r"([A-Za-z][A-Za-z0-9 ()/#-]{1,40}?)\s*:\s*(\S.*?)(?=\s{3,}[A-Za-z]|$)"
|
||||
)
|
||||
kv_map: Dict[str, str] = {}
|
||||
for raw_line in output.splitlines():
|
||||
for m in _KV_RE.finditer(raw_line):
|
||||
k = m.group(1).strip()
|
||||
v = m.group(2).strip()
|
||||
if k and v:
|
||||
kv_map[k] = v
|
||||
|
||||
def get(key: str) -> str:
|
||||
for k, v in kv_map.items():
|
||||
if key.lower() in k.lower():
|
||||
return v
|
||||
return ""
|
||||
|
||||
hostname = get("System Name")
|
||||
os_version = (
|
||||
get("Software revision")
|
||||
or get("Firmware revision")
|
||||
or get("Software Revision")
|
||||
or get("Firmware Version")
|
||||
)
|
||||
serial = get("Serial Number") or get("Serial Num")
|
||||
base_mac = procurve_mac_to_std(get("Base MAC Addr") or get("MAC Address") or "")
|
||||
model = get("System Model") or get("product_model") or ""
|
||||
|
||||
# Parse uptime — ProCurve formats: "5 days 3:14" or "0 days 0:05"
|
||||
# Also: "5 day(s), 3:14:00"
|
||||
uptime_seconds = _parse_procurve_uptime(get("Up Time") or get("Uptime") or "")
|
||||
|
||||
return {
|
||||
"hostname": hostname,
|
||||
"os_version": os_version,
|
||||
"serial_number": serial,
|
||||
"base_mac": base_mac,
|
||||
"model": model,
|
||||
"uptime_seconds": uptime_seconds,
|
||||
}
|
||||
|
||||
|
||||
def _parse_procurve_uptime(uptime_str: str) -> float:
|
||||
"""Convert ProCurve uptime string to total seconds.
|
||||
|
||||
Handles formats::
|
||||
|
||||
5 days 3:14 → 5*86400 + 3*3600 + 14*60
|
||||
0 days 0:05:03 → 303
|
||||
5 day(s), 3:14:00 → same
|
||||
1 hr 5 min 3 sec → some devices
|
||||
"""
|
||||
total = 0.0
|
||||
# Format: "N days H:MM" or "N days H:MM:SS"
|
||||
m = re.search(r"(\d+)\s+days?\s+(\d+):(\d+)(?::(\d+))?", uptime_str, re.I)
|
||||
if m:
|
||||
total = (
|
||||
int(m.group(1)) * 86400
|
||||
+ int(m.group(2)) * 3600
|
||||
+ int(m.group(3)) * 60
|
||||
+ int(m.group(4) or 0)
|
||||
)
|
||||
return float(total)
|
||||
|
||||
# Format: "Xd Xh Xm Xs" or "X day(s), X hour(s), ..."
|
||||
days = re.search(r"(\d+)\s+day", uptime_str, re.I)
|
||||
hours = re.search(r"(\d+)\s+h(?:r|our)", uptime_str, re.I)
|
||||
minutes = re.search(r"(\d+)\s+min", uptime_str, re.I)
|
||||
seconds = re.search(r"(\d+)\s+sec", uptime_str, re.I)
|
||||
if days or hours or minutes or seconds:
|
||||
total = (
|
||||
int(days.group(1) if days else 0) * 86400
|
||||
+ int(hours.group(1) if hours else 0) * 3600
|
||||
+ int(minutes.group(1) if minutes else 0) * 60
|
||||
+ int(seconds.group(1) if seconds else 0)
|
||||
)
|
||||
return float(total)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# show version
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_version(output: str) -> str:
|
||||
"""Extract firmware version from ``show version`` or ``show system-information``.
|
||||
|
||||
ProCurve ``show version`` example::
|
||||
|
||||
HP J9565A 2520G-8-PoE Switch
|
||||
Software revision : R.11.27
|
||||
|
||||
Aruba image stamp format::
|
||||
|
||||
Image stamp: /ws/.../WC.16.11.0004
|
||||
WC.16.11.0004
|
||||
"""
|
||||
# "Software revision : R.11.27" style
|
||||
m = re.search(r"(?:software|firmware)\s+revision\s*:\s*(\S+)", output, re.I)
|
||||
if m:
|
||||
return m.group(1)
|
||||
# Bare revision on its own line (image stamp format)
|
||||
for line in output.splitlines():
|
||||
line = line.strip()
|
||||
if re.match(r"^[A-Z]{2}\.\d+\.\d+", line):
|
||||
return line
|
||||
return ""
|
||||
|
||||
|
||||
def parse_model_from_version(output: str) -> str:
|
||||
"""Extract model from ``show version`` first line.
|
||||
|
||||
Example: ``HP J9565A 2520G-8-PoE Switch`` → ``2520G-8-PoE``
|
||||
"""
|
||||
first = output.strip().splitlines()[0] if output.strip() else ""
|
||||
m = re.search(r"HP\s+\S+\s+(\S+)\s+Switch", first, re.I)
|
||||
if m:
|
||||
return m.group(1)
|
||||
# Aruba format: "Aruba 2530-8-PoE+ Switch"
|
||||
m = re.search(r"(?:Aruba|HP)\s+(\d\S+)\s+(?:Switch|)", first, re.I)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# show interfaces brief / show port status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ProCurve "show interfaces brief" tabular header line reference:
|
||||
# Port Type | Enabled Link MDI Mode FlwCtrl BCast
|
||||
# ------ ------------ + ------- ------ ----- ----------- -------- -----
|
||||
# 1 100/1000T | Yes Down Auto Unknown off 0
|
||||
|
||||
_INTF_BRIEF_RE = re.compile(
|
||||
r"^\s*(\S+)\s+" # Port
|
||||
r"(\S+)\s+\|" # Type |
|
||||
r"\s+(Yes|No)\s+" # Enabled
|
||||
r"(Up|Down)\s+" # Link
|
||||
r"\S+\s+" # MDI (ignored)
|
||||
r"(\S+)", # Mode (speed/duplex)
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def parse_interfaces_brief(output: str) -> Dict[str, Dict]:
|
||||
"""Parse ``show interfaces brief`` tabular output.
|
||||
|
||||
Returns dict keyed by port name with sub-keys:
|
||||
is_up, is_enabled, speed, duplex, description.
|
||||
"""
|
||||
interfaces: Dict[str, Dict] = {}
|
||||
for line in output.splitlines():
|
||||
m = _INTF_BRIEF_RE.match(line)
|
||||
if not m:
|
||||
continue
|
||||
port, itype, enabled, link, mode = (
|
||||
m.group(1), m.group(2), m.group(3), m.group(4), m.group(5)
|
||||
)
|
||||
speed = 0.0
|
||||
duplex = ""
|
||||
# Mode examples: "1000FDx", "100HDx", "Unknown", "Auto"
|
||||
sm = re.match(r"(\d+)(FDx|HDx)?", mode, re.I)
|
||||
if sm:
|
||||
speed = float(sm.group(1))
|
||||
duplex = "full" if (sm.group(2) or "").lower() == "fdx" else "half"
|
||||
|
||||
interfaces[port] = {
|
||||
"is_up": link.lower() == "up",
|
||||
"is_enabled": enabled.lower() == "yes",
|
||||
"speed": speed,
|
||||
"duplex": duplex,
|
||||
"description": "",
|
||||
"last_flapped": -1.0,
|
||||
"mtu": -1,
|
||||
"mac_address": "",
|
||||
}
|
||||
return interfaces
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# show interfaces (per-port detail for MAC / description)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_interface_detail(output: str, port: str) -> Dict:
|
||||
"""Parse ``show interfaces <port>`` for MAC address and description."""
|
||||
mac = procurve_mac_to_std(_kv(output, "MAC address"))
|
||||
name = _kv(output, "Name") or _kv(output, "Description")
|
||||
return {"mac_address": mac, "description": name}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# show ip / show ip interface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_ip_interfaces(output: str) -> Dict[str, Dict]:
|
||||
"""Parse ``show ip`` or ``show ip interface brief`` output.
|
||||
|
||||
Returns NAPALM-compatible dict::
|
||||
|
||||
{"VLAN1": {"ipv4": {"192.168.0.1": {"prefix_length": 24}}}}
|
||||
|
||||
ProCurve ``show ip`` multi-VLAN format::
|
||||
|
||||
IP Configuration for VLAN 1:
|
||||
IP Config : Manual
|
||||
IP Address : 192.168.0.1
|
||||
Subnet Mask : 255.255.255.0
|
||||
|
||||
IP Configuration for VLAN 99:
|
||||
...
|
||||
"""
|
||||
result: Dict[str, Dict] = {}
|
||||
current_vlan = ""
|
||||
current_ip = ""
|
||||
|
||||
for line in output.splitlines():
|
||||
# Detect VLAN section header: "IP Configuration for VLAN X" or "... VLAN 1:"
|
||||
vm = re.search(r"IP\s+Config(?:uration)?\s+for\s+(?:VLAN\s+)?(\S+)", line, re.I)
|
||||
if vm:
|
||||
raw_id = vm.group(1).rstrip(":")
|
||||
# Strip a leading "VLAN" prefix if the regex captured it (e.g. "VLAN1"→"1")
|
||||
num = re.sub(r"^VLAN", "", raw_id, flags=re.I)
|
||||
current_vlan = f"VLAN{num}"
|
||||
result.setdefault(current_vlan, {"ipv4": {}, "ipv6": {}})
|
||||
continue
|
||||
|
||||
# Single VLAN output (no header): detect from "IP Address" line
|
||||
if re.search(r"IP\s+Address\s*:", line, re.I):
|
||||
ip = line.split(":", 1)[-1].strip()
|
||||
if ip and ip not in ("0.0.0.0", ""):
|
||||
current_ip = ip
|
||||
if not current_vlan:
|
||||
current_vlan = "VLAN1"
|
||||
result.setdefault(current_vlan, {"ipv4": {}, "ipv6": {}})
|
||||
continue
|
||||
|
||||
if re.search(r"Subnet\s+Mask\s*:", line, re.I) and current_ip:
|
||||
mask = line.split(":", 1)[-1].strip()
|
||||
try:
|
||||
import netaddr
|
||||
prefix_len = netaddr.IPNetwork(f"{current_ip}/{mask}").prefixlen
|
||||
except Exception:
|
||||
prefix_len = 24
|
||||
if current_vlan:
|
||||
result[current_vlan]["ipv4"][current_ip] = {"prefix_length": prefix_len}
|
||||
current_ip = ""
|
||||
continue
|
||||
|
||||
# Clean up empty entries
|
||||
return {k: v for k, v in result.items() if v["ipv4"] or v["ipv6"]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# show arp
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ARP_RE = re.compile(
|
||||
r"(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})" # IP
|
||||
r"\s+"
|
||||
r"([0-9a-fA-F]{6}-[0-9a-fA-F]{6}|" # MAC ProCurve hex-dash format
|
||||
r"[0-9a-fA-F:]{17})" # or standard colon format
|
||||
r"\s+(\S+)" # Type
|
||||
r"(?:\s+(\S+))?", # Port (optional)
|
||||
)
|
||||
|
||||
|
||||
def parse_arp_table(output: str) -> List[Dict]:
|
||||
"""Parse ``show arp`` output into NAPALM ARP table format."""
|
||||
table = []
|
||||
for line in output.splitlines():
|
||||
m = _ARP_RE.search(line)
|
||||
if not m:
|
||||
continue
|
||||
ip, mac, entry_type, port = (
|
||||
m.group(1), m.group(2), m.group(3), m.group(4) or ""
|
||||
)
|
||||
table.append(
|
||||
{
|
||||
"interface": port,
|
||||
"mac": procurve_mac_to_std(mac),
|
||||
"ip": ip,
|
||||
"age": -1.0,
|
||||
}
|
||||
)
|
||||
return table
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# show mac-address
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MAC_RE = re.compile(
|
||||
r"([0-9a-fA-F]{6}-[0-9a-fA-F]{6}|[0-9a-fA-F:]{17})" # MAC
|
||||
r"\s+(\S+)" # Port
|
||||
r"\s+(\d+)" # VLAN
|
||||
r"\s+(\w+)", # Type
|
||||
)
|
||||
|
||||
|
||||
def parse_mac_table(output: str) -> List[Dict]:
|
||||
"""Parse ``show mac-address`` into NAPALM MAC table format."""
|
||||
table = []
|
||||
for line in output.splitlines():
|
||||
m = _MAC_RE.search(line)
|
||||
if not m:
|
||||
continue
|
||||
mac, port, vlan, entry_type = m.group(1), m.group(2), m.group(3), m.group(4)
|
||||
table.append(
|
||||
{
|
||||
"mac": procurve_mac_to_std(mac),
|
||||
"interface": port,
|
||||
"vlan": int(vlan),
|
||||
"static": entry_type.lower() in ("static", "permanent"),
|
||||
"active": True,
|
||||
"moves": -1,
|
||||
"last_move": -1.0,
|
||||
}
|
||||
)
|
||||
return table
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# show lldp info remote-device
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_lldp_neighbors(output: str) -> Dict[str, List[Dict]]:
|
||||
"""Parse ``show lldp info remote-device`` into NAPALM neighbors dict.
|
||||
|
||||
Output format::
|
||||
|
||||
LocalPort | ChassisId PortId SysName ...
|
||||
--------- + ------------------- ------------ ------------ ...
|
||||
1 | a1 b2 c3 d4 e5 f6 Gi 0/1 otherswitch ...
|
||||
"""
|
||||
neighbors: Dict[str, List[Dict]] = {}
|
||||
in_table = False
|
||||
|
||||
for line in output.splitlines():
|
||||
# Skip header / separator lines
|
||||
if re.match(r"\s*-+\s*\+", line) or re.match(r"\s*LocalPort", line, re.I):
|
||||
in_table = True
|
||||
continue
|
||||
if not in_table:
|
||||
continue
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
# Format: " 1 | aa bb cc ... Gi0/1 hostname ..."
|
||||
m = re.match(r"\s*(\S+)\s*\|\s*(.+)", line)
|
||||
if not m:
|
||||
continue
|
||||
local_port = m.group(1)
|
||||
rest = m.group(2).strip()
|
||||
|
||||
# ChassisId: may be space-separated octets (aa bb cc dd ee ff) or MAC
|
||||
# Then PortId, then SysName
|
||||
# Split by 2+ spaces to separate columns
|
||||
cols = re.split(r"\s{2,}", rest)
|
||||
if len(cols) < 3:
|
||||
continue
|
||||
chassis_raw = cols[0].strip()
|
||||
port_id = cols[1].strip()
|
||||
sys_name = cols[2].strip()
|
||||
|
||||
# Normalise chassis ID: space-separated octets → colon MAC
|
||||
chassis = re.sub(r"\s+", "", chassis_raw)
|
||||
if re.match(r"^[0-9a-fA-F]{12}$", chassis):
|
||||
chassis = ":".join(chassis[i : i + 2] for i in range(0, 12, 2))
|
||||
|
||||
neighbors.setdefault(local_port, []).append(
|
||||
{"hostname": sys_name, "port": port_id}
|
||||
)
|
||||
|
||||
return neighbors
|
||||
|
||||
|
||||
def parse_lldp_neighbors_detail(output: str) -> Dict[str, List[Dict]]:
|
||||
"""Parse ``show lldp info remote-device detail`` output.
|
||||
|
||||
Returns NAPALM lldp_neighbors_detail format.
|
||||
"""
|
||||
neighbors: Dict[str, List[Dict]] = {}
|
||||
current_port = ""
|
||||
entry: Dict = {}
|
||||
|
||||
def _save():
|
||||
if current_port and entry:
|
||||
neighbors.setdefault(current_port, []).append(entry.copy())
|
||||
|
||||
for line in output.splitlines():
|
||||
# New port section: "LLDP Remote Device Information Detail for Port N"
|
||||
m = re.search(r"for\s+[Pp]ort\s+(\S+)", line)
|
||||
if m:
|
||||
_save()
|
||||
current_port = m.group(1)
|
||||
entry = {
|
||||
"parent_interface": "",
|
||||
"remote_chassis_id": "",
|
||||
"remote_port": "",
|
||||
"remote_port_description": "",
|
||||
"remote_system_name": "",
|
||||
"remote_system_description": "",
|
||||
"remote_system_capab": [],
|
||||
"remote_system_enable_capab": [],
|
||||
}
|
||||
continue
|
||||
|
||||
if not entry:
|
||||
continue
|
||||
|
||||
low = line.lower()
|
||||
if "chassis id" in low:
|
||||
val = line.split(":", 1)[-1].strip()
|
||||
entry["remote_chassis_id"] = procurve_mac_to_std(
|
||||
re.sub(r"\s+", "", val)
|
||||
) if re.search(r"[0-9a-fA-F ]{14,}", val) else val
|
||||
elif "port id" in low and "descr" not in low:
|
||||
entry["remote_port"] = line.split(":", 1)[-1].strip()
|
||||
elif "port description" in low:
|
||||
entry["remote_port_description"] = line.split(":", 1)[-1].strip()
|
||||
elif "system name" in low:
|
||||
entry["remote_system_name"] = line.split(":", 1)[-1].strip()
|
||||
elif "system description" in low:
|
||||
entry["remote_system_description"] = line.split(":", 1)[-1].strip()
|
||||
elif "system capabilities" in low and "enabled" not in low:
|
||||
entry["remote_system_capab"] = _parse_capabilities(
|
||||
line.split(":", 1)[-1].strip()
|
||||
)
|
||||
elif "enabled capabilities" in low or ("system capabilities" in low and "enabled" in low):
|
||||
entry["remote_system_enable_capab"] = _parse_capabilities(
|
||||
line.split(":", 1)[-1].strip()
|
||||
)
|
||||
|
||||
_save()
|
||||
return neighbors
|
||||
|
||||
|
||||
def _parse_capabilities(cap_str: str) -> List[str]:
|
||||
"""Convert ProCurve capability abbreviations to NAPALM strings."""
|
||||
_MAP = {
|
||||
"B": "bridge",
|
||||
"R": "router",
|
||||
"T": "telephone",
|
||||
"W": "wlan-access-point",
|
||||
"H": "host",
|
||||
"D": "docsis-cable-device",
|
||||
"O": "other",
|
||||
"S": "station",
|
||||
"C": "customer-vlan-bridge",
|
||||
}
|
||||
result = []
|
||||
for token in re.split(r"[,\s]+", cap_str):
|
||||
token = token.strip()
|
||||
if not token:
|
||||
continue
|
||||
result.append(_MAP.get(token, token.lower()))
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# show ntp associations / show timesync
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_ntp_servers(output: str) -> Dict[str, Dict]:
|
||||
"""Parse ``show ntp associations`` or ``show ntp status`` output.
|
||||
|
||||
Returns dict of {server_ip: {}} as per NAPALM get_ntp_servers.
|
||||
"""
|
||||
servers: Dict[str, Dict] = {}
|
||||
for line in output.splitlines():
|
||||
# " 10.0.0.1 sys.peer 2 ..." or "NTP server: 10.0.0.1"
|
||||
m = re.search(r"(\d{1,3}(?:\.\d{1,3}){3})", line)
|
||||
if m:
|
||||
ip = m.group(1)
|
||||
# Skip gateway / default-gateway entries
|
||||
if "gateway" in line.lower() or "default" in line.lower():
|
||||
continue
|
||||
servers[ip] = {}
|
||||
return servers
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# show environment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_environment(output: str) -> Dict:
|
||||
"""Parse ``show environment`` output into NAPALM environment dict."""
|
||||
env: Dict = {
|
||||
"fans": {},
|
||||
"temperature": {},
|
||||
"power": {},
|
||||
"cpu": [{"%usage": 0.0}],
|
||||
"memory": {"available_ram": -1, "used_ram": -1},
|
||||
}
|
||||
|
||||
# CPU utilisation from show system-information
|
||||
m = re.search(r"CPU\s+Util(?:ization)?\s*[(%:]+\s*(\d+)", output, re.I)
|
||||
if m:
|
||||
env["cpu"][0]["%usage"] = float(m.group(1))
|
||||
|
||||
# Memory
|
||||
total_m = re.search(r"Memory\s*[-–]\s*Total\s*[:\s]+(\d+)", output, re.I)
|
||||
free_m = re.search(r"Free\s*[:\s]+(\d+)", output, re.I)
|
||||
if total_m:
|
||||
env["memory"]["available_ram"] = int(total_m.group(1))
|
||||
if free_m:
|
||||
env["memory"]["used_ram"] = (
|
||||
env["memory"]["available_ram"] - int(free_m.group(1))
|
||||
if env["memory"]["available_ram"] > 0
|
||||
else -1
|
||||
)
|
||||
|
||||
# Temperature sensors: "Sensor 1: 38C" or "Temperature: 38C"
|
||||
for m in re.finditer(
|
||||
r"(?:Sensor\s+(\d+)|Temperature(?:\s+Sensor)?)\s*[:\s]+(\d+)\s*[Cc°]",
|
||||
output,
|
||||
):
|
||||
sensor_id = f"sensor_{m.group(1) or '1'}"
|
||||
temp = float(m.group(2))
|
||||
env["temperature"][sensor_id] = {
|
||||
"temperature": temp,
|
||||
"is_alert": False,
|
||||
"is_critical": False,
|
||||
}
|
||||
|
||||
# Fan status
|
||||
for m in re.finditer(
|
||||
r"Fan\s*(\d+)\s*[:\s]+(\S+)", output, re.I
|
||||
):
|
||||
fan_id = f"fan_{m.group(1)}"
|
||||
status = m.group(2).lower()
|
||||
env["fans"][fan_id] = {"status": status in ("ok", "good", "normal", "operating")}
|
||||
|
||||
return env
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# show local-users
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_users(output: str) -> Dict[str, Dict]:
|
||||
"""Parse ``show local-users`` or ``show users`` output.
|
||||
|
||||
Returns NAPALM users dict.
|
||||
"""
|
||||
users: Dict[str, Dict] = {}
|
||||
for line in output.splitlines():
|
||||
# " manager Operator Enabled ..." or " manager Manager ..."
|
||||
m = re.match(
|
||||
r"\s+(\w+)\s+(\w+(?:\s+\w+)?)\s+(Enabled|Disabled)",
|
||||
line,
|
||||
re.I,
|
||||
)
|
||||
if not m:
|
||||
continue
|
||||
uname, level, _state = m.group(1), m.group(2).lower(), m.group(3)
|
||||
# Map privilege level
|
||||
if "manager" in level or "admin" in level:
|
||||
priv = 15
|
||||
elif "operator" in level:
|
||||
priv = 5
|
||||
else:
|
||||
priv = 1
|
||||
users[uname] = {
|
||||
"level": priv,
|
||||
"password": "",
|
||||
"sshkeys": [],
|
||||
}
|
||||
return users
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# show snmp-server / show snmp
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_snmp_info(output: str) -> Dict:
|
||||
"""Parse ``show snmp-server`` output into NAPALM snmp_information dict."""
|
||||
communities: Dict[str, Dict] = {}
|
||||
contact = _kv(output, "System Contact") or _kv(output, "Contact")
|
||||
location = _kv(output, "System Location") or _kv(output, "Location")
|
||||
chassis_id = ""
|
||||
|
||||
for line in output.splitlines():
|
||||
# " community_name Manager Restricted" (read only)
|
||||
# " community_name Operator Unrestricted" (read write)
|
||||
m = re.match(r"\s+(\S+)\s+(Manager|Operator)\s+(Restricted|Unrestricted)", line, re.I)
|
||||
if m:
|
||||
comm, _role, access = m.group(1), m.group(2), m.group(3)
|
||||
communities[comm] = {
|
||||
"acl": "",
|
||||
"mode": "ro" if access.lower() == "restricted" else "rw",
|
||||
}
|
||||
|
||||
return {
|
||||
"chassis_id": chassis_id,
|
||||
"community": communities,
|
||||
"contact": contact,
|
||||
"location": location,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# VLAN helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def expand_procurve_ports(port_str: str) -> List[str]:
|
||||
"""Expand a ProCurve port-list string into a flat list of port names.
|
||||
|
||||
Handles:
|
||||
- Numeric ranges: ``1-4,6,8`` → ``["1","2","3","4","6","8"]``
|
||||
- Alphanumeric ranges: ``A1-A8`` → ``["A1","A2",..."A8"]``
|
||||
- Trunk ports: ``Trk1,Trk2`` → ``["Trk1","Trk2"]``
|
||||
- Mixed: ``1-3,Trk1,A1`` → ``["1","2","3","Trk1","A1"]``
|
||||
"""
|
||||
ports: List[str] = []
|
||||
for token in port_str.split(","):
|
||||
token = token.strip()
|
||||
if not token:
|
||||
continue
|
||||
|
||||
# Try alphanumeric range: e.g. "A1-A8" (prefix + digits)
|
||||
alpha_range = re.match(r"^([A-Za-z]+)(\d+)-([A-Za-z]+)(\d+)$", token)
|
||||
if alpha_range:
|
||||
prefix = alpha_range.group(1)
|
||||
start = int(alpha_range.group(2))
|
||||
end = int(alpha_range.group(4))
|
||||
ports.extend(f"{prefix}{i}" for i in range(start, end + 1))
|
||||
continue
|
||||
|
||||
# Try pure numeric range: e.g. "1-4"
|
||||
num_range = re.match(r"^(\d+)-(\d+)$", token)
|
||||
if num_range:
|
||||
start = int(num_range.group(1))
|
||||
end = int(num_range.group(2))
|
||||
ports.extend(str(i) for i in range(start, end + 1))
|
||||
continue
|
||||
|
||||
# Single port (numeric, Trk, alphanumeric stacking port etc.)
|
||||
ports.append(token)
|
||||
|
||||
return ports
|
||||
|
||||
|
||||
def parse_vlans_from_running_config(config_text: str) -> Dict[int, Dict]:
|
||||
"""Parse VLAN blocks from ProCurve ``show running-config`` output.
|
||||
|
||||
Extracts ``name``, ``tagged``, and ``untagged`` port lists per VLAN.
|
||||
|
||||
Typical running-config VLAN block::
|
||||
|
||||
vlan 1
|
||||
name "DEFAULT_VLAN"
|
||||
untagged 1-4,6-8
|
||||
ip address 192.168.0.1 255.255.255.0
|
||||
exit
|
||||
|
||||
vlan 10
|
||||
name "MGMT"
|
||||
tagged 1,2,5
|
||||
exit
|
||||
|
||||
Returns extended NAPALM-compatible dict::
|
||||
|
||||
{
|
||||
1: {"name": "DEFAULT_VLAN",
|
||||
"interfaces": ["1","2","3","4","6","7","8","5"],
|
||||
"tagged": [],
|
||||
"untagged": ["1","2","3","4","6","7","8"]},
|
||||
10: {"name": "MGMT",
|
||||
"interfaces": ["1","2","5"],
|
||||
"tagged": ["1","2","5"],
|
||||
"untagged": []},
|
||||
}
|
||||
"""
|
||||
vlans: Dict[int, Dict] = {}
|
||||
current_id: Optional[int] = None
|
||||
|
||||
for line in config_text.splitlines():
|
||||
stripped = line.strip()
|
||||
|
||||
# Start of a vlan block: "vlan <id>"
|
||||
m = re.match(r"^vlan\s+(\d+)\s*$", stripped, re.I)
|
||||
if m:
|
||||
current_id = int(m.group(1))
|
||||
vlans[current_id] = {"name": f"VLAN{current_id}", "tagged": [], "untagged": []}
|
||||
continue
|
||||
|
||||
if current_id is None:
|
||||
continue
|
||||
|
||||
# End of vlan block
|
||||
if stripped.lower() == "exit":
|
||||
current_id = None
|
||||
continue
|
||||
|
||||
# name "VLAN-Name" (with or without quotes)
|
||||
nm = re.match(r'^name\s+"?([^"]+)"?\s*$', stripped, re.I)
|
||||
if nm:
|
||||
vlans[current_id]["name"] = nm.group(1).strip()
|
||||
continue
|
||||
|
||||
# untagged <portlist>
|
||||
utm = re.match(r"^untagged\s+(.+)$", stripped, re.I)
|
||||
if utm:
|
||||
vlans[current_id]["untagged"] = expand_procurve_ports(utm.group(1))
|
||||
continue
|
||||
|
||||
# tagged <portlist>
|
||||
tgm = re.match(r"^tagged\s+(.+)$", stripped, re.I)
|
||||
if tgm:
|
||||
vlans[current_id]["tagged"] = expand_procurve_ports(tgm.group(1))
|
||||
continue
|
||||
|
||||
# Populate "interfaces" as union of tagged + untagged (NAPALM standard key)
|
||||
for vdata in vlans.values():
|
||||
seen: List[str] = []
|
||||
for p in vdata["untagged"] + vdata["tagged"]:
|
||||
if p not in seen:
|
||||
seen.append(p)
|
||||
vdata["interfaces"] = seen
|
||||
|
||||
return vlans
|
||||
|
||||
|
||||
def parse_vlans_brief(output: str) -> Dict[int, str]:
|
||||
"""Parse ``show vlans`` brief table for VLAN IDs and names only.
|
||||
|
||||
Used as a discovery step when running-config is unavailable.
|
||||
|
||||
Example output::
|
||||
|
||||
VLAN ID Name | Status Voice Jumbo
|
||||
------- ---------------- + ---------- ----- -----
|
||||
1 DEFAULT_VLAN | Port-based No No
|
||||
10 MANAGEMENT | Port-based No No
|
||||
|
||||
Returns ``{vlan_id: name}`` dict.
|
||||
"""
|
||||
vlans: Dict[int, str] = {}
|
||||
in_table = False
|
||||
|
||||
for line in output.splitlines():
|
||||
if re.match(r"\s*-{4,}", line):
|
||||
in_table = True
|
||||
continue
|
||||
if not in_table:
|
||||
continue
|
||||
m = re.match(r"\s*(\d+)\s+(\S+)", line)
|
||||
if m:
|
||||
vlans[int(m.group(1))] = m.group(2)
|
||||
|
||||
return vlans
|
||||
|
||||
|
||||
def parse_vlan_ports_detail(output: str) -> Dict[str, List[str]]:
|
||||
"""Parse ``show vlans <id>`` per-VLAN port detail output.
|
||||
|
||||
Returns ``{"tagged": [...], "untagged": [...]}`` for ONE vlan.
|
||||
|
||||
Example output::
|
||||
|
||||
Port Information Mode Unknown VLAN Status
|
||||
---------------- -------- ------------ -------
|
||||
1 Untagged Learn Up
|
||||
2 Tagged Learn Up
|
||||
Trk1 Tagged Learn Up
|
||||
"""
|
||||
tagged: List[str] = []
|
||||
untagged: List[str] = []
|
||||
in_table = False
|
||||
|
||||
for line in output.splitlines():
|
||||
if re.match(r"\s*-{4,}", line):
|
||||
in_table = True
|
||||
continue
|
||||
if not in_table:
|
||||
continue
|
||||
# Columns: Port | Mode | Unknown VLAN | Status
|
||||
m = re.match(r"\s*(\S+)\s+(Tagged|Untagged)\s+", line, re.I)
|
||||
if m:
|
||||
port, mode = m.group(1), m.group(2).lower()
|
||||
if mode == "tagged":
|
||||
tagged.append(port)
|
||||
else:
|
||||
untagged.append(port)
|
||||
|
||||
return {"tagged": tagged, "untagged": untagged}
|
||||
|
||||
|
||||
def derive_port_pvids(vlans: Dict[int, Dict]) -> Dict[str, int]:
|
||||
"""Derive PVID (native/access VLAN) for each port from the ``get_vlans`` result.
|
||||
|
||||
On ProCurve a port is untagged in exactly ONE VLAN — that is its PVID.
|
||||
A port that only appears in ``tagged`` lists has no PVID (trunk-only, PVID = 1 by default).
|
||||
|
||||
Returns ``{"port": pvid_int, ...}`` mapping.
|
||||
|
||||
Example::
|
||||
|
||||
# Port 1 is untagged in VLAN 1 → PVID 1
|
||||
# Port 5 only tagged in VLAN 10 → PVID 1 (default)
|
||||
derive_port_pvids({
|
||||
1: {"untagged": ["1","2","3"], "tagged": []},
|
||||
10: {"untagged": [], "tagged": ["5"]},
|
||||
})
|
||||
# → {"1": 1, "2": 1, "3": 1, "5": 1}
|
||||
"""
|
||||
pvids: Dict[str, int] = {}
|
||||
|
||||
# First pass: assign PVID from untagged membership
|
||||
for vid, vdata in sorted(vlans.items()):
|
||||
for port in vdata.get("untagged", []):
|
||||
pvids[port] = vid
|
||||
|
||||
# Second pass: ports that only appear in tagged lists get PVID 1 (default)
|
||||
for vdata in vlans.values():
|
||||
for port in vdata.get("tagged", []):
|
||||
if port not in pvids:
|
||||
pvids[port] = 1
|
||||
|
||||
return pvids
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user