Files

644 lines
24 KiB
Python

###
# Copyright 2020 Hewlett Packard Enterprise, Inc. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
###
# -*- coding: utf-8 -*-
"""Direct module for working with Redfish/REST technology."""
# ---------Imports---------
import base64
import hashlib
import logging
import sys
from six.moves.urllib.parse import urlparse
from redfish.rest.connections import (
Blobstore2Connection,
HttpConnection,
InvalidCredentialsError,
OneTimePasscodeError,
UnauthorizedLoginAttemptError,
TokenExpiredError,
)
# ---------End of imports---------
# ---------Debug logger---------
LOGGER = logging.getLogger(__name__)
# ---------End of debug logger---------
class ServerDownOrUnreachableError(Exception):
"""Raised when server is unreachable."""
pass
class JsonDecodingError(Exception):
"""Raised when there is an error in json data."""
pass
class AuthMethod(object):
"""AUTH Method class
**BASIC**, **SESSION**, **CERTIFICATE** variables translate to their string counterparts
`basic`, `session`, `certificate`."""
BASIC = "basic"
SESSION = "session"
CERTIFICATE = "certificate"
class RestClientBase(object):
"""Base REST client. Each RestClientBase has a connection object built by parsing the
client_kwargs. This connection is used for communicating remotely or locally.
:param biospassword: The iLO Gen9 bios password. See :func:`bios_password`
:type biospassword: str
:param \\**client_kwargs: Arguments to pass to the client argument. For possible values see
:mod:`redfish.rest.connections.Blobstore2Connection` for a local
connection or :mod:`redfish.rest.connections.HttpConnection`
for remote connection."""
def __init__(self, biospassword=None, **client_kwargs):
self.connection = None
self._biospassword = biospassword
self._build_connection(**client_kwargs)
@property
def bios_password(self):
"""Property for the biospassword. Only required on Gen9 iLO 4 when RBSU bios password is set
and modifying bios settings
"""
return self._biospassword
@bios_password.setter
def bios_password(self, bios_pw):
"""set the bios password"""
self._biospassword = bios_pw
def _build_connection(self, **conn_kwargs):
"""Build the appropriate connection for the client"""
base_url = conn_kwargs.pop("base_url", None)
if not base_url or base_url.startswith("blobstore://"):
self.connection = Blobstore2Connection(**conn_kwargs)
elif not base_url.startswith("https://"):
base_url = "https://" + base_url
_ = conn_kwargs.pop("username", None)
_ = conn_kwargs.pop("password", None)
_ = conn_kwargs.pop("sessionid", None)
_ = conn_kwargs.pop("login_otp", None)
self.connection = HttpConnection(base_url, self._cert_data, **conn_kwargs)
else:
_ = conn_kwargs.pop("username", None)
_ = conn_kwargs.pop("password", None)
_ = conn_kwargs.pop("sessionid", None)
_ = conn_kwargs.pop("login_otp", None)
self.connection = HttpConnection(base_url, self._cert_data, **conn_kwargs)
def _get_req_headers(self, headers=None):
"""Base _get_req_headers function"""
return headers if headers else {}
def get(self, path, args=None, headers=None, noauth=False):
"""Perform a GET request
:param path: The URI path.
:type path: str
:param args: Any query to add to the URI. (Can also be directly added to the URI)
:type args: dict
:param headers: Any extra headers to add to the request.
:type headers: dict
:returns: A :class:`redfish.rest.containers.RestResponse` object
"""
try:
return self.connection.rest_request(
path,
method="GET",
args=args,
headers=self._get_req_headers(headers=headers, noauth=noauth),
)
except ValueError:
LOGGER.debug("Error in json object getting path: %s", path)
raise JsonDecodingError("Error in json decoding.")
def patch(self, path, body, args=None, headers=None, noauth=False):
"""Perform a PATCH request
:param path: The URI path.
:type path: str
:param body: The body to pass with the request.
:type body: dict
:param args: Any query to add to the URI. (Can also be directly added to the URI)
:type args: dict
:param headers: Any extra headers to add to the request.
:type headers: dict
:returns: A :class:`redfish.rest.containers.RestResponse` object
"""
return self.connection.rest_request(
path,
body=body,
method="PATCH",
args=args,
headers=self._get_req_headers(headers=headers, noauth=noauth),
)
def post(self, path, body, args=None, headers=None, noauth=False):
"""Perform a POST request
:param path: The URI path.
:type path: str
:param body: The body to pass with the request.
:type body: dict
:param args: Any query to add to the URI. (Can also be directly added to the URI)
:type args: dict
:param headers: Any extra headers to add to the request.
:type headers: dict
:returns: A :class:`redfish.rest.containers.RestResponse` object
"""
return self.connection.rest_request(
path,
body=body,
method="POST",
args=args,
headers=self._get_req_headers(headers=headers, noauth=noauth),
)
def put(self, path, body, args=None, headers=None, noauth=False):
"""Perform a PUT request
:param path: The URI path.
:type path: str
:param body: The body to pass with the request.
:type body: dict
:param args: Any query to add to the URI. (Can also be directly added to the URI)
:type args: dict
:param headers: Any extra headers to add to the request.
:type headers: dict
:returns: A :class:`redfish.rest.containers.RestResponse` object
"""
return self.connection.rest_request(
path,
body=body,
method="PUT",
args=args,
headers=self._get_req_headers(headers=headers, noauth=noauth),
)
def head(self, path, headers=None):
"""Perform a HEAD request
:param path: The URI path.
:type path: str
:param headers: Any extra headers to add to the request.
:type headers: dict
:returns: A :class:`redfish.rest.containers.RestResponse` object
"""
return self.connection.rest_request(path, method="HEAD", headers=self._get_req_headers(headers=headers))
def delete(self, path, headers=None):
"""Perform a DELETE request
:param path: The URI path.
:type path: str
:param args: Any query to add to the URI. (Can also be directly added to the URI)
:type args: dict
:returns: A :class:`redfish.rest.containers.RestResponse` object
"""
return self.connection.rest_request(path, method="DELETE", headers=self._get_req_headers(headers=headers))
class RestClient(RestClientBase):
"""REST client with Redfish and LegacyRest support built on top.
:param default_prefix: The root of the REST API, either /redfish/v1/ or /rest/v1.
:type default_prefix: str
:param is_redfish: Flag to force redfish conformance, even on LegacyRest clients.
:type is_redfish: bool
:param username: The username of the account to login with.
:type username: str
:param password: The password of the account to login with.
:type password: str
:param auth: The authentication type to force.
:type auth: str or :class:`AuthMethod` class variable.
:param ca_cert_data: Dictionary containing the certificate authority data (user CA, \
user root CA, user root CA key
:type ca_cert_data: dict
:param \\**client_kwargs: Arguments to create a :class:`RestClientBase` instance.
"""
def __init__(
self,
default_prefix="/redfish/v1/",
is_redfish=True,
username=None,
password=None,
sessionid=None,
base_url=None,
auth=None,
ca_cert_data=None,
login_otp=None,
session_location=None,
**client_kwargs
):
"""Create a Rest Client object"""
self.default_prefix = default_prefix
self.is_redfish = is_redfish
self.root = None
self.auth_type = self._get_auth_type(auth, ca_cert_data=ca_cert_data, **client_kwargs)
self._auth_key = None
self._user_pass = (username, password)
self._session_location = None
self._cert_data = ca_cert_data
self.login_otp = login_otp
super(RestClient, self).__init__(
username=username,
password=password,
sessionid=sessionid,
base_url=base_url,
login_otp=login_otp,
session_location=session_location,
**client_kwargs
)
def __enter__(self):
"""Create a connection and return the session object"""
self.login()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Close the connection"""
self.logout()
def _get_auth_type(self, auth_param, ca_cert_data=None, **client_kwargs):
"""Get the auth type based on key args or positional argument.
Defaults to session auth."""
if not auth_param:
# _ca_cert_data = client_kwargs.get('ca_cert_data')
if ca_cert_data:
if ("ca_certs" in ca_cert_data and ca_cert_data["ca_certs"]) or (
"cert_file" in ca_cert_data and ca_cert_data["cert_file"]
):
if (ca_cert_data.get("cert_file") and ca_cert_data.get("key_file")) or ca_cert_data.get("ca_certs"):
return AuthMethod.CERTIFICATE
return AuthMethod.SESSION
return auth_param
@property
def base_url(self):
"""The connection's URL to make calls against"""
return self.connection.base_url
@property
def proxy(self):
"""The connection's proxy, if any."""
try:
proxy = self.connection.proxy
except AttributeError:
proxy = None
return proxy
@property
def session_key(self):
"""The Client's session key, if any."""
return self._auth_key if self.auth_type in [AuthMethod.SESSION, AuthMethod.CERTIFICATE] else None
@session_key.setter
def session_key(self, ses_key):
"""Set _auth_key to a session key"""
self._auth_key = ses_key
@property
def basic_auth(self):
"""The Client's basic auth header, if any."""
return self._auth_key if self.auth_type == AuthMethod.BASIC else None
@basic_auth.setter
def basic_auth(self, bas_key):
"""Set _auth_key to a basic auth header"""
self._auth_key = bas_key
@property
def session_location(self):
"""The session URI. Used for deleting the session when we logout."""
session_loc = None
if self._session_location:
if self.base_url == "blobstore://.":
session_loc = self._session_location.replace("https://", "")
session_loc = session_loc.replace(" ", "%20")
else:
parse_object = urlparse(self.base_url)
if parse_object.hostname is not None:
newurl = "https://" + parse_object.hostname
session_loc = self._session_location.replace(newurl, "")
else:
session_loc = self._session_location.replace(self.base_url, "")
return session_loc
@session_location.setter
def session_location(self, ses_loc):
"""Set the session URI"""
login_url_p = urlparse(self.base_url)
new_port = login_url_p.port
# The session_url returned by iLO does not include the port information
# If we're using a non-standard port to connect to iLO we have to
# manually insert it, based on the base_url.
if new_port and ses_loc:
session_location_p = urlparse(ses_loc)
session_location_p = session_location_p._replace(
netloc="{}:{}".format(session_location_p.hostname, new_port)
)
self._session_location = session_location_p.geturl()
else:
self._session_location = ses_loc
@property
def username(self):
"""The username, if any. Once a login function has been called the credentials are removed
from memory for security and this will return None."""
return self._user_pass[0]
@property
def password(self):
"""The password, if any. Once a login function has been called the credentials are removed
from memory for security and this will return None."""
return self._user_pass[1]
@property
def login_url(self):
"""The login URI from the root response. This is where we post the
credentials for a login."""
login_url = None
try:
login_url = self.root.obj.Links.Sessions["@odata.id"]
except KeyError:
login_url = self.root.obj.links.Sessions.href
finally:
if not login_url:
raise ServerDownOrUnreachableError("Cannot locate the login url. Is this a Rest or" " Redfish server?")
return login_url
def login(self, auth=AuthMethod.SESSION):
"""Login to a Redfish or LegacyRest server.
If auth is not supplied login will intelligently
choose the authentication mode based on the arguments passed.
Basic authentication MUST be specified with `auth`.
:param auth: The auth type to login with.
:type auth: str or :class:`AuthMethod` class variable
"""
if auth:
auth = self._get_auth_type(auth)
self.auth_type = auth
if self.auth_type is AuthMethod.BASIC:
self._get_root()
self._basic_login()
elif self.auth_type is AuthMethod.SESSION:
self._get_root()
self._session_login()
elif self.auth_type is AuthMethod.CERTIFICATE:
self._cert_login()
self._get_root()
def logout(self):
"""Logout of session.
YOU MUST CALL THIS WHEN YOU ARE DONE TO FREE UP SESSIONS"""
if self.session_location:
resp = self.delete(self.session_location)
LOGGER.info("User logged out: %s", resp.read)
self._auth_key = None
self.session_location = None
def _get_root(self):
"""Get the root response of the server"""
if not self.root:
resp = self.get(self.default_prefix)
if resp.status != 200:
raise ServerDownOrUnreachableError("Server not reachable, " "return code: %d" % resp.status)
self.root = resp
def _basic_login(self):
"""Login using basic authentication"""
LOGGER.info("Performing basic authentication.")
if not self.basic_auth:
auth_key = base64.b64encode(("{}:{}".format(self.username, self.password)).encode("utf-8")).decode("utf-8")
self.basic_auth = "Basic {}".format(auth_key)
headers = dict()
headers["Authorization"] = self.basic_auth
respvalidate = self.get(self.login_url, headers=headers)
if respvalidate.status == 401:
self._credential_err()
else:
self._user_pass = (None, None)
def _session_login(self):
"""Login using session authentication"""
if not self.connection.session_key:
LOGGER.info("Performing session authentication.")
data = dict()
data["UserName"] = self.username
data["Password"] = self.password
if self.login_otp is not None:
data["Token"] = self.login_otp
headers = dict()
resp = self.post(self.login_url, body=data, headers=headers)
try:
respread = resp.read
respread = respread.replace("\\\\", "\\")
# LOGGER.info("%s" % respread)
except ValueError:
pass
LOGGER.debug("Login returned code %s: %s", resp.status, respread)
self.session_key = resp.session_key
self.session_location = resp.session_location
self.login_return_code = resp.status
self.login_response = respread
else:
self.session_key = self.connection.session_key
if self.connection.session_location:
self.session_location = self.connection.session_location
if hasattr(self, "login_response") and self.login_response:
if "OneTimePasscodeSent" in self.login_response:
raise OneTimePasscodeError()
elif "UnauthorizedLogin" in self.login_response:
if self.login_otp:
raise UnauthorizedLoginAttemptError(
"Error " + str(self.login_return_code) + ". Login is unauthorized.\n"
"Please check the credentials/OTP entered.\n"
)
else:
self._credential_err()
elif "TokenExpired" in self.login_response:
raise TokenExpiredError(
"Error "
+ str(self.login_return_code)
+ ". The OTP entered has expired. Please enter credentials again.\n"
)
elif not self.session_key and not resp.status == 200:
self._credential_err()
else:
self._user_pass = (None, None)
def _cert_login(self):
"""Login using certificate authentication"""
self.session_key, self.session_location = self.connection.cert_login()
def _credential_err(self):
"""Return credential error based on delay"""
try:
if self.is_redfish:
delay = self.root.obj.Oem.Hpe.Sessions.LoginFailureDelay
else:
delay = self.root.obj.Oem.Hp.Sessions.LoginFailureDelay
except KeyError:
delay = 5
raise InvalidCredentialsError(delay)
def _get_req_headers(self, headers=None, optionalpassword=None, noauth=False):
"""Get the request headers
:param headers: additional headers to be utilized
:type headers: str
:param optionalpassword: provide password for authentication.
:type optionalpassword: str.
:returns: returns headers
"""
headers = headers if isinstance(headers, dict) else dict()
h_list = [header.lower() for header in headers]
auth_headers = True if "x-auth-token" in h_list or "authorization" in h_list else False
token = self._biospassword if self._biospassword else optionalpassword
if token:
token = optionalpassword.encode("utf-8") if type(optionalpassword).__name__ in "basestr" else token
hash_object = hashlib.new("SHA256")
hash_object.update(token)
if not noauth:
headers["X-HPRESTFULAPI-AuthToken"] = hash_object.hexdigest().upper()
if self.session_key and not auth_headers:
if not noauth:
headers["X-Auth-Token"] = self.session_key
elif self.basic_auth and not auth_headers:
if not noauth:
headers["Authorization"] = self.basic_auth
if self.is_redfish:
if not noauth:
headers["OData-Version"] = "4.0"
return headers
def get_resource_directory(self):
try:
resource_uri = self.root.obj.Oem.Hpe.Links.ResourceDirectory["@odata.id"]
except KeyError:
sys.stderr.write("Resource directory is only available on HPE servers.\n")
return None
response = self.get(resource_uri)
resources = []
if response.status == 200:
sys.stdout.write("\tFound resource directory at /redfish/v1/resourcedirectory" + "\n\n")
resources = response.dict["Instances"]
else:
sys.stderr.write("\tResource directory missing at /redfish/v1/resourcedirectory" + "\n")
return resources
def get_gen(self):
rootresp = self.root.obj
# Default iLO 5
ilogen = 5
iloversion = None
gencompany = next(iter(rootresp.get("Oem", {}).keys()), None) in ("Hpe", "Hp")
comp = "Hp" if gencompany else None
comp = "Hpe" if rootresp.get("Oem", {}).get("Hpe", None) else comp
if comp and next(iter(rootresp.get("Oem", {}).get(comp, {}).get("Manager", {}))).get("ManagerType", None):
ilogen = next(iter(rootresp.get("Oem", {}).get(comp, {}).get("Manager", {}))).get("ManagerType")
ilover = next(iter(rootresp.get("Oem", {}).get(comp, {}).get("Manager", {}))).get("ManagerFirmwareVersion")
if ilogen.split(" ")[-1] == "CM":
# Assume iLO 4 types in Moonshot
ilogen = 4
iloversion = None
else:
ilogen = ilogen.split(" ")[1]
iloversion = float(ilogen.split(" ")[-1] + "." + "".join(ilover.split(".")))
return (ilogen, iloversion)
class LegacyRestClient(RestClient):
"""Class for a **Legacy REST** client instance.
Instantiates appropriate Rest object based on existing configuration.
Use this to retrieve a pre-configured Legacy Rest Class.
Basic arguments include (These can be omitted for most local connections):
* **base_url**: The IP or Hostname of the server to perform operations on.
* **username**: The username of the account to login with.
* **password**: The username of the account to login with.
For full description of the arguments allowed see :class:`RestClient`"""
def __init__(self, **client_kwargs):
kwargs = {
"default_prefix": client_kwargs.pop("default_prefix", "/rest/v1"),
"is_redfish": client_kwargs.pop("is_redfish", False),
}
kwargs.update(client_kwargs)
super(LegacyRestClient, self).__init__(**kwargs)
class RedfishClient(RestClient):
"""Class for a **Redfish** client instance.
Instantiates appropriate Redfish object based on existing configuration.
Use this to retrieve a pre-configured Redfish Class.
Basic arguments include (These can be omitted for most local connections):
* **base_url**: The IP or Hostname of the server to perform operations on. None for local.
* **username**: The username of the account to login with.
* **password**: The username of the account to login with.
For full description of the arguments allowed see :class:`RestClient`"""
def __init__(self, **client_kwargs):
kwargs = {
"default_prefix": client_kwargs.pop("default_prefix", "/redfish/v1"),
"is_redfish": client_kwargs.pop("is_redfish", True),
}
kwargs.update(client_kwargs)
super(RedfishClient, self).__init__(**kwargs)