diff --git a/CHANGELOG.md b/CHANGELOG.md index 988f03f..8484c87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.1] - 2026-10-13 + +### Fixed +- Discovery no longer takes every device with an open HTTPS port for a BMC: the + `PortSpec` for 443 carries no weight, so a switch's web UI scores nothing. +- A BMC that refuses Redfish for want of a vendor license (Supermicro X10 without SUM, + DCMS or OOB answers even `GET /redfish/v1/` with 403 `OemLicenseNotPassed`) now fails + `open()` with `RedfishLicenseError` naming the licenses, not with a bare status code. + +### Added +- Fingerprint rules for the BMC web UIs netOrk's discovery already fetches: Supermicro + X9-X11 (ATEN firmware), iDRAC, iLO. A rule for a Redfish error body + (`@message.extendedinfo`) for when discovery reads the service root. +- Recorded fixtures from a Supermicro X10DRL-i (BMC 3.91, unlicensed). + ## [0.1.0] - 2026-10-13 ### Added diff --git a/napalm_redfish/__init__.py b/napalm_redfish/__init__.py index fb14610..ac1dca5 100644 --- a/napalm_redfish/__init__.py +++ b/napalm_redfish/__init__.py @@ -2,5 +2,5 @@ from napalm_redfish.driver import RedfishDriver -__version__ = "0.1.0" +__version__ = "0.1.1" __all__ = ["RedfishDriver"] diff --git a/napalm_redfish/driver.py b/napalm_redfish/driver.py index d1a4ece..3d15366 100644 --- a/napalm_redfish/driver.py +++ b/napalm_redfish/driver.py @@ -109,10 +109,20 @@ class RedfishDriver(ManagementControllerDriver): platform = "redfish" DRIVER_NAME = "redfish" - #: Every Redfish service answers ``GET /redfish/v1/`` without credentials. - PORT_SPECS = [PortSpec("https", 443, ("/redfish/v1/",))] - HTTP_FINGERPRINT = [FingerprintRule("redfishversion", 10.0), - FingerprintRule("#serviceroot.", 5.0)] + #: Every Redfish service answers ``GET /redfish/v1/`` without credentials. The port + #: itself is worth nothing: every switch with a web UI answers on 443. + PORT_SPECS = [PortSpec("https", 443, ("/redfish/v1/",), weight=0.0)] + HTTP_FINGERPRINT = [ + # The service root, and the error body a service refuses it with (an unlicensed + # Supermicro X10 answers 403 with a Redfish error). + FingerprintRule("redfishversion", 10.0), + FingerprintRule("#serviceroot.", 5.0), + FingerprintRule("@message.extendedinfo", 8.0), + # Landing pages of the BMC web UIs: Supermicro X9-X11 (ATEN firmware), iDRAC, iLO. + FingerprintRule("aten international", 6.0), + FingerprintRule("idrac", 6.0), + FingerprintRule("integrated lights-out", 6.0), + ] #: A BMC restart takes a few minutes before Redfish answers again. REBOOT_SETTLE_SECONDS = 180 diff --git a/napalm_redfish/transport.py b/napalm_redfish/transport.py index b6f45db..c01cc60 100644 --- a/napalm_redfish/transport.py +++ b/napalm_redfish/transport.py @@ -19,6 +19,7 @@ from __future__ import annotations import ipaddress import logging import os +import re from typing import Any, Mapping from urllib.parse import urlsplit @@ -39,6 +40,11 @@ SERVICE_ROOT = "/redfish/v1/" DEFAULT_SESSIONS = "/redfish/v1/SessionService/Sessions" AUTH_METHODS = ("auto", "session", "basic") MAX_PAGES = 1000 +#: The MessageId with which a service refuses every request until a vendor license is +#: activated (Supermicro X10 without one of SUM, DCMS or OOB). +LICENSE_REFUSED = "OemLicenseNotPassed" +#: How the library reports a service root that answered with an HTTP error. +_ROOT_STATUS = re.compile(r"return code:\s*(\d{3})") Reference = str | Mapping[str, Any] @@ -52,6 +58,10 @@ class RedfishError(Exception): self.path = path +class RedfishLicenseError(RedfishError): + """The service refuses Redfish until a vendor license is activated.""" + + class RedfishAuthError(RedfishError): """Login failed, or the account may not read the resource (HTTP 401/403).""" @@ -64,6 +74,18 @@ class RedfishNotFound(RedfishHTTPError): """The resource does not exist (HTTP 404).""" +def _message_ids(body: Any) -> list[str]: + """The MessageIds of a Redfish error body's extended info.""" + error = body.get("error") if isinstance(body, Mapping) else None + if not isinstance(error, Mapping): + return [] + return [ + str(info["MessageId"]) + for info in error.get("@Message.ExtendedInfo") or [] + if isinstance(info, Mapping) and info.get("MessageId") + ] + + def _error_message(body: Any) -> str: """The human-readable part of a Redfish error body, if any.""" if not isinstance(body, Mapping): @@ -157,9 +179,9 @@ class RedfishTransport: max_retry=self.retries, ) except (RetriesExhaustedError, ServerDownOrUnreachableError) as exc: - raise RedfishError(f"{self.base_url}: service root not reachable: {exc}") from exc + raise self._root_failure(f"service root not reachable: {exc}", exc) from exc except Exception as exc: - raise RedfishError(f"{self.base_url}: cannot read the service root: {exc}") from exc + raise self._root_failure(f"cannot read the service root: {exc}", exc) from exc self._client = client self.root = dict(getattr(client, "root", None) or {}) try: @@ -169,6 +191,34 @@ class RedfishTransport: client._session.close() raise + def _root_failure(self, reason: str, exc: BaseException) -> RedfishError: + """The error for an unreadable service root, with the service's own reason if it gave one. + + The library keeps only the status code. When the service did answer, its error body is + read once more: an unlicensed Supermicro X10 refuses even the service root, and only + the body says that a license is what is missing. + """ + body = self._root_error_body() if _ROOT_STATUS.search(str(exc)) else None + detail = _error_message(body) + if any(message_id.endswith(LICENSE_REFUSED) for message_id in _message_ids(body)): + return RedfishLicenseError( + f"{self.base_url}: Redfish needs a vendor license on this BMC: {detail}" + ) + return RedfishError(f"{self.base_url}: {reason}" + (f": {detail}" if detail else "")) + + def _root_error_body(self) -> Any: + """The service root's error body, or None when there is none to read.""" + try: + resp = requests.get( + self.base_url + SERVICE_ROOT, + verify=self._cafile or False, + timeout=self.timeout, + headers={"Accept": "application/json"}, + ) + return resp.json() if resp.status_code >= 400 else None + except Exception: # noqa: BLE001 - the original error is what gets reported + return None + def _login(self, client) -> str: if self.auth == "basic": self._call(client.login, auth=AuthMethod.BASIC) diff --git a/pyproject.toml b/pyproject.toml index 11845f9..5af4242 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "napalm-redfish" -version = "0.1.0" +version = "0.1.1" description = "NAPALM driver for server baseboard management controllers over DMTF Redfish" readme = "README.md" license = { file = "LICENSE" } diff --git a/tests/fixtures/supermicro-x10drl-i-3.91/COMMANDS.txt b/tests/fixtures/supermicro-x10drl-i-3.91/COMMANDS.txt new file mode 100644 index 0000000..6b492ac --- /dev/null +++ b/tests/fixtures/supermicro-x10drl-i-3.91/COMMANDS.txt @@ -0,0 +1,6 @@ +Recorded 2026-10-13 from a Supermicro X10DRL-i, BMC firmware 3.91 (REDFISH_X10 line), +without a Supermicro license. Requests were made without credentials. + +service_root_403.json GET /redfish/v1/ -> HTTP 403, Content-Type application/json +landing_page.html GET / -> HTTP 200, the BMC web login page (trimmed to + the head and the login form action) diff --git a/tests/fixtures/supermicro-x10drl-i-3.91/landing_page.html b/tests/fixtures/supermicro-x10drl-i-3.91/landing_page.html new file mode 100644 index 0000000..1db719b --- /dev/null +++ b/tests/fixtures/supermicro-x10drl-i-3.91/landing_page.html @@ -0,0 +1,13 @@ + + + + + + + + + +
+
+ + diff --git a/tests/fixtures/supermicro-x10drl-i-3.91/service_root_403.json b/tests/fixtures/supermicro-x10drl-i-3.91/service_root_403.json new file mode 100644 index 0000000..5191275 --- /dev/null +++ b/tests/fixtures/supermicro-x10drl-i-3.91/service_root_403.json @@ -0,0 +1,20 @@ +{ + "error": { + "code": "Base.v1_4_0.GeneralError", + "Message": "A general error has occurred. See ExtendedInfo for more information.", + "@Message.ExtendedInfo": [ + { + "MessageId": "Base.v1_4_0.OemLicenseNotPassed", + "Severity": "Warning", + "Resolution": "Please activate at least one license in above message.", + "Message": "Not licensed to perform this request. The following licenses SUM DCMS OOB were needed", + "MessageArgs": [ + "SUM DCMS OOB " + ], + "RelatedProperties": [ + "" + ] + } + ] + } +} diff --git a/tests/test_discovery.py b/tests/test_discovery.py new file mode 100644 index 0000000..e039639 --- /dev/null +++ b/tests/test_discovery.py @@ -0,0 +1,47 @@ +"""What netOrk's discovery scores the driver on, checked against recorded pages. + +netOrk lowercases the page (title, Server header, body) and matches each rule's +pattern as a substring; a port that answers adds its PortSpec weight. +""" + +import json +from pathlib import Path + +from napalm_redfish import RedfishDriver + +X10 = Path(__file__).parent / "fixtures" / "supermicro-x10drl-i-3.91" + + +def _score(text: str) -> float: + text = " ".join(text.lower().split()) + return sum(rule.weight for rule in RedfishDriver.HTTP_FINGERPRINT if rule.pattern in text) + + +def test_an_open_https_port_proves_nothing(): + # Every switch with a web UI answers on 443; the port alone made six of + # them look like BMCs. + assert [spec.weight for spec in RedfishDriver.PORT_SPECS if spec.port == 443] == [0.0] + + +def test_service_root_is_still_probed(): + assert any("/redfish/v1/" in spec.paths for spec in RedfishDriver.PORT_SPECS) + + +def test_supermicro_landing_page_is_recognised(): + assert _score((X10 / "landing_page.html").read_text()) >= 6.0 + + +def test_redfish_error_body_is_recognised(): + # An unlicensed X10 refuses even the service root, but the refusal is + # unmistakably Redfish. + assert _score(json.dumps(json.loads((X10 / "service_root_403.json").read_text()))) >= 6.0 + + +def test_idrac_and_ilo_landing_pages_are_recognised(): + assert _score("iDRAC9 - Login") >= 6.0 + assert _score("iLO 5 HPE Integrated Lights-Out") >= 6.0 + + +def test_a_switch_page_scores_nothing(): + page = 'Loading...Web Switch' + assert _score(page) == 0.0 diff --git a/tests/test_driver.py b/tests/test_driver.py index 05a068c..3ef445a 100644 --- a/tests/test_driver.py +++ b/tests/test_driver.py @@ -3,6 +3,7 @@ import json from datetime import datetime from importlib.metadata import entry_points +from pathlib import Path import napalm import pytest @@ -487,3 +488,16 @@ def test_get_management_protocols_missing(driver, server): assert protocols["https"] == {"enabled": True, "port": 443} assert protocols["ipmi"] == {"enabled": False, "port": 623} assert protocols["ssh"] == {"enabled": False, "port": None} + + +def test_unlicensed_bmc_says_so(server): + # A Supermicro X10 refuses Redfish entirely until a license is activated. + x10 = Path(__file__).parent / "fixtures" / "supermicro-x10drl-i-3.91" + body = json.loads((x10 / "service_root_403.json").read_text()) + server.override("GET", "/redfish/v1/", 403, body) + d = RedfishDriver("bmc.example.net", "netork", "s3cret", optional_args={"verify_ssl": False}) + with pytest.raises(ConnectionException) as exc: + d.open() + message = str(exc.value) + assert "license" in message.lower() + assert "SUM DCMS OOB" in message