From 93bbc63f3d9aeaa925d6f31ad0d2ed5d781edfc4 Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Tue, 13 Oct 2026 09:58:27 +0200 Subject: [PATCH] fix: stop scoring every HTTPS port as a BMC; name the missing license netOrk's discovery took six switches for BMCs: the PortSpec for 443 added 5 points to every device with a web UI, and nothing else scored them. The port now carries no weight. The real BMC, a Supermicro X10DRL-i, was not recognised at all. Its web UI (ATEN firmware) gets a rule, as do iDRAC and iLO; discovery fetches those landing pages today. A rule for a Redfish error body covers the service root once discovery reads it. That board refuses Redfish without a Supermicro license, even the service root (403 OemLicenseNotPassed, "SUM DCMS OOB"). The library reports only the status, so open() reads the error body once more and raises RedfishLicenseError naming the licenses. Release 0.1.1. --- CHANGELOG.md | 15 ++++++ napalm_redfish/__init__.py | 2 +- napalm_redfish/driver.py | 18 +++++-- napalm_redfish/transport.py | 54 ++++++++++++++++++- pyproject.toml | 2 +- .../supermicro-x10drl-i-3.91/COMMANDS.txt | 6 +++ .../landing_page.html | 13 +++++ .../service_root_403.json | 20 +++++++ tests/test_discovery.py | 47 ++++++++++++++++ tests/test_driver.py | 14 +++++ 10 files changed, 183 insertions(+), 8 deletions(-) create mode 100644 tests/fixtures/supermicro-x10drl-i-3.91/COMMANDS.txt create mode 100644 tests/fixtures/supermicro-x10drl-i-3.91/landing_page.html create mode 100644 tests/fixtures/supermicro-x10drl-i-3.91/service_root_403.json create mode 100644 tests/test_discovery.py 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