Add Redfish transport

Session login through the SessionService with X-Auth-Token, the session deleted on close (also on errors and when the DELETE fails), basic auth as fallback only when the service cannot create sessions, never after a rejected password. Certificate verification is on by default and configurable per device; True passes the requests CA bundle explicitly because the redfish library disables verification without a CA file.

Collections are followed by @odata.id, including expanded members and nextLink pages. Links to other hosts are refused so the session token stays on the BMC.
This commit is contained in:
Christian Manivong committed 2026-10-12 23:42:14 +00:00
1 parent c8bcd5ace1
commit 8998e1342e
2 files changed
+755

No files matched your search

+327
View File
@@ -0,0 +1,327 @@
"""HTTPS transport to a Redfish service, on top of the DMTF ``redfish`` library.
The library does the HTTP work; this module adds what a BMC driver needs on top:
- Session login through the SessionService (``X-Auth-Token``), with the session deleted on
close, because BMCs allow only a few concurrent sessions. Basic auth is the fallback for
services that cannot create sessions, never a second try after a rejected password.
- Certificate verification that is on unless turned off per device. The library disables
verification whenever it is not given a CA file, so ``verify=True`` passes the CA bundle
of ``requests`` explicitly.
- Collections followed by ``@odata.id`` (including ``nextLink`` pages and expanded members)
instead of assumed member IDs.
- Links followed only on the BMC itself: an ``@odata.id`` pointing at another host (a PDU
outlet, for example) is refused, so the session token never leaves the BMC.
"""
from __future__ import annotations
import ipaddress
import logging
import os
from typing import Any, Mapping
from urllib.parse import urlsplit
import requests
from redfish.rest.v1 import (
AuthMethod,
InvalidCredentialsError,
RedfishPasswordChangeRequiredError,
RetriesExhaustedError,
ServerDownOrUnreachableError,
SessionCreationError,
redfish_client,
)
LOG = logging.getLogger(__name__)
SERVICE_ROOT = "/redfish/v1/"
DEFAULT_SESSIONS = "/redfish/v1/SessionService/Sessions"
AUTH_METHODS = ("auto", "session", "basic")
MAX_PAGES = 1000
Reference = str | Mapping[str, Any]
class RedfishError(Exception):
"""A Redfish request failed."""
def __init__(self, message: str, *, status: int | None = None, path: str | None = None):
super().__init__(message)
self.status = status
self.path = path
class RedfishAuthError(RedfishError):
"""Login failed, or the account may not read the resource (HTTP 401/403)."""
class RedfishHTTPError(RedfishError):
"""The service answered with an HTTP error status."""
class RedfishNotFound(RedfishHTTPError):
"""The resource does not exist (HTTP 404)."""
def _error_message(body: Any) -> str:
"""The human-readable part of a Redfish error body, if any."""
if not isinstance(body, Mapping):
return ""
error = body.get("error")
if not isinstance(error, Mapping):
return ""
messages = [
info.get("Message")
for info in error.get("@Message.ExtendedInfo") or []
if isinstance(info, Mapping) and info.get("Message")
]
if not messages and error.get("message"):
messages = [error["message"]]
return "; ".join(str(m) for m in messages)
class RedfishTransport:
"""One authenticated connection to a Redfish service.
:param hostname: BMC host name or IP address (IPv6 without brackets is fine).
:param port: HTTPS port, default 443.
:param verify: ``True`` verifies the certificate against the CA bundle of ``requests``,
a path verifies against that CA file, ``False`` turns verification off.
:param auth: ``"auto"`` (session, basic auth if the service cannot create sessions),
``"session"`` or ``"basic"``.
:param timeout: seconds per request.
:param retries: extra attempts after a connection error (the library waits 1 s between).
"""
def __init__(
self,
hostname: str,
username: str,
password: str,
*,
port: int | None = None,
verify: bool | str = True,
auth: str = "auto",
timeout: float = 30,
retries: int = 1,
):
if auth not in AUTH_METHODS:
raise ValueError(f"auth must be one of {', '.join(AUTH_METHODS)}, not {auth!r}")
if isinstance(verify, str) and not os.path.isfile(verify):
raise ValueError(f"CA file not found: {verify}")
self.hostname = hostname
self.username = username
self.password = password
self.port = port
self.verify = verify
self.auth = auth
self.timeout = timeout
self.retries = retries
self.root: dict[str, Any] = {}
self.auth_method: str | None = None
self._client = None
@property
def base_url(self) -> str:
host = self.hostname
try:
if ipaddress.ip_address(host).version == 6:
host = f"[{host}]"
except ValueError:
pass
return f"https://{host}" + (f":{self.port}" if self.port else "")
@property
def _cafile(self) -> str | None:
if self.verify is True:
return requests.certs.where()
if self.verify is False:
return None
return self.verify
# --- connection --------------------------------------------------------------------
def open(self) -> None:
"""Read the service root (no credentials needed) and log in. No-op when open."""
if self._client is not None:
return
try:
client = redfish_client(
base_url=self.base_url,
username=self.username,
password=self.password,
default_prefix=SERVICE_ROOT,
cafile=self._cafile,
timeout=self.timeout,
max_retry=self.retries,
)
except (RetriesExhaustedError, ServerDownOrUnreachableError) as exc:
raise RedfishError(f"{self.base_url}: service root not reachable: {exc}") from exc
except Exception as exc:
raise RedfishError(f"{self.base_url}: cannot read the service root: {exc}") from exc
self._client = client
self.root = dict(getattr(client, "root", None) or {})
try:
self.auth_method = self._login(client)
except BaseException:
self._client = None
client._session.close()
raise
def _login(self, client) -> str:
if self.auth == "basic":
self._call(client.login, auth=AuthMethod.BASIC)
return "basic"
try:
self._call(client.login, auth=AuthMethod.SESSION)
except _SessionUnsupported as exc:
if self.auth == "session":
raise RedfishAuthError(f"{self.base_url}: session login failed: {exc}") from exc
LOG.info("%s: no Redfish session (%s), falling back to basic auth", self.base_url, exc)
else:
if client.get_session_key():
return "session"
if self.auth == "session":
raise RedfishAuthError(f"{self.base_url}: session login returned no X-Auth-Token")
LOG.info("%s: session login returned no token, falling back to basic auth",
self.base_url)
self._call(client.login, auth=AuthMethod.BASIC)
return "basic"
def _call(self, login, **kwargs) -> None:
try:
login(**kwargs)
except InvalidCredentialsError as exc:
raise RedfishAuthError(f"{self.base_url}: invalid credentials", status=401) from exc
except RedfishPasswordChangeRequiredError as exc:
raise RedfishAuthError(f"{self.base_url}: the account must change its password "
"before it can be used") from exc
except SessionCreationError as exc:
raise _SessionUnsupported(str(exc).strip()) from exc
except (RetriesExhaustedError, ServerDownOrUnreachableError) as exc:
raise RedfishError(f"{self.base_url}: login failed: {exc}") from exc
def close(self) -> None:
"""Delete the session (if any) and close the HTTP connection. Never raises."""
client, self._client = self._client, None
self.auth_method = None
if client is None:
return
try:
client.logout()
except Exception as exc: # a leaked session times out; a failing close must not mask
LOG.warning("%s: could not delete the Redfish session: %s", self.base_url, exc)
client._session.close()
def __enter__(self) -> "RedfishTransport":
self.open()
return self
def __exit__(self, *exc_info) -> None:
self.close()
# --- requests ----------------------------------------------------------------------
def path(self, ref: Reference) -> str:
"""Request path for a path, an absolute URL on this BMC or an ``{"@odata.id": ...}``."""
if isinstance(ref, Mapping):
ref = ref.get("@odata.id")
if not isinstance(ref, str) or not ref:
raise RedfishError(f"not a Redfish reference: {ref!r}")
parts = urlsplit(ref)
if parts.scheme or parts.netloc:
ours = urlsplit(self.base_url)
if parts.scheme != "https" or parts.netloc.lower() != ours.netloc.lower():
raise RedfishError(f"refusing to follow a link to another host: {ref}")
if not parts.path.startswith("/"):
raise RedfishError(f"not an absolute Redfish path: {ref}")
return parts.path + (f"?{parts.query}" if parts.query else "")
def _request(self, method: str, ref: Reference, body: Any = None) -> dict[str, Any]:
if self._client is None:
raise RedfishError("not connected: call open() first")
path = self.path(ref)
try:
if method == "GET":
resp = self._client.get(path)
else:
resp = self._client.post(path, body=body)
except RetriesExhaustedError as exc:
raise RedfishError(f"{method} {path}: no response", path=path) from exc
status = resp.status
try:
data = resp.dict
except Exception as exc:
if status < 400:
raise RedfishError(f"{method} {path}: response is not JSON",
status=status, path=path) from exc
data = {}
if status < 400:
if not isinstance(data, dict):
raise RedfishError(f"{method} {path}: response is not a JSON object",
status=status, path=path)
return data
detail = _error_message(data)
message = f"{method} {path}: HTTP {status}" + (f": {detail}" if detail else "")
if status in (401, 403):
raise RedfishAuthError(message, status=status, path=path)
if status == 404:
raise RedfishNotFound(message, status=status, path=path)
raise RedfishHTTPError(message, status=status, path=path)
def get(self, ref: Reference) -> dict[str, Any]:
return self._request("GET", ref)
def get_optional(self, ref: Reference | None) -> dict[str, Any] | None:
"""GET, but ``None`` for a missing reference or a 404."""
if ref is None:
return None
try:
return self.get(ref)
except RedfishNotFound:
return None
def post(self, ref: Reference, body: Mapping[str, Any]) -> dict[str, Any]:
return self._request("POST", ref, dict(body))
def follow(self, resource: Mapping[str, Any], key: str) -> dict[str, Any] | None:
"""The resource linked as ``resource[key]``; ``None`` if not linked or not found."""
link = resource.get(key)
if not isinstance(link, Mapping) or "@odata.id" not in link:
return None
return self.get_optional(link)
def members(self, collection: Reference, *, limit: int | None = None) -> list[dict[str, Any]]:
"""All members of a collection, each fetched by its ``@odata.id``.
Members the service already expanded inline are used as they are. ``nextLink`` pages
are followed. ``limit`` stops after that many members.
"""
result: list[dict[str, Any]] = []
page_ref: Reference | None = collection
seen: set[str] = set()
while page_ref is not None and len(seen) < MAX_PAGES:
page_path = self.path(page_ref)
if page_path in seen:
LOG.warning("%s: nextLink loop at %s", self.base_url, page_path)
break
seen.add(page_path)
page = self.get(page_path)
for member in page.get("Members") or []:
if limit is not None and len(result) >= limit:
return result
if not isinstance(member, Mapping):
continue
if "Id" in member or "@odata.type" in member:
result.append(dict(member))
elif "@odata.id" in member:
result.append(self.get(member))
if limit is not None and len(result) >= limit:
break
page_ref = page.get("Members@odata.nextLink") or page.get("@odata.nextLink")
return result
class _SessionUnsupported(Exception):
"""Session creation failed for a reason other than bad credentials."""