These switches have no CLI at all — no SSH, no Telnet and no ArubaOS-Switch REST API — so the ProCurve driver cannot serve them despite the shared vendor. The only management surface is the web UI, which ships its table data as JavaScript array literals; those parse with ast.literal_eval, so the driver needs no HTML parser and no dependency beyond napalm/requests. Read-only by design: the platform exposes a single administrator account with no privilege levels, and serves HTTPS only after a certificate has been uploaded, so the polling credential is necessarily the admin credential over a plain channel. Implements get_facts, get_interfaces, get_vlans, get_vlans_detail and get_mac_address_table, plus HTTP/SNMP fingerprints for discovery. Tested against an HPE OfficeConnect 1820 8G PoE+ (65W), J9982A, PT.02.19.
242 lines
9.5 KiB
Python
242 lines
9.5 KiB
Python
"""NAPALM driver for HPE OfficeConnect 1820 / 1920S web-managed switches.
|
|
|
|
These are Broadcom FASTPATH-derived "smart managed" switches with **no CLI
|
|
at all** — no SSH, no Telnet, and no ArubaOS-Switch REST API. The only
|
|
management surface is the web UI, so this driver drives that UI directly.
|
|
That is why it shares no code with ``napalm-hpe-aruba-procurve``: a
|
|
ProCurve/ArubaOS-Switch speaks ``show``/``configure`` over SSH, which does
|
|
not exist here.
|
|
|
|
The driver is deliberately **read-only**. These switches expose a single
|
|
administrator account with no privilege levels, and unless an operator has
|
|
uploaded a certificate the session runs over plain HTTP — so the credential
|
|
used for polling is necessarily the admin credential. Not implementing any
|
|
write path keeps this driver from being the thing that changes a switch
|
|
over an unauthenticated channel.
|
|
|
|
Tested against: HPE OfficeConnect 1820 8G PoE+ (65W), J9982A, firmware PT.02.19.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from typing import Any, ClassVar
|
|
|
|
from napalm_device_types import FingerprintRule, SwitchDriver
|
|
|
|
from napalm_hpe_officeconnect import parsers
|
|
from napalm_hpe_officeconnect.client import OfficeConnectClient
|
|
|
|
logger = logging.getLogger("napalm_hpe_officeconnect")
|
|
|
|
_SPEED_RE = re.compile(r"(\d+)\s*Mbps", re.IGNORECASE)
|
|
|
|
# Participation values that make an interface a member of a VLAN.
|
|
_MEMBER_STATES = ("untagged", "tagged")
|
|
|
|
|
|
class OfficeConnectDriver(SwitchDriver):
|
|
"""NAPALM driver for HPE OfficeConnect web-managed switches."""
|
|
|
|
VENDOR = "HPE"
|
|
DRIVER_NAME = "hpe_officeconnect"
|
|
|
|
# The ProCurve driver claims the bare HPE enterprise arc (1.3.6.1.4.1.11).
|
|
# OfficeConnect models sit under .2.3.7.11 — claiming the longer arc keeps
|
|
# the two drivers from competing for the same device.
|
|
SNMP_OBJECT_ID_PREFIX = "1.3.6.1.4.1.11.2.3.7.11"
|
|
|
|
# Verified on a J9982A. Deliberately narrow — a wrong OUI produces false
|
|
# positives, while a missing one only costs a few points of confidence.
|
|
OUI_PREFIXES: ClassVar[list[str]] = ["70:10:6F"]
|
|
|
|
HTTP_FINGERPRINT: ClassVar[list[FingerprintRule]] = [
|
|
# The login page title reads e.g.
|
|
# "HPE OfficeConnect Switch 1820 8G PoE+ (65W) J9982A".
|
|
FingerprintRule("officeconnect", weight=9.0, mandatory=True),
|
|
FingerprintRule("1820", weight=6.0),
|
|
FingerprintRule("1920s", weight=6.0),
|
|
FingerprintRule("hewlett packard enterprise", weight=3.0),
|
|
]
|
|
|
|
# sysDescr repeats the model string, e.g. "HPE OfficeConnect Switch 1820
|
|
# 8G PoE+ (65W) J9982A, PT.02.19, Linux 3.6.5-…, U-Boot 2012.10-…".
|
|
# Not mandatory: HTTP already carries the hard requirement, and a device
|
|
# reachable only over SNMP should still be able to score.
|
|
SNMP_FINGERPRINT: ClassVar[list[FingerprintRule]] = [
|
|
FingerprintRule("officeconnect", weight=9.0),
|
|
FingerprintRule("1820", weight=6.0),
|
|
FingerprintRule("1920s", weight=6.0),
|
|
]
|
|
|
|
# --- Web UI endpoints -------------------------------------------------
|
|
DASHBOARD = "/htdocs/pages/base/dashboard.lsp"
|
|
PORT_SUMMARY = "/htdocs/pages/base/port_summary.lsp"
|
|
PORT_STATS = "/htdocs/pages/base/port_summary_stats.lsp"
|
|
MAC_TABLE = "/htdocs/pages/base/mac_address_table.lsp"
|
|
VLAN_STATUS = "/htdocs/pages/switching/vlan_status.lsp"
|
|
VLAN_PER_PORT = "/htdocs/pages/switching/vlan_per_port.lsp?vlan={vlan}"
|
|
|
|
def __init__(
|
|
self,
|
|
hostname: str,
|
|
username: str,
|
|
password: str,
|
|
timeout: int = 60,
|
|
optional_args: dict | None = None,
|
|
) -> None:
|
|
self.hostname = hostname
|
|
self.username = username
|
|
self.password = password
|
|
self.timeout = timeout
|
|
|
|
optional_args = optional_args or {}
|
|
self._client = OfficeConnectClient(
|
|
hostname,
|
|
username,
|
|
password,
|
|
# HTTP by default: HTTPS only works once a certificate has been
|
|
# uploaded to the switch, which is not the common deployment.
|
|
scheme=optional_args.get("scheme", "http"),
|
|
port=optional_args.get("port"),
|
|
timeout=timeout,
|
|
verify=optional_args.get("ssl_verify", False),
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Connection management
|
|
# ------------------------------------------------------------------
|
|
|
|
def open(self) -> None:
|
|
self._client.login()
|
|
|
|
def close(self) -> None:
|
|
# Always release the session: the switch keeps a small session table
|
|
# and only reclaims entries on idle timeout.
|
|
self._client.logout()
|
|
|
|
def is_alive(self) -> dict[str, bool]:
|
|
return {"is_alive": bool(self._client.is_authenticated)}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Internal helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
def _rows(self, path: str) -> list[list[str]]:
|
|
"""Fetch a page and return its table rows as plain text cells."""
|
|
page = self._client.fetch(path)
|
|
return [
|
|
[parsers.strip_markup(cell) for cell in row] for row in parsers.extract_data_set(page)
|
|
]
|
|
|
|
@staticmethod
|
|
def _speed_mbit(value: str) -> float:
|
|
"""``"100 Mbps Full Duplex"`` → ``100.0``; empty (link down) → ``0.0``."""
|
|
match = _SPEED_RE.search(value or "")
|
|
return float(match.group(1)) if match else 0.0
|
|
|
|
# ------------------------------------------------------------------
|
|
# Getters
|
|
# ------------------------------------------------------------------
|
|
|
|
def get_facts(self) -> dict[str, Any]:
|
|
facts = parsers.parse_facts(self._client.fetch(self.DASHBOARD))
|
|
hostname = facts["hostname"]
|
|
return {
|
|
"uptime": facts["uptime"],
|
|
"vendor": self.VENDOR,
|
|
"os_version": facts["os_version"],
|
|
"serial_number": facts["serial_number"],
|
|
"model": facts["model"],
|
|
"hostname": hostname,
|
|
"fqdn": hostname,
|
|
"interface_list": [row[1] for row in self._rows(self.PORT_SUMMARY)],
|
|
}
|
|
|
|
def get_interfaces(self) -> dict[str, dict[str, Any]]:
|
|
"""Physical ports and trunk interfaces.
|
|
|
|
Columns: [checkbox, Interface, Port Description, Admin Mode,
|
|
Physical Type, Port Status, Physical Mode, Link Speed, MTU]
|
|
"""
|
|
interfaces: dict[str, dict[str, Any]] = {}
|
|
for row in self._rows(self.PORT_SUMMARY):
|
|
interfaces[row[1]] = {
|
|
"is_up": row[5] == "Link Up",
|
|
"is_enabled": row[3] == "Enabled",
|
|
"description": row[2],
|
|
# The web UI reports neither flap time nor a per-port MAC.
|
|
"last_flapped": -1.0,
|
|
"speed": self._speed_mbit(row[7]),
|
|
"mtu": int(row[8]) if row[8].isdigit() else 0,
|
|
"mac_address": "",
|
|
}
|
|
return interfaces
|
|
|
|
def get_interfaces_counters(self) -> dict[str, dict[str, int]]:
|
|
"""Not available on this platform.
|
|
|
|
``port_summary_stats.lsp`` declares the expected columns but ships an
|
|
empty dataset on PT.02.19, and carries no AJAX endpoint that would
|
|
fill it. Returning zeros would be indistinguishable from a switch
|
|
that has genuinely passed no traffic, so this raises instead.
|
|
"""
|
|
raise NotImplementedError(
|
|
"OfficeConnect firmware PT.02.19 does not expose interface counters"
|
|
)
|
|
|
|
def _vlan_participation(self) -> dict[str, dict[str, Any]]:
|
|
"""VLAN table joined with per-VLAN interface participation.
|
|
|
|
The participation page renders one VLAN at a time, so this costs one
|
|
request per VLAN on top of the VLAN list itself.
|
|
"""
|
|
result: dict[str, dict[str, Any]] = {}
|
|
for row in self._rows(self.VLAN_STATUS):
|
|
vlan_id, name = row[1], row[2]
|
|
tagged: list[str] = []
|
|
untagged: list[str] = []
|
|
for member in self._rows(self.VLAN_PER_PORT.format(vlan=vlan_id)):
|
|
interface, state = member[1], member[2].lower()
|
|
if state == "tagged":
|
|
tagged.append(interface)
|
|
elif state == "untagged":
|
|
untagged.append(interface)
|
|
result[vlan_id] = {"name": name, "tagged": tagged, "untagged": untagged}
|
|
return result
|
|
|
|
def get_vlans(self) -> dict[str, dict[str, Any]]:
|
|
return {
|
|
vlan_id: {
|
|
"name": data["name"],
|
|
"interfaces": sorted(
|
|
data["untagged"] + data["tagged"],
|
|
key=lambda i: (i.startswith("TRK"), i),
|
|
),
|
|
}
|
|
for vlan_id, data in self._vlan_participation().items()
|
|
}
|
|
|
|
def get_vlans_detail(self) -> dict[str, dict[str, Any]]:
|
|
"""Like :meth:`get_vlans` but keeping tagged and untagged apart."""
|
|
return self._vlan_participation()
|
|
|
|
def get_mac_address_table(self) -> list[dict[str, Any]]:
|
|
"""Columns: [VLAN ID, MAC Address, Interface, Interface Index, Status]."""
|
|
table = []
|
|
for row in self._rows(self.MAC_TABLE):
|
|
status = row[4].lower()
|
|
table.append(
|
|
{
|
|
"mac": row[1],
|
|
"interface": row[2],
|
|
"vlan": int(row[0]) if row[0].isdigit() else 0,
|
|
"static": status in ("static", "management"),
|
|
"active": True,
|
|
"moves": -1,
|
|
"last_move": -1.0,
|
|
}
|
|
)
|
|
return table
|