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.
This commit is contained in:
christianmanivong committed 2026-10-13 09:58:40 +02:00
1 parent c7d947a870
commit 93bbc63f3d
10 files changed
+183 -8

No files matched your search

+52 -2
View File
@@ -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)