1424 lines
51 KiB
Python
1424 lines
51 KiB
Python
# -*- coding: utf-8 -*-
|
||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||
# you may not use this file except in compliance with the License.
|
||
# You may obtain a copy of the License at
|
||
#
|
||
# http://www.apache.org/licenses/LICENSE-2.0
|
||
#
|
||
# Unless required by applicable law or agreed to in writing, software
|
||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
# See the License for the specific language governing permissions and
|
||
# limitations under the License.
|
||
|
||
"""NAPALM driver for Netgear Smart Managed switches.
|
||
|
||
Tested against: GS108EPP, GS110TP, GS316EPP and compatible Smart Managed Pro series.
|
||
Netmiko device_type: ``netgear_prosafe``
|
||
|
||
CLI notes
|
||
---------
|
||
* Prompt format: ``(hostname) >`` (user) / ``(hostname) #`` (privileged) /
|
||
``(hostname) (Config)#`` (config mode).
|
||
* Key-value output uses either ``Key: value`` or ``Key..... value`` notation
|
||
depending on firmware generation.
|
||
* Interface names are ``0/1``, ``0/2``, … (slot/port) on all Smart Managed Pro
|
||
models. Link-Aggregation channels appear as ``ch1``, ``ch2``, etc.
|
||
* Config is saved with ``write memory``.
|
||
"""
|
||
|
||
import difflib
|
||
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.base import helpers as napalm_helpers
|
||
from napalm.base.exceptions import (
|
||
CommandErrorException,
|
||
ConnectionClosedException,
|
||
ConnectionException,
|
||
MergeConfigException,
|
||
ReplaceConfigException,
|
||
)
|
||
from napalm.base.netmiko_helpers import netmiko_args
|
||
import napalm.base.constants as C
|
||
|
||
|
||
class NetgearSmartDriver(SwitchDriver):
|
||
"""NAPALM driver for Netgear Smart Managed switches (GS-series)."""
|
||
|
||
VENDOR = "Netgear"
|
||
# Netmiko device type – handles prompt ``(hostname) >`` / ``(hostname) #``
|
||
NETMIKO_DEVICE_TYPE = "netgear_prosafe"
|
||
|
||
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
|
||
self.device: Optional[ConnectHandler] = None
|
||
|
||
if optional_args is None:
|
||
optional_args = {}
|
||
|
||
self.force_no_enable = optional_args.get("force_no_enable", False)
|
||
self.port = optional_args.get("port", 22)
|
||
self.use_canonical_interface = optional_args.get("canonical_int_fmt", False)
|
||
|
||
# Netgear Smart switches run older SSH stacks that advertise ssh-rsa but
|
||
# reject connections from clients that negotiate rsa-sha2-256/512 first
|
||
# (the Paramiko 3+ default). Force legacy pubkey algorithms unless the
|
||
# caller has already supplied their own disabled_algorithms override.
|
||
if "disabled_algorithms" not in optional_args:
|
||
optional_args.setdefault(
|
||
"disabled_algorithms",
|
||
{"pubkeys": ["rsa-sha2-256", "rsa-sha2-512"]},
|
||
)
|
||
|
||
# Use a generous conn_timeout for slow-to-respond embedded SSH daemons.
|
||
optional_args.setdefault("conn_timeout", 20)
|
||
optional_args.setdefault("banner_timeout", 20)
|
||
|
||
self.netmiko_optional_args = netmiko_args(optional_args)
|
||
|
||
# Config management state
|
||
self._candidate_config: Optional[str] = None
|
||
self._candidate_mode: Optional[str] = None # 'merge' or 'replace'
|
||
self._backup_config: Optional[str] = None
|
||
|
||
# ------------------------------------------------------------------
|
||
# Connection management
|
||
# ------------------------------------------------------------------
|
||
|
||
def open(self) -> None:
|
||
"""Open an SSH connection to the device."""
|
||
try:
|
||
self.device = ConnectHandler(
|
||
device_type=self.NETMIKO_DEVICE_TYPE,
|
||
host=self.hostname,
|
||
username=self.username,
|
||
password=self.password,
|
||
timeout=self.timeout,
|
||
**self.netmiko_optional_args,
|
||
)
|
||
if not self.force_no_enable:
|
||
self.device.enable()
|
||
except NetmikoTimeoutException as exc:
|
||
raise ConnectionException(
|
||
f"Cannot connect to {self.hostname}: {exc}"
|
||
) from exc
|
||
except NetmikoAuthenticationException as exc:
|
||
raise ConnectionException(
|
||
f"Authentication failed for {self.hostname}: {exc}"
|
||
) from exc
|
||
except Exception as exc:
|
||
raise ConnectionException(
|
||
f"Verbindungsaufbau fehlgeschlagen (ConnectionException): "
|
||
f"Cannot connect to {self.hostname}:\n{exc}"
|
||
) from exc
|
||
|
||
def close(self) -> None:
|
||
"""Close the SSH connection."""
|
||
if self.device:
|
||
self.device.disconnect()
|
||
self.device = None
|
||
|
||
def is_alive(self) -> Dict[str, bool]:
|
||
"""Return connection liveness without writing to the channel."""
|
||
if self.device is None:
|
||
return {"is_alive": False}
|
||
try:
|
||
return {"is_alive": self.device.remote_conn.transport.is_active()}
|
||
except (socket.error, EOFError, AttributeError):
|
||
return {"is_alive": False}
|
||
|
||
# ------------------------------------------------------------------
|
||
# Internal helpers
|
||
# ------------------------------------------------------------------
|
||
|
||
def _send_command(self, command: Union[str, List[str]]) -> str:
|
||
"""Send a command (or ordered list of fallback commands) to the device.
|
||
|
||
Uses an explicit ``expect_string`` so that Netmiko never confuses a
|
||
data line inside the output with the device prompt.
|
||
"""
|
||
prompt_pattern = rf"{re.escape(self.device.base_prompt)}[>#]"
|
||
|
||
def _do_send(cmd: str) -> str:
|
||
return self.device.send_command(
|
||
cmd,
|
||
expect_string=prompt_pattern,
|
||
read_timeout=self.timeout,
|
||
).strip()
|
||
|
||
try:
|
||
if isinstance(command, list):
|
||
output = ""
|
||
for cmd in command:
|
||
output = _do_send(cmd)
|
||
if "Invalid" not in output and "Error" not in output:
|
||
return output
|
||
return output
|
||
return _do_send(command)
|
||
except (socket.error, EOFError) as exc:
|
||
raise ConnectionClosedException(str(exc)) from exc
|
||
|
||
# ------------------------------------------------------------------
|
||
# Configuration-mode helpers
|
||
# ------------------------------------------------------------------
|
||
|
||
def _exec_prompt(self) -> str:
|
||
return rf"{re.escape(self.device.base_prompt)}[>#]"
|
||
|
||
def _conf_prompt(self) -> str:
|
||
"""Matches ``(hostname) (Config)#`` config-mode prompt."""
|
||
return rf"{re.escape(self.device.base_prompt)}\s*\(Config\)[>#]"
|
||
|
||
def _any_prompt(self) -> str:
|
||
"""Matches exec prompt and any config sub-mode prompt."""
|
||
return rf"{re.escape(self.device.base_prompt)}(?:\s*\([^)]*\))?[>#]"
|
||
|
||
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(
|
||
"exit",
|
||
expect_string=self._exec_prompt(),
|
||
read_timeout=self.timeout,
|
||
)
|
||
|
||
def _save_config(self) -> None:
|
||
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]:
|
||
"""Send config lines while in config mode; return list of rejected lines."""
|
||
ep_any = 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_any,
|
||
read_timeout=self.timeout,
|
||
).strip()
|
||
if out and ("Error" in out or "Invalid" in out or "Unknown" in out):
|
||
errors.append(f" {stripped!r}: {out}")
|
||
return errors
|
||
|
||
@staticmethod
|
||
def _parse_key_value(output: str, key: str) -> str:
|
||
"""Extract a value from Netgear ``key.... value`` or ``key: value`` output.
|
||
|
||
Both dot-separator (``show version``) and colon-separator
|
||
(``show sysinfo``) forms are handled automatically.
|
||
"""
|
||
for line in output.splitlines():
|
||
if key.lower() in line.lower():
|
||
# dots form: "Software Version............................. 6.6.3"
|
||
m = re.match(r"[^.\n]+\.{2,}\s*(.*)", line.strip())
|
||
if m:
|
||
return m.group(1).strip()
|
||
# colon form: "System Name: myswitch"
|
||
m = re.match(r"[^:\n]+:\s*(.*)", line.strip())
|
||
if m:
|
||
return m.group(1).strip()
|
||
return ""
|
||
|
||
@staticmethod
|
||
def _parse_uptime_seconds(uptime_str: str) -> float:
|
||
"""Convert Netgear uptime string to seconds.
|
||
|
||
Expected format: ``0 days 2 hrs 43 mins 18 secs``
|
||
Also handles ``day(s) hour(s) min(s) sec(s)`` variants.
|
||
"""
|
||
days = hours = minutes = seconds = 0
|
||
m = re.search(r"(\d+)\s+day", uptime_str, re.I)
|
||
if m:
|
||
days = int(m.group(1))
|
||
m = re.search(r"(\d+)\s+hr", uptime_str, re.I)
|
||
if m:
|
||
hours = int(m.group(1))
|
||
m = re.search(r"(\d+)\s+min", uptime_str, re.I)
|
||
if m:
|
||
minutes = int(m.group(1))
|
||
m = re.search(r"(\d+)\s+sec", uptime_str, re.I)
|
||
if m:
|
||
seconds = int(m.group(1))
|
||
return float(days * 86400 + hours * 3600 + minutes * 60 + seconds)
|
||
|
||
# ------------------------------------------------------------------
|
||
# NAPALM getters
|
||
# ------------------------------------------------------------------
|
||
|
||
def get_facts(self) -> Dict:
|
||
"""Return general device facts.
|
||
|
||
Combines ``show sysinfo`` and ``show version``.
|
||
|
||
Example ``show sysinfo`` output::
|
||
|
||
System Description: GS110TP Gigabit Smart Managed Pro Switch
|
||
Machine Model: GS110TP
|
||
Machine Type: Ethernet Switch
|
||
Burned In MAC Address: C4:AD:34:AB:12:34
|
||
System OID String: 1.3.6.1.4.1.4526.100.5.4
|
||
System Uptime: 0 days 2 hrs 43 mins 18 secs
|
||
System Location: Server Room
|
||
System Contact: admin@example.com
|
||
System Name: myswitch
|
||
|
||
Example ``show version`` output::
|
||
|
||
Software Version............................. 6.6.3
|
||
Loader Version............................... 1.0.0.15
|
||
Boot ROM Version............................. B1.0.0.15
|
||
Hardware Version............................. V1
|
||
Serial Number................................ 1FE2345678
|
||
"""
|
||
sysinfo = self._send_command("show sysinfo")
|
||
version = self._send_command("show version")
|
||
|
||
hostname = self._parse_key_value(sysinfo, "System Name")
|
||
|
||
# "Machine Model" is the most reliable field for the switch SKU
|
||
model = self._parse_key_value(sysinfo, "Machine Model")
|
||
if not model:
|
||
desc = self._parse_key_value(sysinfo, "System Description")
|
||
model = desc.split()[0] if desc else ""
|
||
|
||
os_version = (
|
||
self._parse_key_value(version, "Software Version")
|
||
or self._parse_key_value(sysinfo, "Software Version")
|
||
)
|
||
|
||
uptime_str = self._parse_key_value(sysinfo, "System Uptime")
|
||
uptime = self._parse_uptime_seconds(uptime_str)
|
||
|
||
serial_number = (
|
||
self._parse_key_value(version, "Serial Number")
|
||
or self._parse_key_value(sysinfo, "Serial Number")
|
||
or ""
|
||
)
|
||
|
||
interface_list = self._get_interface_list()
|
||
|
||
return {
|
||
"vendor": self.VENDOR,
|
||
"model": model,
|
||
"hostname": hostname,
|
||
"fqdn": hostname,
|
||
"os_version": os_version,
|
||
"serial_number": serial_number,
|
||
"uptime": uptime,
|
||
"interface_list": interface_list,
|
||
}
|
||
|
||
def _get_interface_list(self) -> List[str]:
|
||
"""Return sorted list of slot/port interface names from ``show port all``."""
|
||
output = self._send_command("show port all")
|
||
interfaces: List[str] = []
|
||
for line in output.splitlines():
|
||
# Physical ports: "0/1 ..." and LAG channels: "ch1 ..."
|
||
m = re.match(r"^\s*(\d+/\d+|ch\d+)\s+", line)
|
||
if m:
|
||
interfaces.append(m.group(1))
|
||
return sorted(
|
||
set(interfaces),
|
||
key=lambda s: [int(x) if x.isdigit() else x for x in re.split(r"(\d+)", s)],
|
||
)
|
||
|
||
def get_interfaces(self) -> Dict[str, Dict]:
|
||
"""Return per-interface details.
|
||
|
||
Combines ``show port all`` (link status, speed) with
|
||
``show interface all`` (description, MTU).
|
||
|
||
Example ``show port all`` columns::
|
||
|
||
Intf Type Admin Physical Physical Link
|
||
Mode Mode Status Status
|
||
------ ---- ------ ---------- ---------- ------
|
||
0/1 Enable Auto 100M/Full Up
|
||
0/2 Enable Auto - Down
|
||
|
||
Example ``show interface all`` (block per port)::
|
||
|
||
Interface................................ 0/1
|
||
Description.............................. uplink
|
||
MTU...................................... 1518
|
||
"""
|
||
port_out = self._send_command("show port all")
|
||
intf_out = self._send_command("show interface all")
|
||
return self._parse_interfaces(port_out, intf_out)
|
||
|
||
def _parse_interfaces(
|
||
self, port_output: str, intf_output: str = ""
|
||
) -> Dict[str, Dict]:
|
||
"""Parse ``show port all`` and ``show interface all`` into NAPALM format."""
|
||
interfaces: Dict[str, Dict] = {}
|
||
|
||
# --- parse 'show port all' ---
|
||
# Skip header lines until we hit a separator row, then parse data rows.
|
||
# Data row: "0/1 <type> Enable Auto 100M/Full Up ..."
|
||
# The "Type" column is often empty; splitting by whitespace is more
|
||
# robust than relying on fixed-width column positions.
|
||
in_table = False
|
||
for line in port_output.splitlines():
|
||
line_s = line.strip()
|
||
if re.match(r"^-{4,}", line_s):
|
||
in_table = True
|
||
continue
|
||
if not in_table:
|
||
continue
|
||
parts = line_s.split()
|
||
# Must start with a port name (slot/port or channel)
|
||
if not parts or not re.match(r"^(\d+/\d+|ch\d+)$", parts[0]):
|
||
continue
|
||
# With an empty Type column the fields map to:
|
||
# parts[0]=port parts[1]=admin parts[2]=phys_mode
|
||
# parts[3]=phys_status parts[4]=link_status
|
||
# When Type is present (non-empty token) the indices shift by +1;
|
||
# detect this by checking whether parts[1] looks like a type token
|
||
# (not Enable/Disable).
|
||
offset = 0
|
||
if len(parts) > 1 and parts[1].lower() not in ("enable", "disable"):
|
||
offset = 1
|
||
if len(parts) < 5 + offset:
|
||
continue
|
||
port = parts[0]
|
||
admin = parts[1 + offset] # Enable / Disable
|
||
phys_status = parts[3 + offset] # e.g. "100M/Full", "1G/Full", or "-"
|
||
link_status = parts[4 + offset] # Up / Down
|
||
|
||
speed_val = 0.0
|
||
speed_m = re.search(r"(\d+)([MG])", phys_status)
|
||
if speed_m:
|
||
speed_val = float(speed_m.group(1))
|
||
if speed_m.group(2) == "G":
|
||
speed_val *= 1000.0
|
||
|
||
interfaces[port] = {
|
||
"is_up": link_status.lower() == "up",
|
||
"is_enabled": admin.lower() == "enable",
|
||
"description": "",
|
||
"last_flapped": -1.0,
|
||
"speed": speed_val,
|
||
"mtu": 1518,
|
||
"mac_address": "",
|
||
}
|
||
|
||
# --- parse 'show interface all' (block format) ---
|
||
current_port: Optional[str] = None
|
||
for line in intf_output.splitlines():
|
||
# Block header: "Interface................................ 0/1"
|
||
m = re.match(r"^\s*Interface[.:\s]+(\d+/\d+|ch\d+)", line, re.I)
|
||
if m:
|
||
current_port = m.group(1)
|
||
continue
|
||
if current_port is None:
|
||
continue
|
||
# Description
|
||
dm = re.match(r"^\s*(?:Description|Port Description)[.:\s]+(.*)", line, re.I)
|
||
if dm:
|
||
desc = dm.group(1).strip()
|
||
if current_port in interfaces and desc:
|
||
interfaces[current_port]["description"] = desc
|
||
continue
|
||
# MTU
|
||
mm = re.match(r"^\s*MTU[.:\s]+(\d+)", line, re.I)
|
||
if mm:
|
||
if current_port in interfaces:
|
||
interfaces[current_port]["mtu"] = int(mm.group(1))
|
||
|
||
return interfaces
|
||
|
||
def get_interfaces_ip(self) -> Dict[str, Dict]:
|
||
"""Return all configured IP addresses grouped by interface.
|
||
|
||
Parses ``show ip interface`` output. On Smart Managed switches
|
||
only the management VLAN typically has an IP address.
|
||
|
||
Example output block::
|
||
|
||
IP Address....................................... 192.168.0.239
|
||
Subnet Mask...................................... 255.255.255.0
|
||
Default Gateway.................................. 192.168.0.1
|
||
"""
|
||
output = self._send_command("show ip interface")
|
||
interfaces_ip: Dict[str, Dict] = {}
|
||
|
||
# Smart Managed switches expose a single management IP block;
|
||
# some firmwares add an "Interface" header for the routing VLAN.
|
||
current_iface = "vlan1"
|
||
ip_addr = ""
|
||
|
||
for line in output.splitlines():
|
||
line_s = line.strip()
|
||
|
||
# Optional interface header
|
||
m = re.match(
|
||
r"(?:Interface|Routing Interface)[.:\s]+(vlan\d+|\d+/\d+)",
|
||
line_s,
|
||
re.I,
|
||
)
|
||
if m:
|
||
current_iface = m.group(1).lower()
|
||
ip_addr = ""
|
||
continue
|
||
|
||
m = re.match(r"IP Address[.:\s]+([\d.]+)", line_s, re.I)
|
||
if m:
|
||
ip_addr = m.group(1)
|
||
continue
|
||
|
||
m = re.match(r"Subnet Mask[.:\s]+([\d.]+)", line_s, re.I)
|
||
if m and ip_addr:
|
||
try:
|
||
net = netaddr.IPNetwork(f"{ip_addr}/{m.group(1)}")
|
||
if current_iface not in interfaces_ip:
|
||
interfaces_ip[current_iface] = {}
|
||
interfaces_ip[current_iface].setdefault("ipv4", {})[ip_addr] = {
|
||
"prefix_length": net.prefixlen
|
||
}
|
||
except (netaddr.AddrFormatError, ValueError):
|
||
pass
|
||
ip_addr = ""
|
||
|
||
return interfaces_ip
|
||
|
||
def get_config(
|
||
self,
|
||
retrieve: str = "all",
|
||
full: bool = False,
|
||
sanitized: bool = False,
|
||
format: str = "text",
|
||
) -> Dict[str, str]:
|
||
"""Return running and/or startup configuration.
|
||
|
||
Netgear Smart Managed does not support a candidate config;
|
||
that slot is always returned as an empty string.
|
||
"""
|
||
configs = {"running": "", "startup": "", "candidate": ""}
|
||
|
||
if retrieve in ("all", "running"):
|
||
configs["running"] = self._send_command("show running-config")
|
||
|
||
if retrieve in ("all", "startup"):
|
||
configs["startup"] = self._send_command("show startup-config")
|
||
|
||
if sanitized:
|
||
configs = napalm_helpers.sanitize_configs(configs, C.CISCO_SANITIZE_FILTERS)
|
||
|
||
return configs
|
||
|
||
def get_arp_table(self, vrf: str = "") -> List[Dict]:
|
||
"""Return the ARP table.
|
||
|
||
Example ``show arp`` output::
|
||
|
||
IP Address MAC Address Interface Age (min) Type
|
||
--------- ---------------- --------- --------- -------
|
||
192.168.0.1 00:11:22:33:44:55 0/0 - Local
|
||
192.168.0.100 00:aa:bb:cc:dd:ee 0/0 5 Dynamic
|
||
"""
|
||
output = self._send_command("show arp")
|
||
arp_table = []
|
||
in_table = False
|
||
|
||
for line in output.splitlines():
|
||
line_s = line.strip()
|
||
if not line_s:
|
||
continue
|
||
if re.match(r"^-{3,}", line_s):
|
||
in_table = True
|
||
continue
|
||
if not in_table:
|
||
continue
|
||
|
||
parts = line_s.split()
|
||
if len(parts) < 3:
|
||
continue
|
||
|
||
ip_addr = parts[0]
|
||
mac_raw = parts[1]
|
||
interface = parts[2]
|
||
|
||
try:
|
||
netaddr.IPAddress(ip_addr)
|
||
except (netaddr.AddrFormatError, ValueError):
|
||
continue
|
||
|
||
try:
|
||
mac_addr = napalm_helpers.mac(mac_raw)
|
||
except Exception:
|
||
mac_addr = mac_raw
|
||
|
||
arp_table.append(
|
||
{
|
||
"interface": interface,
|
||
"mac": mac_addr,
|
||
"ip": ip_addr,
|
||
"age": 0.0,
|
||
}
|
||
)
|
||
|
||
return arp_table
|
||
|
||
def get_mac_address_table(self) -> List[Dict]:
|
||
"""Return the MAC address table.
|
||
|
||
Example ``show mac-addr-table`` output::
|
||
|
||
VLAN ID MAC Address Type Port
|
||
------- ------------------ ---------- ------
|
||
1 00:11:22:33:44:55 Dynamic 0/1
|
||
1 ff:ff:ff:ff:ff:ff Management CPU
|
||
"""
|
||
output = self._send_command("show mac-addr-table")
|
||
mac_table = []
|
||
in_table = False
|
||
|
||
for line in output.splitlines():
|
||
line_s = line.strip()
|
||
if not line_s:
|
||
continue
|
||
if re.match(r"^-{3,}", line_s):
|
||
in_table = True
|
||
continue
|
||
if not in_table:
|
||
continue
|
||
|
||
parts = line_s.split()
|
||
if len(parts) < 4:
|
||
continue
|
||
|
||
try:
|
||
vlan = int(parts[0])
|
||
except ValueError:
|
||
continue
|
||
|
||
mac_raw = parts[1]
|
||
entry_type = parts[2].lower()
|
||
interface = parts[3]
|
||
|
||
try:
|
||
mac_addr = napalm_helpers.mac(mac_raw)
|
||
except Exception:
|
||
mac_addr = mac_raw
|
||
|
||
mac_table.append(
|
||
{
|
||
"mac": mac_addr,
|
||
"interface": interface,
|
||
"vlan": vlan,
|
||
"static": entry_type in ("static", "management"),
|
||
"active": True,
|
||
"moves": None,
|
||
"last_move": None,
|
||
}
|
||
)
|
||
|
||
return mac_table
|
||
|
||
def get_lldp_neighbors(self) -> Dict[str, List[Dict]]:
|
||
"""Return a dict of LLDP neighbors keyed by local port.
|
||
|
||
Example ``show lldp remote-device all`` output::
|
||
|
||
Local RemID Chassis ID Port ID System Name
|
||
Interface
|
||
--------- ----- -------------------- --------------- ---------------
|
||
0/1 1 00:1a:2b:3c:4d:5e 0/1 router-1
|
||
"""
|
||
neighbors: Dict[str, List[Dict]] = {}
|
||
for row in self._get_lldp_table():
|
||
neighbors.setdefault(row["local_port"], []).append(
|
||
{"hostname": row["system_name"], "port": row["port_id"]}
|
||
)
|
||
return neighbors
|
||
|
||
def _get_lldp_table(self) -> List[Dict]:
|
||
"""Parse ``show lldp remote-device all`` into a list of row dicts."""
|
||
output = self._send_command("show lldp remote-device all")
|
||
rows: List[Dict] = []
|
||
in_table = False
|
||
|
||
for line in output.splitlines():
|
||
line_s = line.strip()
|
||
if not line_s:
|
||
continue
|
||
if re.match(r"^-{4,}", line_s):
|
||
in_table = True
|
||
continue
|
||
if not in_table:
|
||
continue
|
||
|
||
parts = line_s.split()
|
||
# Columns: Local Interface, RemID, Chassis ID, Port ID, System Name
|
||
if len(parts) < 3:
|
||
continue
|
||
if not re.match(r"^\d+/\d+$", parts[0]):
|
||
continue
|
||
|
||
rows.append(
|
||
{
|
||
"local_port": parts[0],
|
||
"remote_id": parts[1] if len(parts) > 1 else "",
|
||
"remote_chassis_id": parts[2] if len(parts) > 2 else "",
|
||
"port_id": parts[3] if len(parts) > 3 else "",
|
||
"system_name": " ".join(parts[4:]) if len(parts) > 4 else "",
|
||
}
|
||
)
|
||
|
||
return rows
|
||
|
||
def get_lldp_neighbors_detail(self, interface: str = "") -> Dict[str, List[Dict]]:
|
||
"""Return detailed LLDP neighbor information.
|
||
|
||
Fetches per-port detail via ``show lldp remote-device detail <port>``
|
||
and falls back to the summary table values for missing fields.
|
||
"""
|
||
details: Dict[str, List[Dict]] = {}
|
||
|
||
for row in self._get_lldp_table():
|
||
if interface and row["local_port"] != interface:
|
||
continue
|
||
|
||
detail_out = self._send_command(
|
||
f"show lldp remote-device detail {row['local_port']}"
|
||
)
|
||
parsed = self._parse_lldp_detail(detail_out)
|
||
|
||
# Fill in from summary table if the detail command returned nothing
|
||
if not parsed["remote_chassis_id"]:
|
||
parsed["remote_chassis_id"] = row["remote_chassis_id"]
|
||
if not parsed["remote_port"]:
|
||
parsed["remote_port"] = row["port_id"]
|
||
if not parsed["remote_system_name"]:
|
||
parsed["remote_system_name"] = row["system_name"]
|
||
|
||
details.setdefault(row["local_port"], []).append(parsed)
|
||
|
||
return details
|
||
|
||
@staticmethod
|
||
def _parse_lldp_detail(output: str) -> Dict:
|
||
"""Parse ``show lldp remote-device detail`` block output."""
|
||
result: Dict = {
|
||
"parent_interface": "",
|
||
"remote_port": "",
|
||
"remote_port_description": "",
|
||
"remote_chassis_id": "",
|
||
"remote_system_name": "",
|
||
"remote_system_description": "",
|
||
"remote_system_capab": [],
|
||
"remote_system_enable_capab": [],
|
||
}
|
||
|
||
for line in output.splitlines():
|
||
line_s = line.strip()
|
||
# Handle both dot-separator and colon-separator
|
||
m = re.match(r"([^.\n]+)\.{2,}\s*(.*)", line_s)
|
||
if not m:
|
||
m = re.match(r"([^:\n]+):\s*(.*)", line_s)
|
||
if not m:
|
||
continue
|
||
|
||
key = m.group(1).strip().lower()
|
||
val = m.group(2).strip()
|
||
|
||
if "chassis id" in key:
|
||
result["remote_chassis_id"] = val
|
||
elif "port id" in key and "desc" not in key:
|
||
result["remote_port"] = val
|
||
elif "port desc" in key or "port description" in key:
|
||
result["remote_port_description"] = val
|
||
elif "system name" in key:
|
||
result["remote_system_name"] = val
|
||
elif "system desc" in key or "system description" in key:
|
||
result["remote_system_description"] = val
|
||
elif "system cap" in key and "enabl" not in key:
|
||
result["remote_system_capab"] = [
|
||
c.strip().lower() for c in val.split(",") if c.strip()
|
||
]
|
||
elif "enabled cap" in key or ("system cap" in key and "enabl" in key):
|
||
result["remote_system_enable_capab"] = [
|
||
c.strip().lower() for c in val.split(",") if c.strip()
|
||
]
|
||
|
||
return result
|
||
|
||
def get_vlans(self) -> Dict[str, Dict]:
|
||
"""Return VLAN information.
|
||
|
||
Example ``show vlan`` output::
|
||
|
||
VLAN ID VLAN Name VLAN Type Interface(s)
|
||
------- --------------- ---------- -----------------------------------
|
||
1 Default Default 0/1-0/8
|
||
10 Management Static 0/1, 0/3
|
||
"""
|
||
output = self._send_command("show vlan")
|
||
vlans: Dict[str, Dict] = {}
|
||
current_id: Optional[str] = None
|
||
in_table = False
|
||
|
||
for line in output.splitlines():
|
||
line_s = line.strip()
|
||
if not line_s:
|
||
continue
|
||
if re.match(r"^-{3,}", line_s):
|
||
in_table = True
|
||
continue
|
||
if not in_table:
|
||
continue
|
||
|
||
m = re.match(r"^(\d+)\s+(\S+)\s+\S+\s*(.*)", line_s)
|
||
if m:
|
||
current_id = str(int(m.group(1)))
|
||
vlans[current_id] = {
|
||
"name": m.group(2),
|
||
"interfaces": self._parse_vlan_ports(m.group(3).strip()),
|
||
}
|
||
elif current_id is not None and line_s:
|
||
# Continuation line with additional ports
|
||
vlans[current_id]["interfaces"].extend(self._parse_vlan_ports(line_s))
|
||
|
||
return vlans
|
||
|
||
@staticmethod
|
||
def _parse_vlan_ports(ports_raw: str) -> List[str]:
|
||
"""Parse Netgear VLAN port string and expand ranges.
|
||
|
||
Input examples::
|
||
"0/1-0/8" → ["0/1", "0/2", …, "0/8"]
|
||
"0/1, 0/3, 0/5" → ["0/1", "0/3", "0/5"]
|
||
"0/1-4" → ["0/1", "0/2", "0/3", "0/4"]
|
||
"""
|
||
interfaces: List[str] = []
|
||
for token in ports_raw.split(","):
|
||
token = token.strip()
|
||
if not token:
|
||
continue
|
||
# Range: "0/1-0/8" (fully qualified) or "0/1-8" (short form)
|
||
range_m = re.match(r"^(\d+)/(\d+)-(?:(\d+)/)?(\d+)$", token)
|
||
if range_m:
|
||
slot = range_m.group(1)
|
||
start = int(range_m.group(2))
|
||
end = int(range_m.group(4))
|
||
interfaces.extend(f"{slot}/{i}" for i in range(start, end + 1))
|
||
elif re.match(r"^\d+/\d+$", token):
|
||
interfaces.append(token)
|
||
return interfaces
|
||
|
||
# ------------------------------------------------------------------
|
||
# NAPALM configuration management
|
||
# ------------------------------------------------------------------
|
||
|
||
def load_merge_candidate(
|
||
self, filename: Optional[str] = None, config: Optional[str] = None
|
||
) -> None:
|
||
"""Stage CLI commands to be merged into the running configuration.
|
||
|
||
*config* is a plain-text string of CLI commands as typed in config
|
||
mode (one command per line). Sub-mode entry/exit lines are supported.
|
||
Blank lines and lines starting with ``!`` or ``#`` are ignored.
|
||
|
||
The configuration is **not** applied until :meth:`commit_config`.
|
||
|
||
:raises MergeConfigException: on invalid input.
|
||
"""
|
||
if filename is not None:
|
||
try:
|
||
with open(filename) as fh:
|
||
config = fh.read()
|
||
except OSError as exc:
|
||
raise MergeConfigException(str(exc)) from exc
|
||
if config is None:
|
||
raise MergeConfigException("Either 'filename' or 'config' must be provided.")
|
||
self._candidate_config = config
|
||
self._candidate_mode = "merge"
|
||
|
||
def load_replace_candidate(
|
||
self, filename: Optional[str] = None, config: Optional[str] = None
|
||
) -> None:
|
||
"""Stage a full running-config replacement candidate.
|
||
|
||
Netgear Smart Managed does not support atomic config replace;
|
||
commit applies the candidate lines additively (same as merge).
|
||
:meth:`compare_config` still shows a full unified diff.
|
||
|
||
:raises ReplaceConfigException: on invalid input.
|
||
"""
|
||
if filename is not None:
|
||
try:
|
||
with open(filename) as fh:
|
||
config = fh.read()
|
||
except OSError as exc:
|
||
raise ReplaceConfigException(str(exc)) from exc
|
||
if config is None:
|
||
raise ReplaceConfigException("Either 'filename' or 'config' must be provided.")
|
||
self._candidate_config = config
|
||
self._candidate_mode = "replace"
|
||
|
||
def compare_config(self) -> str:
|
||
"""Return a human-readable diff of the pending candidate vs running config.
|
||
|
||
Merge candidate → lines prefixed with ``+``.
|
||
Replace candidate → unified diff against current running config.
|
||
Returns an empty string when no candidate is staged.
|
||
"""
|
||
if self._candidate_config is None:
|
||
return ""
|
||
|
||
if self._candidate_mode == "merge":
|
||
lines = []
|
||
for line in self._candidate_config.splitlines():
|
||
if line.strip() and not line.strip().startswith(("!", "#")):
|
||
lines.append(f"+{line}")
|
||
return "\n".join(lines)
|
||
|
||
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 staged candidate configuration to the device and persist it.
|
||
|
||
1. Saves current running config as rollback backup.
|
||
2. Enters config mode and pushes the candidate lines.
|
||
3. Returns to exec mode.
|
||
4. Persists with ``write memory``.
|
||
|
||
:raises MergeConfigException / ReplaceConfigException: if no candidate
|
||
is staged or if lines are rejected by the device.
|
||
"""
|
||
if self._candidate_config is None:
|
||
raise MergeConfigException("No candidate configuration is staged.")
|
||
|
||
ex_cls = (
|
||
ReplaceConfigException
|
||
if self._candidate_mode == "replace"
|
||
else MergeConfigException
|
||
)
|
||
|
||
self._backup_config = self._send_command("show running-config")
|
||
|
||
errors: List[str] = []
|
||
try:
|
||
self._enter_config_mode()
|
||
errors = self._apply_config_lines(self._candidate_config)
|
||
finally:
|
||
self._exit_config_mode()
|
||
|
||
if errors:
|
||
raise ex_cls("The following commands were rejected:\n" + "\n".join(errors))
|
||
|
||
self._save_config()
|
||
self._candidate_config = None
|
||
self._candidate_mode = None
|
||
|
||
def discard_config(self) -> None:
|
||
"""Discard the staged candidate configuration without applying it."""
|
||
self._candidate_config = None
|
||
self._candidate_mode = None
|
||
|
||
def rollback(self) -> None:
|
||
"""Revert the running config to the state before the last commit.
|
||
|
||
:raises CommandErrorException: if :meth:`commit_config` has not been
|
||
called in this session.
|
||
"""
|
||
if self._backup_config is None:
|
||
raise CommandErrorException(
|
||
"No backup configuration available – "
|
||
"commit_config has not been called in this session."
|
||
)
|
||
|
||
current = self._send_command("show running-config")
|
||
rollback_cmds = NetgearSmartDriver._diff_to_commands(self._backup_config, current)
|
||
|
||
if rollback_cmds:
|
||
try:
|
||
self._enter_config_mode()
|
||
self._apply_config_lines("\n".join(rollback_cmds))
|
||
finally:
|
||
self._exit_config_mode()
|
||
self._save_config()
|
||
|
||
self._backup_config = None
|
||
|
||
@staticmethod
|
||
def _parse_config_blocks(config_text: str) -> Dict[str, List[str]]:
|
||
"""Parse a running-config into ``context → [command lines]`` mapping.
|
||
|
||
The special key ``"__global__"`` holds top-level commands.
|
||
Sub-mode blocks (``interface …``, ``vlan …``) are stored under the
|
||
opening line that introduces them.
|
||
"""
|
||
blocks: Dict[str, List[str]] = {"__global__": []}
|
||
ctx = "__global__"
|
||
|
||
for line in config_text.splitlines():
|
||
stripped = line.strip()
|
||
|
||
if not stripped or stripped == "!":
|
||
ctx = "__global__"
|
||
continue
|
||
|
||
if stripped.startswith("!"):
|
||
continue
|
||
|
||
if re.match(r"^(interface|vlan)\s+\S+", stripped, re.I):
|
||
ctx = stripped
|
||
if ctx not in blocks:
|
||
blocks[ctx] = []
|
||
continue
|
||
|
||
if stripped.lower() == "exit":
|
||
ctx = "__global__"
|
||
continue
|
||
|
||
blocks.setdefault(ctx, []).append(stripped)
|
||
|
||
return blocks
|
||
|
||
@staticmethod
|
||
def _negate_command(cmd: str) -> Optional[str]:
|
||
"""Return the ``no`` form of *cmd*, or ``None`` if not known."""
|
||
for kw in (
|
||
"description",
|
||
"name",
|
||
"spanning-tree",
|
||
"lldp",
|
||
"ip address",
|
||
):
|
||
if re.match(rf"^{re.escape(kw)}\b", cmd, re.I):
|
||
return f"no {kw}"
|
||
return None
|
||
|
||
@staticmethod
|
||
def _diff_to_commands(backup: str, current: str) -> List[str]:
|
||
"""Generate the minimal set of config commands to revert *current* to *backup*."""
|
||
backup_blocks = NetgearSmartDriver._parse_config_blocks(backup)
|
||
current_blocks = NetgearSmartDriver._parse_config_blocks(current)
|
||
|
||
cmds: List[str] = []
|
||
all_ctxs = set(backup_blocks.keys()) | set(current_blocks.keys())
|
||
|
||
for ctx in sorted(all_ctxs):
|
||
backup_lines = set(backup_blocks.get(ctx, []))
|
||
current_lines = set(current_blocks.get(ctx, []))
|
||
|
||
if backup_lines == current_lines:
|
||
continue
|
||
|
||
in_block = ctx != "__global__"
|
||
if in_block:
|
||
cmds.append(ctx)
|
||
|
||
# Lines added since backup → negate them
|
||
for line in current_lines - backup_lines:
|
||
neg = NetgearSmartDriver._negate_command(line)
|
||
if neg:
|
||
cmds.append(f" {neg}" if in_block else neg)
|
||
|
||
# Lines removed since backup → restore them
|
||
for line in backup_lines - current_lines:
|
||
cmds.append(f" {line}" if in_block else line)
|
||
|
||
if in_block:
|
||
cmds.append("exit")
|
||
|
||
return cmds
|
||
|
||
# ------------------------------------------------------------------
|
||
# Additional NAPALM getters
|
||
# ------------------------------------------------------------------
|
||
|
||
def has_pending_commit(self) -> bool:
|
||
"""Return True when a candidate configuration is staged."""
|
||
return self._candidate_config is not None
|
||
|
||
def get_environment(self) -> Dict:
|
||
"""Return device environment data (CPU, memory).
|
||
|
||
Netgear Smart Managed switches do not expose fan, temperature, or
|
||
power rail data through the CLI; those fields are returned as assumed
|
||
healthy (``status: True``) with ``-1.0`` for numeric values.
|
||
|
||
Tries ``show process cpu`` and ``show memory`` for utilisation data.
|
||
"""
|
||
cpu_out = self._send_command(["show process cpu", "show cpu"])
|
||
mem_out = self._send_command(["show memory", "show memory cpu"])
|
||
|
||
cpu_pct = 0.0
|
||
m = re.search(r"(?:CPU Utilization|utilization)[.:\s]+(\d+)%?", cpu_out, re.I)
|
||
if m:
|
||
cpu_pct = float(m.group(1))
|
||
else:
|
||
# Some firmwares: "5 percent"
|
||
m = re.search(r"(\d+)\s+percent", cpu_out, re.I)
|
||
if m:
|
||
cpu_pct = float(m.group(1))
|
||
|
||
mem_pct = 0.0
|
||
m = re.search(r"(?:Memory Utilization|utilization)[.:\s]+(\d+)%?", mem_out, re.I)
|
||
if m:
|
||
mem_pct = float(m.group(1))
|
||
|
||
return {
|
||
"fans": {},
|
||
"temperature": {},
|
||
"power": {},
|
||
"cpu": {0: {"%usage": cpu_pct}},
|
||
"memory": {
|
||
"available_ram": int((1 - mem_pct / 100) * 100),
|
||
"used_ram": int(mem_pct),
|
||
},
|
||
}
|
||
|
||
def get_interfaces_counters(self) -> Dict[str, Dict]:
|
||
"""Return per-interface packet and byte counters.
|
||
|
||
Parses ``show interface counters`` (or ``show interface ethernet 0/1``).
|
||
Each block starts with ``Interface................................ 0/1``.
|
||
"""
|
||
output = self._send_command(
|
||
["show interface counters", "show interface ethernet all"]
|
||
)
|
||
|
||
def _int(s: str) -> int:
|
||
return int(s.replace(",", "")) if s.strip().replace(",", "").isdigit() else 0
|
||
|
||
counters: Dict[str, Dict] = {}
|
||
current_port: Optional[str] = None
|
||
current: Optional[Dict] = None
|
||
|
||
_zero: Dict = {
|
||
"tx_errors": 0, "rx_errors": 0,
|
||
"tx_discards": 0, "rx_discards": 0,
|
||
"tx_octets": 0, "rx_octets": 0,
|
||
"tx_unicast_packets": 0, "rx_unicast_packets": 0,
|
||
"tx_multicast_packets": 0, "rx_multicast_packets": 0,
|
||
"tx_broadcast_packets": 0, "rx_broadcast_packets": 0,
|
||
}
|
||
|
||
_mapping = {
|
||
"tx error": "tx_errors",
|
||
"rx error": "rx_errors",
|
||
"tx discard": "tx_discards",
|
||
"rx discard": "rx_discards",
|
||
"tx byte": "tx_octets",
|
||
"rx byte": "rx_octets",
|
||
"tx unicast": "tx_unicast_packets",
|
||
"rx unicast": "rx_unicast_packets",
|
||
"tx multicast": "tx_multicast_packets",
|
||
"rx multicast": "rx_multicast_packets",
|
||
"tx broadcast": "tx_broadcast_packets",
|
||
"rx broadcast": "rx_broadcast_packets",
|
||
}
|
||
|
||
for line in output.splitlines():
|
||
m = re.match(r"^\s*Interface[.:\s]+(\d+/\d+|ch\d+)", line, re.I)
|
||
if m:
|
||
if current_port and current:
|
||
counters[current_port] = current
|
||
current_port = m.group(1)
|
||
current = dict(_zero)
|
||
continue
|
||
|
||
if current is None:
|
||
continue
|
||
|
||
# Parse "Key.... value" or "Key: value"
|
||
kv = re.match(r"([^.\n]+)[.:\.]{2,}\s*(.*)", line.strip())
|
||
if not kv:
|
||
kv = re.match(r"([^:\n]+):\s*(.*)", line.strip())
|
||
if not kv:
|
||
continue
|
||
key = kv.group(1).strip().lower()
|
||
val = kv.group(2).strip()
|
||
|
||
for pat, field in _mapping.items():
|
||
if pat in key:
|
||
current[field] = _int(val)
|
||
break
|
||
|
||
if current_port and current:
|
||
counters[current_port] = current
|
||
|
||
return counters
|
||
|
||
def get_users(self) -> Dict[str, Dict]:
|
||
"""Return local user accounts.
|
||
|
||
Example ``show users`` output::
|
||
|
||
User Access Level Session Timeout Password Strength
|
||
-------- ------------ --------------- -----------------
|
||
admin Read/Write 5 Enabled
|
||
"""
|
||
output = self._send_command("show users")
|
||
users: Dict[str, Dict] = {}
|
||
in_table = False
|
||
|
||
for line in output.splitlines():
|
||
line_s = line.strip()
|
||
if not line_s:
|
||
continue
|
||
if re.match(r"^-{4,}", line_s):
|
||
in_table = True
|
||
continue
|
||
if not in_table:
|
||
continue
|
||
|
||
parts = line_s.split()
|
||
if len(parts) < 2:
|
||
continue
|
||
|
||
username = parts[0]
|
||
role = parts[1].lower()
|
||
level = 15 if "write" in role or "admin" in role else 1
|
||
users[username] = {"level": level, "password": "", "sshkeys": []}
|
||
|
||
return users
|
||
|
||
def get_snmp_information(self) -> Dict:
|
||
"""Return SNMP configuration.
|
||
|
||
Parses ``show sysinfo`` for contact/location/chassis-ID and
|
||
``show snmp`` for community strings.
|
||
"""
|
||
sysinfo = self._send_command("show sysinfo")
|
||
contact = self._parse_key_value(sysinfo, "System Contact")
|
||
location = self._parse_key_value(sysinfo, "System Location")
|
||
mac = self._parse_key_value(sysinfo, "Burned In MAC Address").replace("-", ":").upper()
|
||
|
||
snmp_out = self._send_command("show snmp")
|
||
communities: Dict[str, Dict] = {}
|
||
|
||
in_comm = False
|
||
for line in snmp_out.splitlines():
|
||
line_s = line.strip()
|
||
if "Community" in line_s and ("Name" in line_s or "String" in line_s):
|
||
in_comm = True
|
||
continue
|
||
if not in_comm or not line_s:
|
||
continue
|
||
if re.match(r"^-{3,}", line_s):
|
||
continue
|
||
parts = line_s.split()
|
||
if len(parts) >= 2:
|
||
comm_name = parts[0]
|
||
# Access mode may be "Read Only" (2 tokens) or "Read Write" (2 tokens)
|
||
access = " ".join(parts[1:3]).lower()
|
||
mode = "rw" if "write" in access or "rw" in access else "ro"
|
||
communities[comm_name] = {"acl": "", "mode": mode}
|
||
|
||
return {
|
||
"contact": contact,
|
||
"location": location,
|
||
"community": communities,
|
||
"chassis_id": mac,
|
||
}
|
||
|
||
def get_ntp_servers(self) -> Dict[str, Dict]:
|
||
"""Return configured NTP/SNTP servers from ``show sntp server``."""
|
||
output = self._send_command(["show sntp server", "show ntp server"])
|
||
servers: Dict[str, Dict] = {}
|
||
|
||
for line in output.splitlines():
|
||
line_s = line.strip()
|
||
# Key/value line: "SNTP Server Address..... 192.168.0.1"
|
||
# Use re.search so "SNTP Server Address" is also matched.
|
||
m = re.search(
|
||
r"Server\s+(?:Address|IP|Host)?[.:\s]+((?:\d{1,3}\.){3}\d{1,3}|[\w][\w.-]+\.\w+)",
|
||
line_s,
|
||
re.I,
|
||
)
|
||
if m:
|
||
servers[m.group(1)] = {}
|
||
continue
|
||
# Plain IP in a table row (first column)
|
||
m = re.match(r"^((?:\d{1,3}\.){3}\d{1,3})", line_s)
|
||
if m:
|
||
servers[m.group(1)] = {}
|
||
|
||
return servers
|
||
|
||
def get_ntp_peers(self) -> Dict[str, Dict]:
|
||
"""Return NTP peers (same as servers on Netgear Smart Managed)."""
|
||
return self.get_ntp_servers()
|
||
|
||
def get_ntp_stats(self) -> List[Dict]:
|
||
"""Netgear Smart Managed CLI does not expose per-peer NTP statistics."""
|
||
return []
|
||
|
||
def get_optics(self) -> Dict:
|
||
"""SFP DDM/DOM data is not accessible via the Smart Managed CLI."""
|
||
return {}
|
||
|
||
def get_ipv6_neighbors_table(self) -> List[Dict]:
|
||
"""IPv6 ND table is not exposed via the Smart Managed CLI."""
|
||
return []
|
||
|
||
def get_route_to(
|
||
self,
|
||
destination: str = "",
|
||
protocol: str = "",
|
||
longer: bool = False,
|
||
) -> Dict[str, List[Dict]]:
|
||
"""Return routing table entries.
|
||
|
||
Smart Managed Pro switches support only a default gateway;
|
||
``show ip route`` is parsed where available.
|
||
"""
|
||
output = self._send_command("show ip route")
|
||
routes: Dict[str, List[Dict]] = {}
|
||
proto_map = {"c": "connected", "s": "static"}
|
||
|
||
for line in output.splitlines():
|
||
line_s = line.strip()
|
||
if not line_s or re.match(r"^[Cc]ode", line_s):
|
||
continue
|
||
|
||
m = re.match(
|
||
r"^([A-Za-z])\*?\s+([\d./]+)"
|
||
r"(?:\s+\[(\d+)/(\d+)\])?"
|
||
r"(?:\s+via\s+([\d.]+))?",
|
||
line_s,
|
||
)
|
||
if not m:
|
||
continue
|
||
|
||
code = m.group(1).lower()
|
||
prefix = m.group(2)
|
||
preference = int(m.group(3)) if m.group(3) else 0
|
||
next_hop = m.group(5) or ""
|
||
prot = proto_map.get(code, code)
|
||
connected = code == "c"
|
||
|
||
if destination and prefix != destination:
|
||
continue
|
||
if protocol and prot != protocol.lower():
|
||
continue
|
||
|
||
entry = {
|
||
"protocol": prot,
|
||
"current_active": True,
|
||
"last_active": False,
|
||
"age": -1,
|
||
"next_hop": next_hop if not connected else "",
|
||
"outgoing_interface": "",
|
||
"selected_next_hop": True,
|
||
"preference": preference,
|
||
"inactive_reason": "",
|
||
"routing_table": "global",
|
||
"protocol_attributes": {},
|
||
}
|
||
routes.setdefault(prefix, []).append(entry)
|
||
|
||
return routes
|
||
|
||
def ping(
|
||
self,
|
||
destination: str,
|
||
source: str = "",
|
||
ttl: int = 255,
|
||
timeout: int = 2,
|
||
size: int = 100,
|
||
count: int = 5,
|
||
vrf: str = "",
|
||
source_interface: str = "",
|
||
) -> Dict:
|
||
"""Ping *destination* from the device.
|
||
|
||
Uses ``ping <dst> count <N>``. Source IP and packet size selection
|
||
are not supported by the Smart Managed CLI.
|
||
|
||
:returns: NAPALM-standard ping result dict.
|
||
"""
|
||
cmd = f"ping {destination} count {count}"
|
||
output = self._send_command(cmd)
|
||
|
||
if "Error" in output or "Invalid" in output:
|
||
return {"error": output.strip()}
|
||
|
||
sent = received = 0
|
||
m = re.search(
|
||
r"(\d+)\s+packets?\s+transmitted.*?(\d+)\s+(?:packets?\s+)?received",
|
||
output,
|
||
re.I | re.S,
|
||
)
|
||
if m:
|
||
sent = int(m.group(1))
|
||
received = int(m.group(2))
|
||
|
||
rtt_min = rtt_max = rtt_avg = 0.0
|
||
m = re.search(
|
||
r"min/avg/max\s*=\s*([\d.]+)/([\d.]+)/([\d.]+)",
|
||
output,
|
||
re.I,
|
||
)
|
||
if m:
|
||
rtt_min = float(m.group(1))
|
||
rtt_avg = float(m.group(2))
|
||
rtt_max = float(m.group(3))
|
||
|
||
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": [],
|
||
}
|
||
}
|
||
|
||
def cli(
|
||
self,
|
||
commands: List[str],
|
||
encoding: str = "text",
|
||
) -> Dict[str, Union[str, Dict[str, Any]]]:
|
||
"""Execute a list of CLI commands and return their raw output."""
|
||
if encoding != "text":
|
||
raise NotImplementedError(
|
||
f"Encoding '{encoding}' is not supported by this driver."
|
||
)
|
||
result: Dict[str, Union[str, Dict[str, Any]]] = {}
|
||
for cmd in commands:
|
||
result[cmd] = self._send_command(cmd)
|
||
return result
|