"""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."""