Files

625 lines
23 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 -*-
"""All Connections for interacting with REST."""
import gzip
import json
import logging
import time
import urllib3
from urllib3 import PoolManager, ProxyManager
from urllib3.exceptions import DecodeError, MaxRetryError
try:
urllib3.disable_warnings()
from urllib3.contrib.socks import SOCKSProxyManager
except ImportError:
pass
import six
from six import BytesIO
from six.moves.urllib.parse import urlencode, urlparse
from redfish.hpilo.risblobstore2 import (
Blob2OverrideError,
Blob2SecurityError,
BlobStore2,
)
from redfish.hpilo.rishpilo import HpIloChifPacketExchangeError
from redfish.rest.containers import RestRequest, RestResponse, RisRestResponse
from redfish.security_masking import SecurityMasker
# ---------End of imports---------
# ---------Debug logger---------
LOGGER = logging.getLogger(__name__)
# ---------End of debug logger---------
class RetriesExhaustedError(Exception):
"""Raised when retry attempts have been exhausted."""
pass
class VnicNotEnabledError(Exception):
"""Raised when retry attempts have been exhausted when VNIC is not enabled."""
pass
class DecompressResponseError(Exception):
"""Raised when decompressing the response failed."""
pass
class InvalidCredentialsError(Exception):
"""Raised when invalid credentials have been provided."""
pass
class InvalidCertificateError(Exception):
"""Raised when a invalid certificate has been provided."""
pass
class ChifDriverMissingOrNotFound(Exception):
"""Raised when CHIF driver is missing or not found."""
pass
class SecurityStateError(Exception):
"""Raised when there is a strict security state without authentication."""
pass
class OneTimePasscodeError(Exception):
"""Raised when OTP is sent to the registered email."""
pass
class TokenExpiredError(Exception):
"""Raised when OTP entered has expired."""
pass
class UnauthorizedLoginAttemptError(Exception):
"""Raised when Login is Unauthorized"""
pass
class HttpConnection(object):
"""HTTP connection capable of authenticating with HTTPS and Http/Socks Proxies
:param base_url: The URL to make HTTP calls against
:type base_url: str
:param \\**client_kwargs: Arguments to pass to the connection initialization. These are"
"passed to a urllib3 `PoolManager <https://urllib3.readthedocs.io/en/latest/reference/"
"index.html?highlight=PoolManager#urllib3.PoolManager>`_. All arguments that can be passed to "
"a PoolManager are valid arguments."
"""
def __init__(self, base_url, cert_data, **client_kwargs):
self._conn = None
self.base_url = base_url
# Default values for connection properties
self._connection_properties = {
"timeout": urllib3.util.Timeout(connect=4800, read=4800),
"retries": urllib3.util.Retry(connect=50, read=50, redirect=50),
}
self._connection_properties.update(client_kwargs)
if cert_data:
if ("cert_file" in cert_data and cert_data["cert_file"]) or (
"ca_certs" in cert_data and cert_data["ca_certs"]
):
self._connection_properties.update({"ca_cert_data": cert_data})
self._proxy = self._connection_properties.pop("proxy", None)
self.session_key = self._connection_properties.pop("session_key", None)
self.session_location = self._connection_properties.pop("session_location", None)
self.log_dir = self._connection_properties.pop("log_dir", None)
self._init_connection()
@property
def proxy(self):
"""The proxy, if any."""
return self._proxy
@proxy.setter
def proxy(self, proxy):
"""set the proxy"""
self._proxy = proxy
def _init_connection(self):
"""Function for initiating connection with remote server"""
# For certificate-based authentication, we don't verify the server's certificate
# We're providing our client certificate to authenticate TO the server
cert_reqs = "CERT_NONE"
if self._connection_properties.get("ca_cert_data"):
LOGGER.info("Using certificate-based authentication.")
ca_cert_data = self._connection_properties.pop("ca_cert_data")
# Extract certificate files
cert_file = ca_cert_data.get("cert_file")
key_file = ca_cert_data.get("key_file")
ca_certs = ca_cert_data.get("ca_certs")
# Add client certificate and key for authentication
if cert_file:
self._connection_properties["cert_file"] = cert_file
if key_file:
self._connection_properties["key_file"] = key_file
# If CA certs provided, use them to verify server certificate
if ca_certs:
self._connection_properties["ca_certs"] = ca_certs
cert_reqs = "CERT_REQUIRED"
LOGGER.info("Server certificate verification enabled with provided CA.")
if self.proxy:
if self.proxy.startswith("socks"):
LOGGER.info("Initializing a SOCKS proxy.")
http = SOCKSProxyManager(self.proxy, cert_reqs=cert_reqs, maxsize=50, **self._connection_properties)
else:
LOGGER.info("Initializing a HTTP proxy.")
http = ProxyManager(self.proxy, cert_reqs=cert_reqs, maxsize=50, **self._connection_properties)
else:
LOGGER.info("Initializing no proxy.")
http = PoolManager(cert_reqs=cert_reqs, maxsize=50, **self._connection_properties)
self._conn = http.request
def rest_request(self, path, method="GET", args=None, body=None, headers=None):
"""Format and do HTTP Rest request
:param path: The URI path to perform the operation on.
:type path: str
:param method: method to perform on the path.
:type method: str
:param args: Any query to add to the URI. (Can also be directly added to the URI)
:type args: dict
:param body: body payload to include in the request if needed.
:type body: dict
:param headers: Any extra headers to add to the request.
:type headers: dict
:returns: A :class:`redfish.rest.containers.RestResponse` object
"""
# TODO: Need to remove redfish.dmtf.org calls from here, add to their own HttpConnection
files = None
request_args = {}
if isinstance(path, bytes):
path = path.decode("utf-8")
external_uri = True if "redfish.dmtf.org" in path else False
else:
external_uri = True if "redfish.dmtf.org" in path else False
headers = {} if external_uri else headers
reqpath = path.replace("//", "/") if not external_uri else path
if body is not None:
if body and isinstance(body, list) and isinstance(body[0], tuple):
files = body
body = None
elif isinstance(body, (dict, list)):
headers["Content-Type"] = "application/json"
body = json.dumps(body)
elif not files:
headers["Content-Type"] = "application/octet-stream"
if method == "PUT":
resp = self.rest_request(method="HEAD", path=path, headers=headers)
try:
if resp.getheader("content-encoding") == "gzip":
buf = BytesIO()
gfile = gzip.GzipFile(mode="wb", fileobj=buf)
try:
gfile.write(str(body).encode("utf-8") if six.PY3 else str(body))
finally:
gfile.close()
compresseddata = buf.getvalue()
if compresseddata:
data = bytearray()
data.extend(memoryview(compresseddata))
body = data
except BaseException as excp:
LOGGER.error("Error occur while compressing body: %s", excp)
raise
if args:
if method == "GET":
reqpath += "?" + urlencode(args)
elif method == "PUT" or method == "POST" or method == "PATCH":
headers["Content-Type"] = "application/x-www-form-urlencoded"
body = urlencode(args)
# TODO: ADD to the default headers?
if headers is not None:
headers["Accept-Encoding"] = "gzip"
restreq = RestRequest(path, method, data=files if files else body, url=self.base_url)
if LOGGER.isEnabledFor(logging.DEBUG):
try:
logbody = None
if restreq.body:
if restreq.body[0] == "{":
logbody = restreq.body
else:
raise KeyError()
if restreq.method in ["POST", "PATCH"]:
debugjson = json.loads(restreq.body)
debugjson_masked = SecurityMasker.mask_simple_body(debugjson)
logbody = json.dumps(debugjson_masked)
logbody = logbody.replace("\\\\", "\\")
headers_masked = SecurityMasker.mask_http_headers(headers)
LOGGER.debug(
"HTTP REQUEST: %s\n\tPATH: %s\n\t" "HEADERS: %s\n\tBODY: %s",
restreq.method,
restreq.path,
headers_masked,
logbody,
)
except:
LOGGER.debug(
"HTTP REQUEST: %s\n\tPATH: %s\n\tBODY: %s",
restreq.method,
restreq.path,
"binary body",
)
inittime = time.time()
reqfullpath = self.base_url + reqpath if not external_uri else reqpath
# To ensure we don't have unicode/string merging issues in httplib of Python 2
if isinstance(reqfullpath, six.text_type):
reqfullpath = str(reqfullpath)
if headers:
request_args["headers"] = headers
if files:
request_args["fields"] = files
else:
request_args["body"] = body
try:
resp = self._conn(method, reqfullpath, **request_args)
except MaxRetryError:
vnic_url = "16.1.15.1"
if reqfullpath.find(vnic_url) != -1:
raise VnicNotEnabledError()
raise RetriesExhaustedError()
except DecodeError:
raise DecompressResponseError()
endtime = time.time()
LOGGER.info("Response Time to %s: %s seconds.", restreq.path, str(endtime - inittime))
restresp = RestResponse(restreq, resp)
# if restresp.request.body:
# respbody = restresp.read
# respbody = respbody.replace("\\\\", "\\")
if LOGGER.isEnabledFor(logging.DEBUG):
headerstr = ""
if restresp is not None:
respheader = restresp.getheaders()
for kiy, headerval in respheader.items():
headerstr += "\t" + kiy + ": " + headerval + "\n"
try:
headerstr_masked = SecurityMasker.mask_http_headers(headerstr)
response_body = SecurityMasker.mask_simple_body(restresp.read)
LOGGER.debug(
"HTTP RESPONSE for %s:\nCode:%s\nHeaders:" "\n%s\nBody Response of %s: %s",
restresp.request.path,
str(restresp._http_response.status) + " " + restresp._http_response.reason,
headerstr_masked,
restresp.request.path,
response_body,
)
except:
LOGGER.debug("HTTP RESPONSE:\nCode:%s", restresp)
else:
LOGGER.debug("HTTP RESPONSE: No HTTP Response obtained")
return restresp
def cert_login(self):
"""Login using a certificate."""
resp = self.rest_request("/html/login_cert.html", "GET")
if resp.status == 200 or resp.status == 201:
token = resp.getheader("X-Auth-Token")
location = resp.getheader("Location")
else:
raise InvalidCertificateError("")
return token, location
class Blobstore2Connection(object):
"""A connection for communicating locally with HPE servers
:param \\**conn_kwargs: Arguments to pass to the connection initialization.
Possible arguments for *\\**conn_kwargs* include:
:username: The username to login with
:password: The password to login with
"""
_http_vsn_str = "HTTP/1.1"
blobstore_headers = {"Accept": "*/*", "Connection": "Keep-Alive"}
def __del__(self):
"""Clear channel"""
self._conn = None
def __init__(self, **conn_kwargs):
self._conn = None
self.base_url = "blobstore://."
self._connection_properties = conn_kwargs
self.session_key = self._connection_properties.pop("sessionid", None)
self._init_connection(**self._connection_properties)
def _init_connection(self, **kwargs):
"""Initiate blobstore connection"""
# mixed security modes require a password at all times
username = kwargs.pop("username", "nousername")
if isinstance(username, bytes):
username = username.decode("utf-8")
password = kwargs.pop("password", "nopassword")
if isinstance(password, bytes):
password = password.decode("utf-8")
log_dir = kwargs.pop("log_dir", "")
try:
correctcreds = BlobStore2.initializecreds(username=username, password=password, log_dir=log_dir)
bs2 = BlobStore2()
if not correctcreds:
security_state = int(bs2.get_security_state())
if security_state == 3:
raise InvalidCredentialsError(0)
else:
raise SecurityStateError(security_state)
except Blob2SecurityError:
raise InvalidCredentialsError(0)
except HpIloChifPacketExchangeError as excp:
LOGGER.info("Exception: %s", str(excp))
raise ChifDriverMissingOrNotFound()
except Exception as excp:
if str(excp) == "chif":
raise ChifDriverMissingOrNotFound()
else:
raise
else:
self._conn = bs2
def rest_request(self, path="", method="GET", args=None, body=None, headers=None):
"""Rest request for blobstore client
:param path: The URI path to perform the operation on.
:type path: str
:param method: method to perform on the path.
:type method: str
:param args: Any query to add to the URI. (Can also be directly added to the URI)
:type args: dict
:param body: body payload to include in the request if needed.
:type body: dict
:param headers: Any extra headers to add to the request.
:type headers: dict
:returns: A :class:`redfish.rest.containers.RestResponse` object
"""
# default headers if not passed in - otherwise will throw on .update call
if headers is None:
headers = {}
else:
headers.update(Blobstore2Connection.blobstore_headers)
if isinstance(path, bytes):
path = path.decode("utf-8")
reqpath = path.replace("//", "/")
oribody = body
if body is not None:
if isinstance(body, (dict, list)):
headers["Content-Type"] = "application/json"
if isinstance(body, bytes):
body = body.decode("utf-8")
body = json.dumps(body)
elif isinstance(body, bytes):
headers["Content-Type"] = "application/octet-stream"
body = bytearray(body)
else:
headers["Content-Type"] = "application/x-www-form-urlencoded"
body = urlencode(body)
if method == "PUT":
resp = self.rest_request(path=path, headers=headers)
try:
if resp.getheader("content-encoding") == "gzip":
buf = BytesIO()
gfile = gzip.GzipFile(mode="wb", fileobj=buf)
try:
gfile.write(str(body).encode("utf-8") if six.PY3 else str(body))
finally:
gfile.close()
compresseddata = buf.getvalue()
if compresseddata:
data = bytearray()
data.extend(memoryview(compresseddata))
body = data
except BaseException as excp:
LOGGER.error("Error occur while compressing body: %s", excp)
raise
headers["Content-Length"] = len(body)
if args:
if method == "GET":
reqpath += "?" + urlencode(args)
elif method == "PUT" or method == "POST" or method == "PATCH":
headers["Content-Type"] = "application/x-www-form-urlencoded"
body = urlencode(args)
str1 = "{} {} {}\r\n".format(method, reqpath, Blobstore2Connection._http_vsn_str)
str1 += "Host: \r\n"
str1 += "Accept-Encoding: gzip\r\n"
for header, value in headers.items():
str1 += "{}: {}\r\n".format(header, value)
str1 += "\r\n"
if body and len(body) > 0:
if isinstance(body, bytearray):
str1 = bytearray(str1.encode("utf-8")) + body
else:
# if isinstance(body, bytes):
# body = body.decode("utf-8")
str1 += body
if not isinstance(str1, bytearray):
str1 = bytearray(str1.encode("utf-8"))
if LOGGER.isEnabledFor(logging.DEBUG):
try:
logbody = None
if body:
if body[0] == "{":
logbody = body
else:
raise
if method in ["POST", "PATCH"]:
debugjson = json.loads(body)
debugjson_masked = SecurityMasker.mask_simple_body(debugjson)
logbody = json.dumps(debugjson_masked)
headers_masked = SecurityMasker.mask_http_headers(headers)
LOGGER.debug(
"Blobstore REQUEST: %s\n\tPATH: %s\n\tHEADERS: " "%s\n\tBODY: %s",
method,
str(headers_masked),
path,
logbody,
)
except:
LOGGER.debug(
"Blobstore REQUEST: %s\n\tPATH: %s\n\tHEADERS: " "%s\n\tBODY: %s",
method,
str(headers),
path,
"binary body",
)
inittime = time.time()
for idx in range(5):
try:
resp_txt = self._conn.rest_immediate(str1)
break
except Blob2OverrideError:
if idx == 4:
raise Blob2OverrideError(2)
else:
continue
endtime = time.time()
LOGGER.info("iLO Response Time to %s: %s secs.", path, str(endtime - inittime))
if resp_txt is not None:
# Dummy response to support a bad host response
if len(resp_txt) == 0:
resp_txt = (
"HTTP/1.1 500 Not Found\r\nAllow: "
"GET\r\nCache-Control: no-cache\r\nContent-length: "
"0\r\nContent-type: text/html\r\nDate: Tues, 1 Apr 2025 "
"00:00:01 GMT\r\nServer: "
"HP-iLO-Server/1.30\r\nX_HP-CHRP-Service-Version: 1.0.3\r\n\r\n\r\n"
)
restreq = RestRequest(path, method, data=body, url=self.base_url)
rest_response = RisRestResponse(restreq, resp_txt)
if rest_response.status in range(300, 399) and rest_response.status != 304:
newloc = rest_response.getheader("location")
newurl = urlparse(newloc)
rest_response = self.rest_request(newurl.path, method, args, oribody, headers)
try:
if rest_response.getheader("content-encoding") == "gzip":
if hasattr(gzip, "decompress"):
rest_response.read = gzip.decompress(rest_response.ori)
else:
compressedfile = BytesIO(rest_response.ori)
decompressedfile = gzip.GzipFile(fileobj=compressedfile)
rest_response.read = decompressedfile.read()
except Exception:
pass
if LOGGER.isEnabledFor(logging.DEBUG):
headerstr = ""
headerget = rest_response.getheaders()
for header in headerget:
headerstr += "\t" + header + ": " + headerget[header] + "\n"
try:
response_body = SecurityMasker.mask_complex_body(rest_response.read)
LOGGER.debug(
"Blobstore RESPONSE for %s:\nCode: %s\nHeaders:" "\n%s\nBody of %s: %s",
rest_response.request.path,
str(rest_response._http_response.status) + " " + rest_response._http_response.reason,
headerstr,
rest_response.request.path,
response_body,
)
except:
LOGGER.debug(
"Blobstore RESPONSE for %s:\nCode:%s",
rest_response.request.path,
rest_response,
)
return rest_response
def cert_login(self):
"""Login using a certificate."""
# local cert login is only available on iLO 5
token = self.cert_login()
resp = self.rest_request("/redfish/v1/SessionService/Sessions/", "GET")
if resp.status == 200:
try:
location = resp.obj.Oem.Hpe.Links.MySession["@odata.id"]
except KeyError:
raise InvalidCertificateError("")
else:
raise InvalidCertificateError("")
return token, location