From c76a177ff297d4454a32c7850ce49ffad7c139c0 Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Mon, 10 Aug 2026 20:45:55 +0700 Subject: [PATCH] feat: NAPALM driver for HPE OfficeConnect 1820/1920S MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 11 + LICENSE | 191 +++++++ README.md | 136 +++++ napalm_hpe_officeconnect/__init__.py | 5 + napalm_hpe_officeconnect/client.py | 145 +++++ napalm_hpe_officeconnect/officeconnect.py | 241 ++++++++ napalm_hpe_officeconnect/parsers.py | 147 +++++ pyproject.toml | 56 ++ tests/__init__.py | 0 tests/fixtures/base_dashboard.html | 397 +++++++++++++ tests/fixtures/base_mac_address_table.html | 226 ++++++++ tests/fixtures/base_network_ipv4_cfg.html | 531 ++++++++++++++++++ tests/fixtures/base_port_summary.html | 248 ++++++++ tests/fixtures/base_port_summary_stats.html | 222 ++++++++ tests/fixtures/base_user_accounts.html | 241 ++++++++ .../switching_port_channel_summary.html | 212 +++++++ tests/fixtures/switching_vlan_per_port_1.html | 276 +++++++++ .../fixtures/switching_vlan_per_port_10.html | 276 +++++++++ tests/fixtures/switching_vlan_status.html | 259 +++++++++ tests/unit/__init__.py | 0 tests/unit/test_client.py | 152 +++++ tests/unit/test_driver.py | 196 +++++++ tests/unit/test_parsers.py | 134 +++++ 23 files changed, 4302 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 napalm_hpe_officeconnect/__init__.py create mode 100644 napalm_hpe_officeconnect/client.py create mode 100644 napalm_hpe_officeconnect/officeconnect.py create mode 100644 napalm_hpe_officeconnect/parsers.py create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/fixtures/base_dashboard.html create mode 100644 tests/fixtures/base_mac_address_table.html create mode 100644 tests/fixtures/base_network_ipv4_cfg.html create mode 100644 tests/fixtures/base_port_summary.html create mode 100644 tests/fixtures/base_port_summary_stats.html create mode 100644 tests/fixtures/base_user_accounts.html create mode 100644 tests/fixtures/switching_port_channel_summary.html create mode 100644 tests/fixtures/switching_vlan_per_port_1.html create mode 100644 tests/fixtures/switching_vlan_per_port_10.html create mode 100644 tests/fixtures/switching_vlan_status.html create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_client.py create mode 100644 tests/unit/test_driver.py create mode 100644 tests/unit/test_parsers.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e8045eb --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ +.venv/ +venv/ +.pytest_cache/ +.ruff_cache/ +.coverage +htmlcov/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..68d39ec --- /dev/null +++ b/LICENSE @@ -0,0 +1,191 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship made available under + the License, as indicated by a copyright notice that is included in + or attached to the work (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other transformations + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean, as submitted to the Licensor for inclusion + in the Work by the copyright owner or by an individual or Legal Entity + authorized to submit on behalf of the copyright owner. For the purposes + of this definition, "submitted" means any form of electronic, verbal, + or written communication sent to the Licensor or its representatives, + including but not limited to communication on electronic mailing lists, + source code control systems, and issue tracking systems that are managed + by, or on behalf of, the Licensor for the purpose of discussing and + improving the Work, but excluding communication that is conspicuously + marked or designated in writing by the copyright owner as "Not a + Contribution." + + "Contributor" shall mean Licensor and any Legal Entity on behalf of + whom a Contribution has been received by the Licensor and incorporated + within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by the combined Contribution(s) and the Work + to which such Contribution(s) was submitted. If You institute patent + litigation against any entity (including a cross-claim or counterclaim + in a lawsuit) alleging that the Work or any Contribution embodied + within the Work constitutes direct or contributory patent infringement, + then any patent licenses granted to You under this License for that + Work shall terminate as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative + Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, You must include a readable copy of the + attribution notices contained within such NOTICE file, in + at least one of the following places: within a NOTICE text file + distributed as part of the Derivative Works; within the Source + form or documentation, if provided along with the Derivative + Works; or, within a display generated by the Derivative Works, + if and wherever such third-party notices normally appear. The + contents of the NOTICE file are for informational purposes only + and do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own license statement for Your modifications and + may provide additional grant of rights to use, copy, modify, merge, + publish, distribute, sublicense, and/or sell copies of the Work, + and to permit persons to whom the Work is furnished to do so, + subject to the following conditions: + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any conditions of TITLE, + MERCHANTIBILITY, or FITNESS FOR A PARTICULAR PURPOSE. + See the License for the specific language governing permissions and + limitations under the License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or exemplary damages of any character arising as a + result of this License or out of the use or inability to use the + Work (even if such Contributor has been advised of the possibility + of such damages). + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may offer only + conditions consistent with this License, subject to the following + additional conditions: + + (a) You may offer additional warranty, indemnity, or other liability + terms and conditions; and + + (b) You may offer support for the Work in return for a fee. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format in question. It may also be + possible to make it available with a creative commons license. + + Copyright 2026 Christian Manivong + + 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e61f201 --- /dev/null +++ b/README.md @@ -0,0 +1,136 @@ +# napalm-hpe-officeconnect + +NAPALM driver for **HPE OfficeConnect** web-managed switches — 1820 and 1920S. + +## Why this is not part of `napalm-hpe-aruba-procurve` + +Despite the shared vendor, these are unrelated platforms: + +| | ProCurve / ArubaOS-Switch | OfficeConnect 1820 / 1920S | +|---|---|---| +| Base OS | ProVision / ArubaOS-Switch | Broadcom FASTPATH derivative | +| SSH / Telnet | yes | **none at all** | +| REST API | ArubaOS-Switch REST v3/v6/v7 | none | +| Management | CLI + REST + web | web only | + +The ProCurve driver's four transports (REST, SSH, legacy-KEX SSH, Telnet) all +require something this hardware does not have. There is no shared transport, +no shared command set and no shared parser, so the two drivers share nothing +but a vendor name. + +The real HP **1920** (without the S) is a *third* platform — Comware 5, with a +`display`-style CLI — and would need its own driver again. + +## Design + +The switch has no machine API, so the driver reads the web UI. Every list page +is a jQuery DataTable whose payload is embedded as JavaScript: + +```js +var aDataSet = [ +['', '1', '', 'Enabled', 'Normal', 'Link Up', ...] +]; +var aColumns = [ { "sTitle": "Interface", ... }, ... ]; +``` + +That array literal is also valid Python, so `parsers.extract_data_set` parses +it with `ast.literal_eval`. No HTML parser is involved and the package needs +no dependency beyond `napalm` and `requests`. + +This is a property of firmware PT.02.xx and newer. Older 1820 firmware +server-rendered the same tables as ``/`` markup — which is why the +(unmaintained, last commit 2017) [hp1820-cli](https://github.com/BookGin/hp1820-cli) +scraper does not work on current firmware. Its endpoint list was nonetheless a +useful map when writing this driver. + +## Endpoints + +| Purpose | Path | +|---|---| +| Login | `POST /htdocs/login/login.lua` | +| Logout | `/htdocs/pages/main/logout.lsp` | +| Facts | `/htdocs/pages/base/dashboard.lsp` | +| Interfaces | `/htdocs/pages/base/port_summary.lsp` | +| MAC table | `/htdocs/pages/base/mac_address_table.lsp` | +| VLANs | `/htdocs/pages/switching/vlan_status.lsp` | +| VLAN membership | `/htdocs/pages/switching/vlan_per_port.lsp?vlan=` | + +Authentication is a form POST answering JSON; an empty `error` field means +success. Firmware generations disagree about the other fields, so `error` is +the only one worth branching on. + +## Read-only by design + +The driver implements no write path. Two properties of the platform make that +the right default: + +- **One account, no privilege levels.** `user_accounts.lsp` offers a username + and a password change — there is no read-only role — so the polling + credential is necessarily the administrator credential. +- **HTTP unless a certificate was uploaded.** The switch serves HTTPS only + after an operator installs a certificate; out of the box the session, and + therefore that admin credential, crosses the network in the clear. + +Set `optional_args={"scheme": "https"}` once a certificate is in place. + +**Session hygiene:** the switch keeps a small table of concurrent sessions and +reclaims them only on idle timeout. `close()` always logs out, including on +the failure path, or repeated polling will eventually lock the administrator +out of the web UI. + +## Supported methods + +| Method | Status | +|---|---| +| `open` / `close` / `is_alive` | ✅ | +| `get_facts` | ✅ | +| `get_interfaces` | ✅ | +| `get_vlans` | ✅ | +| `get_vlans_detail` | ✅ tagged/untagged split | +| `get_mac_address_table` | ✅ | +| `get_interfaces_counters` | ❌ see below | +| `get_config` | ❌ not yet | +| config load / commit / rollback | ❌ by design | + +### Known gaps + +- **`get_interfaces_counters`** raises `NotImplementedError`. + `port_summary_stats.lsp` declares its 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 the driver refuses rather than inventing data. +- **`get_config`** is unimplemented. The source would be + `/htdocs/lua/ajax/file_download_ajax.lua?protocol=6`, reachable but not yet + mapped. +- **SNMP.** The switch answers SNMPv2c (verified against a J9982A: `sysDescr` + repeats the model string and `sysObjectID` is `1.3.6.1.4.1.11.2.3.7.11.170`), + and the driver declares SNMP fingerprints accordingly. It does not yet + implement `get_snmp_config` or `get_health_metrics`, so CPU/memory readings — + which the web dashboard does show — are not surfaced. + +## Usage + +```python +from napalm import get_network_driver + +Driver = get_network_driver("hpe_officeconnect") + +with Driver("192.0.2.20", "admin", "secret") as dev: + print(dev.get_facts()) + print(dev.get_vlans()) +``` + +## Tests + +Fixtures are real pages captured from an HPE OfficeConnect 1820 8G PoE+ +(J9982A, PT.02.19), scrubbed of serial number, MAC addresses, IP addresses, +hostname and location. + +```bash +pip install -e ".[dev]" +pytest tests/ +``` + +## License + +Apache 2.0 diff --git a/napalm_hpe_officeconnect/__init__.py b/napalm_hpe_officeconnect/__init__.py new file mode 100644 index 0000000..d18bf8f --- /dev/null +++ b/napalm_hpe_officeconnect/__init__.py @@ -0,0 +1,5 @@ +"""NAPALM driver for HPE OfficeConnect 1820 / 1920S web-managed switches.""" + +from napalm_hpe_officeconnect.officeconnect import OfficeConnectDriver + +__all__ = ["OfficeConnectDriver"] diff --git a/napalm_hpe_officeconnect/client.py b/napalm_hpe_officeconnect/client.py new file mode 100644 index 0000000..300eeac --- /dev/null +++ b/napalm_hpe_officeconnect/client.py @@ -0,0 +1,145 @@ +"""HTTP session handling for HPE OfficeConnect switches. + +The switch authenticates with a form POST that answers JSON and hands out a +``SID`` cookie:: + + GET /htdocs/login/login.lsp → sets the short-lived RID cookie + POST /htdocs/login/login.lua → {"redirect": "...", "error": ""} + GET /htdocs/pages/main/logout.lsp + +An empty ``error`` means success. Firmware generations differ in what else +they put in that object — older builds omit ``redirect``, newer ones omit +``username`` — so ``error`` is the only field worth branching on. + +Session hygiene matters here: the switch keeps a small table of concurrent +sessions and only reclaims them on idle timeout. A driver that logs in on +every poll and never logs out will eventually lock the administrator out of +the web UI, so :meth:`logout` is best-effort and always safe to call. +""" + +from __future__ import annotations + +import logging + +import requests +from napalm.base.exceptions import ( + CommandErrorException, + ConnectionClosedException, + ConnectionException, +) + +logger = logging.getLogger("napalm_hpe_officeconnect") + +LOGIN_PAGE = "/htdocs/login/login.lsp" +LOGIN_ENDPOINT = "/htdocs/login/login.lua" +LOGOUT_ENDPOINT = "/htdocs/pages/main/logout.lsp" + + +class OfficeConnectClient: + """Authenticated HTTP session against an OfficeConnect switch.""" + + def __init__( + self, + host: str, + username: str, + password: str, + scheme: str = "http", + port: int | None = None, + timeout: int = 30, + verify: bool = False, + ) -> None: + self.host = host + self.username = username + self.password = password + # HTTP by default: the switch only serves HTTPS once an operator has + # uploaded a certificate, which is not the common case. + self.scheme = scheme + self.port = port + self.timeout = timeout + self.verify = verify + self._session = requests.Session() + self._authenticated = False + + @property + def base_url(self) -> str: + if self.port: + return f"{self.scheme}://{self.host}:{self.port}" + return f"{self.scheme}://{self.host}" + + @property + def is_authenticated(self) -> bool: + return self._authenticated + + def login(self) -> None: + """Establish a session. Raises ConnectionException on any failure.""" + try: + # The login page issues the RID cookie the POST is validated against. + self._session.get(self.base_url + LOGIN_PAGE, timeout=self.timeout, verify=self.verify) + response = self._session.post( + self.base_url + LOGIN_ENDPOINT, + data={"username": self.username, "password": self.password}, + timeout=self.timeout, + verify=self.verify, + ) + except requests.RequestException as exc: + raise ConnectionException(f"Cannot reach {self.host}: {exc}") from exc + + try: + payload = response.json() + except ValueError as exc: + raise ConnectionException( + f"Login to {self.host} returned no JSON " + f"(HTTP {response.status_code}) — is this an OfficeConnect switch?" + ) from exc + + error = (payload.get("error") or "").strip() + if error: + raise ConnectionException(f"Login to {self.host} failed: {error}") + + self._authenticated = True + + def fetch(self, path: str) -> str: + """Return the body of an authenticated page.""" + if not self._authenticated: + raise ConnectionException(f"Not logged in to {self.host} — call login() first") + + try: + response = self._session.get( + self.base_url + path, + timeout=self.timeout, + verify=self.verify, + # Do not follow the redirect: a 3xx here *is* the error signal. + allow_redirects=False, + ) + except requests.RequestException as exc: + raise ConnectionClosedException(f"Request to {self.host}{path} failed: {exc}") from exc + + if 300 <= response.status_code < 400: + # The switch bounces expired sessions to the login page instead of + # answering 401. + self._authenticated = False + raise ConnectionClosedException(f"Session to {self.host} expired while fetching {path}") + if response.status_code != 200: + raise CommandErrorException(f"{self.host}{path} returned HTTP {response.status_code}") + + return response.text + + def logout(self) -> None: + """Release the session. Best-effort: never raises. + + Called from ``close()``, including on the failure path, where raising + would mask the exception that actually caused the disconnect. + """ + if not self._authenticated: + return + try: + self._session.get( + self.base_url + LOGOUT_ENDPOINT, + timeout=self.timeout, + verify=self.verify, + allow_redirects=False, + ) + except Exception as exc: # noqa: BLE001 — deliberate: see docstring + logger.debug("Logout from %s failed, session will idle out: %s", self.host, exc) + finally: + self._authenticated = False diff --git a/napalm_hpe_officeconnect/officeconnect.py b/napalm_hpe_officeconnect/officeconnect.py new file mode 100644 index 0000000..a32897d --- /dev/null +++ b/napalm_hpe_officeconnect/officeconnect.py @@ -0,0 +1,241 @@ +"""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 diff --git a/napalm_hpe_officeconnect/parsers.py b/napalm_hpe_officeconnect/parsers.py new file mode 100644 index 0000000..6c321d2 --- /dev/null +++ b/napalm_hpe_officeconnect/parsers.py @@ -0,0 +1,147 @@ +"""Parsers for the HPE OfficeConnect web UI. + +These switches have no CLI and no REST API — the only machine-readable +surface is the web UI itself. Every list page (ports, VLANs, MAC table, +trunks, counters) is a jQuery DataTable whose payload is embedded in the +page as two JavaScript declarations:: + + var aDataSet = [ + ['', '1', '', 'Enabled', ..., '1518'] + , + ['', '2', '', 'Enabled', ..., '1518'] + ]; + + var aColumns = [ + { "sTitle": "Interface", "sType": "intf-sort", "sWidth": "8%" }, + ... + ]; + +The array literal happens to be valid Python syntax, so it parses with +``ast.literal_eval`` — no HTML parser and no extra dependency needed. + +Note that this is a property of firmware PT.02.xx and later. Older 1820 +firmware server-rendered the same data as ````/```` markup, which +is why the (unmaintained) hp1820-cli scraper does not work here. +""" + +from __future__ import annotations + +import ast +import html as html_module +import re + +_DATASET_RE = re.compile(r"var\s+aDataSet\s*=\s*\[(.*?)\n\s*\];", re.DOTALL) +_STITLE_RE = re.compile(r"\"sTitle\"\s*:\s*(['\"])(.*?)\1", re.DOTALL) +_TAG_RE = re.compile(r"<[^>]*>") +_UPTIME_RE = re.compile( + r"(\d+)\s*days?,\s*(\d+)\s*hours?,\s*(\d+)\s*mins?,\s*(\d+)\s*secs?", + re.IGNORECASE, +) + + +def strip_markup(value: str) -> str: + """Reduce a DataTable cell to its plain text. + + The leading column of most tables is a row-selection checkbox whose + "value" is an ```` element; it collapses to an empty string. + """ + text = _TAG_RE.sub("", value) + text = html_module.unescape(text) + return re.sub(r"\s+", " ", text).strip() + + +def extract_data_set(page: str) -> list[list[str]]: + """Return the rows of the page's ``aDataSet`` as lists of raw cell strings. + + Cells are returned verbatim, markup included — callers decide per column + whether to run them through :func:`strip_markup`. Returns an empty list + if the page carries no table (e.g. an error or login-redirect page). + """ + match = _DATASET_RE.search(page) + if not match: + return [] + body = match.group(1) + + # Fast path: the whole literal at once. + try: + rows = ast.literal_eval("[" + body + "]") + except (SyntaxError, ValueError): + # Fall back to row-at-a-time so a single malformed row — an escape + # sequence that is valid in JS but not in Python, say — costs us that + # row rather than the entire table. + rows = [] + for line in body.splitlines(): + line = line.strip().rstrip(",") + if not line.startswith("["): + continue + try: + rows.append(ast.literal_eval(line)) + except (SyntaxError, ValueError): + continue + + return [[str(cell) for cell in row] for row in rows if isinstance(row, (list, tuple))] + + +def extract_column_titles(page: str) -> list[str]: + """Return the text column titles declared in ``aColumns``. + + Purely informational columns whose title is markup rather than text — + the select-all checkbox — are dropped, so the result lines up with the + data columns a caller actually reads. + """ + titles = [strip_markup(m.group(2)) for m in _STITLE_RE.finditer(page)] + return [t for t in titles if t] + + +def parse_uptime(value: str) -> int: + """Convert ``"0 days, 18 hours, 44 mins, 40 secs"`` to seconds. + + Returns -1 when the string cannot be parsed, which is NAPALM's + convention for an unknown uptime. + """ + match = _UPTIME_RE.search(value or "") + if not match: + return -1 + days, hours, mins, secs = (int(g) for g in match.groups()) + return days * 86400 + hours * 3600 + mins * 60 + secs + + +def _text_by_id(page: str, element_id: str) -> str: + """Text content of the ```` carrying a dashboard value.""" + match = re.search(rf'id="{re.escape(element_id)}"[^>]*>(.*?) str: + """Value of the ```` carrying an editable dashboard field. + + The attribute often wraps across lines, hence the DOTALL search. + """ + match = re.search(rf'id="{re.escape(element_id)}"[^>]*?value="([^"]*)"', page, re.DOTALL) + return html_module.unescape(match.group(1)).strip() if match else "" + + +def parse_facts(page: str) -> dict[str, object]: + """Extract system facts from ``dashboard.lsp``. + + The System Description is a comma-separated tuple of model, firmware, + kernel and bootloader:: + + HPE OfficeConnect Switch 1820 8G PoE+ (65W) J9982A, PT.02.19, + Linux 3.6.5, U-Boot 2012.10-00116 (Jul 30 2014 - 10:52:01) + """ + descr = _text_by_id(page, "sys_descr") + parts = [p.strip() for p in descr.split(",")] if descr else [] + + model = parts[0] if parts else "" + os_version = _text_by_id(page, "sw_version") or (parts[1] if len(parts) > 1 else "") + + return { + "hostname": _input_value_by_id(page, "sys_name"), + "model": model, + "serial_number": _text_by_id(page, "serial_number"), + "os_version": os_version, + "sys_object_id": _text_by_id(page, "sys_obj_id"), + "uptime": parse_uptime(_text_by_id(page, "sys_up_time")), + "system_description": descr, + } diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..04a24f5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,56 @@ +[build-system] +requires = ["setuptools>=64", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "napalm-hpe-officeconnect" +version = "0.1.0" +description = "NAPALM driver for HPE OfficeConnect 1820 / 1920S web-managed switches (HTTP only, no CLI)" +readme = "README.md" +license = { text = "Apache-2.0" } +requires-python = ">=3.8" +authors = [ + { name = "Christian Manivong" }, +] +classifiers = [ + "Topic :: Utilities", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Operating System :: POSIX :: Linux", + "Operating System :: MacOS", +] +# No netmiko/paramiko: these switches have no CLI at all. +# No BeautifulSoup: the web UI ships its table data as JS array literals, +# which parse with ast.literal_eval — see parsers.extract_data_set. +dependencies = [ + "napalm>=4.0.0", + "requests>=2.25.0", + "urllib3", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-cov", + "black", + "ruff", +] + +[project.entry-points."napalm.drivers"] +hpe_officeconnect = "napalm_hpe_officeconnect:OfficeConnectDriver" + +[project.urls] +Repository = "https://git.netork.io/NAPALM/napalm-hpe-officeconnect" + +[tool.setuptools.packages.find] +where = ["."] +include = ["napalm_hpe_officeconnect*"] + +[tool.ruff] +line-length = 100 +target-version = "py38" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/base_dashboard.html b/tests/fixtures/base_dashboard.html new file mode 100644 index 0000000..006b196 --- /dev/null +++ b/tests/fixtures/base_dashboard.html @@ -0,0 +1,397 @@ + + + + + + + + +Dashboard + + + + + + + + + + + + + + + + +
+
Dashboard
  +
+ +Help + +
+
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
System Information
System DescriptionHPE OfficeConnect Switch 1820 8G PoE+ (65W) J9982A, PT.02.19, Linux 3.6.5, U-Boot 2012.10-00116 (Jul 30 2014 - 10:52:01)
System Name +  (0 to 64 characters)
System Location  (0 to 255 characters)
System Contact +  (0 to 255 characters)
System Object ID1.3.6.1.4.1.11.2.3.7.11.170
System Up Time0 days, 18 hours, 44 mins, 40 secs
Current Time +19:44:40
Date +January 01, 1970
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + +
Device Information
Software VersionPT.02.19
Operating SystemLinux 3.6.5
Serial NumberCN0FIXTURE0
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
System Resource Usage
CPU Utilization (60 Second Average) +
+
14 %
+
Memory Usage +
+
36 %
+
Logged In Users
+
+
+
+ +
+ +  + +  + +
+
+ +
+ + + + + + + + + + + + + + diff --git a/tests/fixtures/base_mac_address_table.html b/tests/fixtures/base_mac_address_table.html new file mode 100644 index 0000000..8a0c382 --- /dev/null +++ b/tests/fixtures/base_mac_address_table.html @@ -0,0 +1,226 @@ + + + + + + + + +MAC Table + + + + + + + + + + + + + + + + +
+
MAC Table
  +
+ +Help + +
+
+ +
+ +
+ +
+ + + + + + + + + +
Maximum Entries Supported8000
+
+ +
+
+
+
+ +
+ +
+
+ +
+ + + + + + + + + + + + + diff --git a/tests/fixtures/base_network_ipv4_cfg.html b/tests/fixtures/base_network_ipv4_cfg.html new file mode 100644 index 0000000..71b1e9c --- /dev/null +++ b/tests/fixtures/base_network_ipv4_cfg.html @@ -0,0 +1,531 @@ + + + + + + + + +Get Connected + + + + + + + + + + + + + + + + +
+
Get Connected
  +
+ +Help + +
+
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Network Details
Protocol Type + + + + + + +
IP Address +  (x.x.x.x)
Subnet Mask +  (x.x.x.x)
Gateway Address +  (x.x.x.x)
MAC Address00:11:22:33:44:05
+
+ +
+ + + + + + + + + + + + + + +
Web Parameters
Session Timeout  (Minutes) +  (1 to 60)
+
+ +
+ + + + + + + + + + + + + + + + + + + +
Management Access
Management VLAN ID + +
Management Port + +
+
+ +
+ + + + + + + + + + + + + + + + + + + +
SNMP
SNMP + + + + +
Community Name +  (1 to 20 characters)
+
+ + +
+ +  + +  + +
+ + +
+ + +
+ + + + + + + + + + + + + diff --git a/tests/fixtures/base_port_summary.html b/tests/fixtures/base_port_summary.html new file mode 100644 index 0000000..65ac8af --- /dev/null +++ b/tests/fixtures/base_port_summary.html @@ -0,0 +1,248 @@ + + + + + + + + +Port Status + + + + + + + + + + + + + + + + +
+
Port Status
  +
+ +Help + +
+
+ +
+ +
+
+
+
+ +
+ +  + +  + +
+
+ +
+ + + + + + + + + + + + + diff --git a/tests/fixtures/base_port_summary_stats.html b/tests/fixtures/base_port_summary_stats.html new file mode 100644 index 0000000..3a5d6b7 --- /dev/null +++ b/tests/fixtures/base_port_summary_stats.html @@ -0,0 +1,222 @@ + + + + + + + + +Port Summary Statistics + + + + + + + + + + + + + + + + +
+
Port Summary Statistics
  +
+ +Help + +
+
+ +
+ +
+ +
+
+
+ +
+ +  + +
+
+ +
+ + + + + + + + + + + + + + diff --git a/tests/fixtures/base_user_accounts.html b/tests/fixtures/base_user_accounts.html new file mode 100644 index 0000000..0a41484 --- /dev/null +++ b/tests/fixtures/base_user_accounts.html @@ -0,0 +1,241 @@ + + + + + + + + +Password Manager + + + + + + + + + + + + + + + + +
+
Password Manager
  +
+ +Help + +
+
+ +
+ + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + +
Username +  (1 to 64 characters)
Current Password +  (8 to 64)
New Password +  (8 to 64)
Confirm New Password +  (8 to 64)
+ +
+ +
+ +  + +  + +
+ +
+ +
+ + + + + + + + + + + + + + diff --git a/tests/fixtures/switching_port_channel_summary.html b/tests/fixtures/switching_port_channel_summary.html new file mode 100644 index 0000000..6c11642 --- /dev/null +++ b/tests/fixtures/switching_port_channel_summary.html @@ -0,0 +1,212 @@ + + + + + + + + +Trunk Configuration + + + + + + + + + + + + + + + + +
+
Trunk Configuration
  +
+ +Help + +
+
+ +
+ +
+ +
+
+
+ +
+ +  + +
+
+ +
+ + + + + + + + + + + + + + diff --git a/tests/fixtures/switching_vlan_per_port_1.html b/tests/fixtures/switching_vlan_per_port_1.html new file mode 100644 index 0000000..d99b9c9 --- /dev/null +++ b/tests/fixtures/switching_vlan_per_port_1.html @@ -0,0 +1,276 @@ + + + + + + + + +VLAN Port Membership + + + + + + + + + + + + + + + + +
+
VLAN Port Membership
  +
+ +Help + +
+
+ +
+ +
+ +
+ + + + + + + + + +
VLAN ID + +
+
+ +
+
+
+
+ +
+ +  + +  + +
+ +
+ +
+ + + + + + + + + + + + + + diff --git a/tests/fixtures/switching_vlan_per_port_10.html b/tests/fixtures/switching_vlan_per_port_10.html new file mode 100644 index 0000000..ae8f6d7 --- /dev/null +++ b/tests/fixtures/switching_vlan_per_port_10.html @@ -0,0 +1,276 @@ + + + + + + + + +VLAN Port Membership + + + + + + + + + + + + + + + + +
+
VLAN Port Membership
  +
+ +Help + +
+
+ +
+ +
+ +
+ + + + + + + + + +
VLAN ID + +
+
+ +
+
+
+
+ +
+ +  + +  + +
+ +
+ +
+ + + + + + + + + + + + + + diff --git a/tests/fixtures/switching_vlan_status.html b/tests/fixtures/switching_vlan_status.html new file mode 100644 index 0000000..6962937 --- /dev/null +++ b/tests/fixtures/switching_vlan_status.html @@ -0,0 +1,259 @@ + + + + + + + + +VLAN Configuration + + + + + + + + + + + + + + + + +
+
VLAN Configuration
  +
+ +Help + +
+
+ +
+ +
+ +
+
+
+ +
+ +  + +  + +  + +
+ +
+ +
+ + + + + + + + + + + + + + diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py new file mode 100644 index 0000000..60519c3 --- /dev/null +++ b/tests/unit/test_client.py @@ -0,0 +1,152 @@ +"""Unit tests for the OfficeConnect HTTP client — no real device required.""" + +from unittest.mock import MagicMock, patch + +import pytest +from napalm.base.exceptions import ConnectionClosedException, ConnectionException + +from napalm_hpe_officeconnect.client import OfficeConnectClient + + +def make_response(status_code=200, text="", json_data=None): + resp = MagicMock() + resp.status_code = status_code + resp.text = text + if json_data is None: + resp.json.side_effect = ValueError("no json") + else: + resp.json.return_value = json_data + return resp + + +@pytest.fixture +def client(): + with patch("napalm_hpe_officeconnect.client.requests.Session") as session_cls: + c = OfficeConnectClient("192.0.2.20", "admin", "secret") + c._session = session_cls.return_value + yield c + + +# =========================================================================== +# base_url +# =========================================================================== + + +def test_base_url_defaults_to_http(): + """These switches ship without a certificate, so HTTP is the default.""" + c = OfficeConnectClient("192.0.2.20", "admin", "secret") + assert c.base_url == "http://192.0.2.20" + + +def test_base_url_honours_https_opt_in(): + c = OfficeConnectClient("192.0.2.20", "admin", "secret", scheme="https") + assert c.base_url == "https://192.0.2.20" + + +def test_base_url_includes_non_default_port(): + c = OfficeConnectClient("192.0.2.20", "admin", "secret", port=8080) + assert c.base_url == "http://192.0.2.20:8080" + + +# =========================================================================== +# login +# =========================================================================== + + +def test_login_posts_credentials_and_succeeds(client): + client._session.get.return_value = make_response(text="login page") + client._session.post.return_value = make_response( + json_data={"redirect": "/htdocs/pages/main/main.lsp", "error": ""} + ) + + client.login() + + # The RID cookie is handed out by the login page, so it must be fetched first. + client._session.get.assert_called_once() + assert client._session.get.call_args[0][0].endswith("/htdocs/login/login.lsp") + + (url,) = client._session.post.call_args[0] + assert url.endswith("/htdocs/login/login.lua") + assert client._session.post.call_args[1]["data"] == { + "username": "admin", + "password": "secret", + } + assert client.is_authenticated is True + + +def test_login_raises_with_device_message_on_bad_credentials(client): + client._session.get.return_value = make_response(text="login page") + client._session.post.return_value = make_response( + json_data={"redirect": "", "error": "Invalid username or password"} + ) + + with pytest.raises(ConnectionException, match="Invalid username or password"): + client.login() + assert client.is_authenticated is False + + +def test_login_raises_when_response_is_not_json(client): + """A firmware that redirects instead of answering must not look like success.""" + client._session.get.return_value = make_response(text="login page") + client._session.post.return_value = make_response(status_code=303, text="") + + with pytest.raises(ConnectionException): + client.login() + assert client.is_authenticated is False + + +# =========================================================================== +# fetch +# =========================================================================== + + +def test_fetch_returns_page_text(client): + client._authenticated = True + client._session.get.return_value = make_response(text="data") + + assert client.fetch("/htdocs/pages/base/dashboard.lsp") == "data" + + +def test_fetch_raises_when_session_expired(client): + """An expired session answers 303 to the login page rather than 401.""" + client._authenticated = True + client._session.get.return_value = make_response(status_code=303, text="") + + with pytest.raises(ConnectionClosedException): + client.fetch("/htdocs/pages/base/dashboard.lsp") + + +def test_fetch_requires_login_first(client): + with pytest.raises(ConnectionException): + client.fetch("/htdocs/pages/base/dashboard.lsp") + + +# =========================================================================== +# logout — must be reliable, the session table is small +# =========================================================================== + + +def test_logout_releases_the_session(client): + client._authenticated = True + client._session.get.return_value = make_response(status_code=303) + + client.logout() + + assert client._session.get.call_args[0][0].endswith("/htdocs/pages/main/logout.lsp") + assert client.is_authenticated is False + + +def test_logout_swallows_errors(client): + """close() runs logout on the failure path too; it must never mask the + original exception, and an unreachable switch is not a new problem.""" + client._authenticated = True + client._session.get.side_effect = OSError("network gone") + + client.logout() # must not raise + + assert client.is_authenticated is False + + +def test_logout_is_a_noop_when_not_logged_in(client): + client.logout() + client._session.get.assert_not_called() diff --git a/tests/unit/test_driver.py b/tests/unit/test_driver.py new file mode 100644 index 0000000..83a5248 --- /dev/null +++ b/tests/unit/test_driver.py @@ -0,0 +1,196 @@ +"""Unit tests for OfficeConnectDriver — fixture-driven, no real device.""" + +import pathlib +from unittest.mock import patch + +import pytest + +from napalm_hpe_officeconnect.officeconnect import OfficeConnectDriver + +FIXTURES = pathlib.Path(__file__).parent.parent / "fixtures" + +PAGE_FOR_PATH = { + OfficeConnectDriver.DASHBOARD: "base_dashboard", + OfficeConnectDriver.PORT_SUMMARY: "base_port_summary", + OfficeConnectDriver.MAC_TABLE: "base_mac_address_table", + OfficeConnectDriver.VLAN_STATUS: "switching_vlan_status", +} + + +def fake_fetch(path: str) -> str: + """Serve a captured page for the requested path.""" + if path.startswith("/htdocs/pages/switching/vlan_per_port.lsp"): + vlan = path.rsplit("=", 1)[-1] + name = f"switching_vlan_per_port_{vlan if vlan == '10' else '1'}" + else: + name = PAGE_FOR_PATH[path] + return (FIXTURES / f"{name}.html").read_text(encoding="utf-8") + + +@pytest.fixture +def driver(): + with patch("napalm_hpe_officeconnect.officeconnect.OfficeConnectClient") as client_cls: + client = client_cls.return_value + client.fetch.side_effect = fake_fetch + client.is_authenticated = True + d = OfficeConnectDriver("192.0.2.20", "admin", "secret") + d._client = client + yield d + + +# =========================================================================== +# Fingerprinting contract — consumed by netOrk discovery without a connection +# =========================================================================== + + +def test_driver_identity(): + assert OfficeConnectDriver.DRIVER_NAME == "hpe_officeconnect" + assert OfficeConnectDriver.VENDOR == "HPE" + + +def test_snmp_object_id_is_specific_enough_to_not_collide_with_procurve(): + """The ProCurve driver claims 1.3.6.1.4.1.11 — the bare HPE arc. This + driver must claim the longer OfficeConnect arc or the two compete.""" + assert OfficeConnectDriver.SNMP_OBJECT_ID_PREFIX == "1.3.6.1.4.1.11.2.3.7.11" + assert "1.3.6.1.4.1.11.2.3.7.11.170".startswith(OfficeConnectDriver.SNMP_OBJECT_ID_PREFIX) + + +def test_http_fingerprint_requires_officeconnect(): + mandatory = [r for r in OfficeConnectDriver.HTTP_FINGERPRINT if r.mandatory] + assert [r.pattern for r in mandatory] == ["officeconnect"] + + +def test_snmp_fingerprint_matches_real_sysdescr(): + """sysDescr as returned by a J9982A over SNMPv2c.""" + sys_descr = ( + "hpe officeconnect switch 1820 8g poe+ (65w) j9982a, pt.02.19, " + "linux 3.6.5-58b5074c, u-boot 2012.10-00116-g3ab515c (jul 30 2014 - 10:52:01)" + ) + matched = [r.pattern for r in OfficeConnectDriver.SNMP_FINGERPRINT if r.pattern in sys_descr] + assert "officeconnect" in matched + assert "1820" in matched + + +def test_snmp_fingerprint_has_no_mandatory_rule(): + """HTTP already carries the hard requirement. A mandatory SNMP rule would + disqualify the driver for any device answering SNMP but not HTTP.""" + assert not any(r.mandatory for r in OfficeConnectDriver.SNMP_FINGERPRINT) + + +# =========================================================================== +# Connection lifecycle +# =========================================================================== + + +def test_open_logs_in(driver): + driver.open() + driver._client.login.assert_called_once() + + +def test_close_logs_out(driver): + driver.close() + driver._client.logout.assert_called_once() + + +def test_is_alive_reflects_session_state(driver): + assert driver.is_alive() == {"is_alive": True} + + +# =========================================================================== +# get_facts +# =========================================================================== + + +def test_get_facts(driver): + facts = driver.get_facts() + assert facts["vendor"] == "HPE" + assert facts["hostname"] == "testswitch" + assert facts["serial_number"] == "CN0FIXTURE0" + assert facts["os_version"] == "PT.02.19" + assert facts["model"] == "HPE OfficeConnect Switch 1820 8G PoE+ (65W) J9982A" + assert facts["uptime"] == 67480 + assert facts["interface_list"][:3] == ["1", "2", "3"] + assert "TRK1" in facts["interface_list"] + + +# =========================================================================== +# get_interfaces +# =========================================================================== + + +def test_get_interfaces_returns_ports_and_trunks(driver): + interfaces = driver.get_interfaces() + assert len(interfaces) == 12 + assert set(interfaces) >= {"1", "8", "TRK1", "TRK4"} + + +def test_get_interfaces_link_state(driver): + interfaces = driver.get_interfaces() + assert interfaces["3"]["is_up"] is True + assert interfaces["1"]["is_up"] is False + # Admin state is independent of link state on this platform. + assert interfaces["1"]["is_enabled"] is True + + +def test_get_interfaces_speed_is_mbit(driver): + interfaces = driver.get_interfaces() + assert interfaces["3"]["speed"] == 1000.0 + assert interfaces["6"]["speed"] == 100.0 # "100 Mbps Full Duplex" + assert interfaces["1"]["speed"] == 0.0 # link down — no speed reported + + +def test_get_interfaces_mtu(driver): + assert driver.get_interfaces()["1"]["mtu"] == 1518 + + +# =========================================================================== +# get_vlans +# =========================================================================== + + +def test_get_vlans_lists_every_vlan(driver): + vlans = driver.get_vlans() + assert set(vlans) == {"1", "10", "20", "30", "40", "50", "60"} + assert vlans["1"]["name"] == "default" + assert vlans["10"]["name"] == "VLAN0010" + + +def test_get_vlans_membership_excludes_non_members(driver): + """Participation is Untagged / Tagged / Excluded — only the first two + make an interface a member.""" + vlans = driver.get_vlans() + assert vlans["10"]["interfaces"] == ["2", "3", "4", "8"] + + +def test_get_vlans_detail_splits_tagged_and_untagged(driver): + detail = driver.get_vlans_detail() + assert detail["1"]["untagged"] == ["1", "5", "7", "TRK1", "TRK2", "TRK3", "TRK4"] + assert detail["1"]["tagged"] == ["8"] + + +# =========================================================================== +# get_mac_address_table +# =========================================================================== + + +def test_get_mac_address_table(driver): + table = driver.get_mac_address_table() + assert len(table) == 17 + first = table[0] + assert first["mac"] == "00:11:22:33:44:01" + assert first["interface"] == "7" + assert first["vlan"] == 1 + assert first["static"] is False + assert first["active"] is True + + +# =========================================================================== +# Deliberate gaps — documented, not silently faked +# =========================================================================== + + +def test_get_interfaces_counters_is_not_implemented(driver): + """port_summary_stats.lsp ships an empty dataset on PT.02.19; returning + zeros would look like real counters. See README.""" + with pytest.raises(NotImplementedError): + driver.get_interfaces_counters() diff --git a/tests/unit/test_parsers.py b/tests/unit/test_parsers.py new file mode 100644 index 0000000..2ebef97 --- /dev/null +++ b/tests/unit/test_parsers.py @@ -0,0 +1,134 @@ +"""Unit tests for the OfficeConnect HTML/JS parsers. + +Fixtures are real pages captured from an HPE OfficeConnect 1820 8G PoE+ +(J9982A, firmware PT.02.19), scrubbed of serial number, MAC addresses, +IP addresses and hostname. +""" + +import pathlib + +import pytest + +from napalm_hpe_officeconnect import parsers + +FIXTURES = pathlib.Path(__file__).parent.parent / "fixtures" + + +def fixture(name: str) -> str: + return (FIXTURES / f"{name}.html").read_text(encoding="utf-8") + + +# =========================================================================== +# extract_data_set — the DataTables payload every list page ships +# =========================================================================== + + +def test_extract_data_set_returns_one_row_per_port(): + """port_summary carries 8 physical ports plus 4 trunk interfaces.""" + rows = parsers.extract_data_set(fixture("base_port_summary")) + assert len(rows) == 12 + assert all(isinstance(r, list) for r in rows) + + +def test_extract_data_set_preserves_cell_values(): + rows = parsers.extract_data_set(fixture("base_port_summary")) + # Columns: [checkbox, interface, description, admin, phy type, status, ...] + assert rows[0][1] == "1" + assert rows[0][3] == "Enabled" + assert rows[2][5] == "Link Up" + assert rows[2][7] == "1000 Mbps" + + +def test_extract_data_set_handles_rows_without_markup(): + """mac_address_table rows are plain values, no embedded checkbox cell.""" + rows = parsers.extract_data_set(fixture("base_mac_address_table")) + assert len(rows) == 17 + assert rows[0] == ["1", "00:11:22:33:44:01", "7", "7", "Learned"] + + +def test_extract_data_set_handles_empty_cells(): + """Counter columns are empty strings on a port that never linked up.""" + rows = parsers.extract_data_set(fixture("base_port_summary_stats")) + assert rows[0] == ["1", "", "", "", "", "", "", "", ""] + + +def test_extract_data_set_returns_empty_list_when_absent(): + assert parsers.extract_data_set("nothing here") == [] + + +# =========================================================================== +# extract_column_titles — used to assert the firmware layout hasn't shifted +# =========================================================================== + + +def test_extract_column_titles(): + titles = parsers.extract_column_titles(fixture("switching_vlan_status")) + assert titles == ["VLAN ID", "Name", "Type"] + + +def test_extract_column_titles_skips_markup_only_header(): + """The leading checkbox column has an as its title, not text.""" + titles = parsers.extract_column_titles(fixture("base_mac_address_table")) + assert titles == ["VLAN ID", "MAC Address", "Interface", "Interface Index", "Status"] + + +# =========================================================================== +# strip_markup — the checkbox cell must collapse to an empty string +# =========================================================================== + + +@pytest.mark.parametrize( + "raw,expected", + [ + ('', ""), + ("plain value", "plain value"), + (" spaced&out ", "spaced&out"), + ("", ""), + ], +) +def test_strip_markup(raw, expected): + assert parsers.strip_markup(raw) == expected + + +# =========================================================================== +# parse_uptime +# =========================================================================== + + +@pytest.mark.parametrize( + "raw,expected", + [ + ("0 days, 18 hours, 44 mins, 40 secs", 67480), + ("3 days, 0 hours, 0 mins, 0 secs", 259200), + ("0 days, 0 hours, 0 mins, 1 secs", 1), + ], +) +def test_parse_uptime(raw, expected): + assert parsers.parse_uptime(raw) == expected + + +def test_parse_uptime_returns_negative_one_when_unparseable(): + """NAPALM's convention for 'uptime unknown' is -1, not 0.""" + assert parsers.parse_uptime("nonsense") == -1 + + +# =========================================================================== +# parse_facts — dashboard.lsp +# =========================================================================== + + +def test_parse_facts(): + facts = parsers.parse_facts(fixture("base_dashboard")) + assert facts["hostname"] == "testswitch" + assert facts["serial_number"] == "CN0FIXTURE0" + assert facts["os_version"] == "PT.02.19" + assert facts["model"] == "HPE OfficeConnect Switch 1820 8G PoE+ (65W) J9982A" + assert facts["uptime"] == 67480 + assert facts["sys_object_id"] == "1.3.6.1.4.1.11.2.3.7.11.170" + + +def test_parse_facts_tolerates_missing_fields(): + facts = parsers.parse_facts("") + assert facts["hostname"] == "" + assert facts["serial_number"] == "" + assert facts["uptime"] == -1