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.
328 lines
13 KiB
Python
328 lines
13 KiB
Python
"""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."""
|