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

+15
View File
@@ -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
+1 -1
View File
@@ -2,5 +2,5 @@
from napalm_redfish.driver import RedfishDriver
__version__ = "0.1.0"
__version__ = "0.1.1"
__all__ = ["RedfishDriver"]
+14 -4
View File
@@ -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
+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)
+1 -1
View File
@@ -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" }
+6
View File
@@ -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)
@@ -0,0 +1,13 @@
<!-- <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> -->
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<META http-equiv="Content-Type" content="text/html; charset=utf-8">
<META HTTP-EQUIV="Pragma" CONTENT="no_cache">
<META NAME="ATEN International Co Ltd." CONTENT="(c) ATEN International Co Ltd. 2010">
<title></title>
</head>
<body>
<form name="form1" action="/cgi/login.cgi" method="post" autocomplete="off">
</form>
</body>
</html>
@@ -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": [
""
]
}
]
}
}
+47
View File
@@ -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("<title>iDRAC9 - Login</title>") >= 6.0
assert _score("<title>iLO 5</title> HPE Integrated Lights-Out") >= 6.0
def test_a_switch_page_scores_nothing():
page = '<html><head><title>Loading...</title></head><body>Web Switch</body></html>'
assert _score(page) == 0.0
+14
View File
@@ -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