1# This code is part of Ansible, but is an independent component. 2# This particular file snippet, and this file snippet only, is BSD licensed. 3# Modules you write using this snippet, which is embedded dynamically by Ansible 4# still belong to the author of the module, and may assign their own license 5# to the complete work. 6# 7# (c) 2016 Red Hat Inc. 8# (c) 2020 Cisco Systems Inc. 9# 10# Redistribution and use in source and binary forms, with or without modification, 11# are permitted provided that the following conditions are met: 12# 13# * Redistributions of source code must retain the above copyright 14# notice, this list of conditions and the following disclaimer. 15# * Redistributions in binary form must reproduce the above copyright notice, 16# this list of conditions and the following disclaimer in the documentation 17# and/or other materials provided with the distribution. 18# 19# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 20# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 21# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 22# IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 23# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 24# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 25# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 26# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE 27# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28# 29# Intersight REST API Module 30# Author: Matthew Garrett 31# Contributors: David Soper, Chris Gascoigne, John McDonough 32 33from base64 import b64encode 34from email.utils import formatdate 35import re 36import json 37import hashlib 38from ansible.module_utils.six import iteritems 39from ansible.module_utils.six.moves.urllib.parse import urlparse, urlencode 40from ansible.module_utils.urls import fetch_url 41from ansible.module_utils.basic import env_fallback 42 43try: 44 from cryptography.hazmat.primitives import serialization, hashes 45 from cryptography.hazmat.primitives.asymmetric import padding, ec 46 from cryptography.hazmat.backends import default_backend 47 HAS_CRYPTOGRAPHY = True 48except ImportError: 49 HAS_CRYPTOGRAPHY = False 50 51intersight_argument_spec = dict( 52 api_private_key=dict(fallback=(env_fallback, ['INTERSIGHT_API_PRIVATE_KEY']), type='path', required=True, no_log=True), 53 api_uri=dict(fallback=(env_fallback, ['INTERSIGHT_API_URI']), type='str', default='https://intersight.com/api/v1'), 54 api_key_id=dict(fallback=(env_fallback, ['INTERSIGHT_API_KEY_ID']), type='str', required=True), 55 validate_certs=dict(type='bool', default=True), 56 use_proxy=dict(type='bool', default=True), 57) 58 59 60def get_sha256_digest(data): 61 """ 62 Generates a SHA256 digest from a String. 63 64 :param data: data string set by user 65 :return: instance of digest object 66 """ 67 68 digest = hashlib.sha256() 69 digest.update(data.encode()) 70 71 return digest 72 73 74def prepare_str_to_sign(req_tgt, hdrs): 75 """ 76 Concatenates Intersight headers in preparation to be signed 77 78 :param req_tgt : http method plus endpoint 79 :param hdrs: dict with header keys 80 :return: concatenated header authorization string 81 """ 82 ss = "" 83 ss = ss + "(request-target): " + req_tgt + "\n" 84 85 length = len(hdrs.items()) 86 87 i = 0 88 for key, value in hdrs.items(): 89 ss = ss + key.lower() + ": " + value 90 if i < length - 1: 91 ss = ss + "\n" 92 i += 1 93 94 return ss 95 96 97def get_gmt_date(): 98 """ 99 Generated a GMT formatted Date 100 101 :return: current date 102 """ 103 104 return formatdate(timeval=None, localtime=False, usegmt=True) 105 106 107def compare_lists(expected_list, actual_list): 108 if len(expected_list) != len(actual_list): 109 # mismatch if list lengths aren't equal 110 return False 111 for expected, actual in zip(expected_list, actual_list): 112 # if compare_values returns False, stop the loop and return 113 if not compare_values(expected, actual): 114 return False 115 # loop complete with all items matching 116 return True 117 118 119def compare_values(expected, actual): 120 try: 121 if isinstance(expected, list) and isinstance(actual, list): 122 return compare_lists(expected, actual) 123 for (key, value) in iteritems(expected): 124 if re.search(r'P(ass)?w(or)?d', key) or key not in actual: 125 # do not compare any password related attributes or attributes that are not in the actual resource 126 continue 127 if not compare_values(value, actual[key]): 128 return False 129 # loop complete with all items matching 130 return True 131 except (AttributeError, TypeError): 132 # if expected and actual != expected: 133 if actual != expected: 134 return False 135 return True 136 137 138class IntersightModule(): 139 140 def __init__(self, module): 141 self.module = module 142 self.result = dict(changed=False) 143 if not HAS_CRYPTOGRAPHY: 144 self.module.fail_json(msg='cryptography is required for this module') 145 self.host = self.module.params['api_uri'] 146 self.public_key = self.module.params['api_key_id'] 147 try: 148 with open(self.module.params['api_private_key'], 'r') as f: 149 self.private_key = f.read() 150 except FileNotFoundError: 151 self.private_key = self.module.params['api_private_key'] 152 self.digest_algorithm = '' 153 self.response_list = [] 154 155 def get_sig_b64encode(self, data): 156 """ 157 Generates a signed digest from a String 158 159 :param digest: string to be signed & hashed 160 :return: instance of digest object 161 """ 162 # Python SDK code: Verify PEM Pre-Encapsulation Boundary 163 r = re.compile(r"\s*-----BEGIN (.*)-----\s+") 164 m = r.match(self.private_key) 165 if not m: 166 raise ValueError("Not a valid PEM pre boundary") 167 pem_header = m.group(1) 168 key = serialization.load_pem_private_key(self.private_key.encode(), None, default_backend()) 169 if pem_header == 'RSA PRIVATE KEY': 170 sign = key.sign(data.encode(), padding.PKCS1v15(), hashes.SHA256()) 171 self.digest_algorithm = 'rsa-sha256' 172 elif pem_header == 'EC PRIVATE KEY': 173 sign = key.sign(data.encode(), ec.ECDSA(hashes.SHA256())) 174 self.digest_algorithm = 'hs2019' 175 else: 176 raise Exception("Unsupported key: {0}".format(pem_header)) 177 178 return b64encode(sign) 179 180 def get_auth_header(self, hdrs, signed_msg): 181 """ 182 Assmebled an Intersight formatted authorization header 183 184 :param hdrs : object with header keys 185 :param signed_msg: base64 encoded sha256 hashed body 186 :return: concatenated authorization header 187 """ 188 189 auth_str = "Signature" 190 191 auth_str = auth_str + " " + "keyId=\"" + self.public_key + "\"," + "algorithm=\"" + self.digest_algorithm + "\"," 192 193 auth_str = auth_str + "headers=\"(request-target)" 194 195 for key, dummy in hdrs.items(): 196 auth_str = auth_str + " " + key.lower() 197 auth_str = auth_str + "\"" 198 199 auth_str = auth_str + "," + "signature=\"" + signed_msg.decode('ascii') + "\"" 200 201 return auth_str 202 203 def get_moid_by_name(self, resource_path, target_name): 204 """ 205 Retrieve an Intersight object moid by name 206 207 :param resource_path: intersight resource path e.g. '/ntp/Policies' 208 :param target_name: intersight object name 209 :return: json http response object 210 """ 211 query_params = { 212 "$filter": "Name eq '{0}'".format(target_name) 213 } 214 215 options = { 216 "http_method": "GET", 217 "resource_path": resource_path, 218 "query_params": query_params 219 } 220 221 get_moid = self.intersight_call(**options) 222 223 if get_moid.json()['Results'] is not None: 224 located_moid = get_moid.json()['Results'][0]['Moid'] 225 else: 226 raise KeyError('Intersight object with name "{0}" not found!'.format(target_name)) 227 228 return located_moid 229 230 def call_api(self, **options): 231 """ 232 Call the Intersight API and check for success status 233 :param options: options dict with method and other params for API call 234 :return: json http response object 235 """ 236 237 try: 238 response, info = self.intersight_call(**options) 239 if not re.match(r'2..', str(info['status'])): 240 raise RuntimeError(info['status'], info['msg'], info['body']) 241 except Exception as e: 242 self.module.fail_json(msg="API error: %s " % str(e)) 243 244 response_data = response.read() 245 if len(response_data) > 0: 246 resp_json = json.loads(response_data) 247 resp_json['trace_id'] = info.get('x-starship-traceid') 248 return resp_json 249 return {} 250 251 def intersight_call(self, http_method="", resource_path="", query_params=None, body=None, moid=None, name=None): 252 """ 253 Invoke the Intersight API 254 255 :param resource_path: intersight resource path e.g. '/ntp/Policies' 256 :param query_params: dictionary object with query string parameters as key/value pairs 257 :param body: dictionary object with intersight data 258 :param moid: intersight object moid 259 :param name: intersight object name 260 :return: json http response object 261 """ 262 263 target_host = urlparse(self.host).netloc 264 target_path = urlparse(self.host).path 265 query_path = "" 266 method = http_method.upper() 267 bodyString = "" 268 269 # Verify an accepted HTTP verb was chosen 270 if(method not in ['GET', 'POST', 'PATCH', 'DELETE']): 271 raise ValueError('Please select a valid HTTP verb (GET/POST/PATCH/DELETE)') 272 273 # Verify the resource path isn't empy & is a valid <str> object 274 if(resource_path != "" and not (resource_path, str)): 275 raise TypeError('The *resource_path* value is required and must be of type "<str>"') 276 277 # Verify the query parameters isn't empy & is a valid <dict> object 278 if(query_params is not None and not isinstance(query_params, dict)): 279 raise TypeError('The *query_params* value must be of type "<dict>"') 280 281 # Verify the MOID is not null & of proper length 282 if(moid is not None and len(moid.encode('utf-8')) != 24): 283 raise ValueError('Invalid *moid* value!') 284 285 # Check for query_params, encode, and concatenate onto URL 286 if query_params: 287 query_path = "?" + urlencode(query_params) 288 289 # Handle PATCH/DELETE by Object "name" instead of "moid" 290 if method in ('PATCH', 'DELETE'): 291 if moid is None: 292 if name is not None: 293 if isinstance(name, str): 294 moid = self.get_moid_by_name(resource_path, name) 295 else: 296 raise TypeError('The *name* value must be of type "<str>"') 297 else: 298 raise ValueError('Must set either *moid* or *name* with "PATCH/DELETE!"') 299 300 # Check for moid and concatenate onto URL 301 if moid is not None: 302 resource_path += "/" + moid 303 304 # Check for GET request to properly form body 305 if method != "GET": 306 bodyString = json.dumps(body) 307 308 # Concatenate URLs for headers 309 target_url = self.host + resource_path + query_path 310 request_target = method.lower() + " " + target_path + resource_path + query_path 311 312 # Get the current GMT Date/Time 313 cdate = get_gmt_date() 314 315 # Generate the body digest 316 body_digest = get_sha256_digest(bodyString) 317 b64_body_digest = b64encode(body_digest.digest()) 318 319 # Generate the authorization header 320 auth_header = { 321 'Host': target_host, 322 'Date': cdate, 323 'Digest': "SHA-256=" + b64_body_digest.decode('ascii'), 324 } 325 326 string_to_sign = prepare_str_to_sign(request_target, auth_header) 327 b64_signed_msg = self.get_sig_b64encode(string_to_sign) 328 auth_header = self.get_auth_header(auth_header, b64_signed_msg) 329 330 # Generate the HTTP requests header 331 request_header = { 332 'Accept': 'application/json', 333 'Content-Type': 'application/json', 334 'Host': '{0}'.format(target_host), 335 'Date': '{0}'.format(cdate), 336 'Digest': 'SHA-256={0}'.format(b64_body_digest.decode('ascii')), 337 'Authorization': '{0}'.format(auth_header), 338 } 339 340 response, info = fetch_url(self.module, target_url, data=bodyString, headers=request_header, method=method, use_proxy=self.module.params['use_proxy']) 341 342 return response, info 343 344 def get_resource(self, resource_path, query_params, return_list=False): 345 ''' 346 GET a resource and return the 1st element found or the full Results list 347 ''' 348 options = { 349 'http_method': 'get', 350 'resource_path': resource_path, 351 'query_params': query_params, 352 } 353 response = self.call_api(**options) 354 if response.get('Results'): 355 if return_list: 356 self.result['api_response'] = response['Results'] 357 else: 358 # return the 1st list element 359 self.result['api_response'] = response['Results'][0] 360 self.result['trace_id'] = response.get('trace_id') 361 362 def configure_resource(self, moid, resource_path, body, query_params, update_method=''): 363 if not self.module.check_mode: 364 if moid and update_method != 'post': 365 # update the resource - user has to specify all the props they want updated 366 options = { 367 'http_method': 'patch', 368 'resource_path': resource_path, 369 'body': body, 370 'moid': moid, 371 } 372 response_dict = self.call_api(**options) 373 if response_dict.get('Results'): 374 # return the 1st element in the results list 375 self.result['api_response'] = response_dict['Results'][0] 376 self.result['trace_id'] = response_dict.get('trace_id') 377 else: 378 # create the resource 379 options = { 380 'http_method': 'post', 381 'resource_path': resource_path, 382 'body': body, 383 } 384 response_dict = self.call_api(**options) 385 if response_dict: 386 self.result['api_response'] = response_dict 387 self.result['trace_id'] = response_dict.get('trace_id') 388 elif query_params: 389 # POSTs may not return any data. 390 # Get the current state of the resource if query_params. 391 self.get_resource( 392 resource_path=resource_path, 393 query_params=query_params, 394 ) 395 self.result['changed'] = True 396 397 def delete_resource(self, moid, resource_path): 398 # delete resource and create empty api_response 399 if not self.module.check_mode: 400 options = { 401 'http_method': 'delete', 402 'resource_path': resource_path, 403 'moid': moid, 404 } 405 resp = self.call_api(**options) 406 self.result['api_response'] = {} 407 self.result['trace_id'] = resp.get('trace_id') 408 self.result['changed'] = True 409 410 def configure_policy_or_profile(self, resource_path): 411 # Configure (create, update, or delete) the policy or profile 412 organization_moid = None 413 # GET Organization Moid 414 self.get_resource( 415 resource_path='/organization/Organizations', 416 query_params={ 417 '$filter': "Name eq '" + self.module.params['organization'] + "'", 418 '$select': 'Moid', 419 }, 420 ) 421 if self.result['api_response'].get('Moid'): 422 # resource exists and moid was returned 423 organization_moid = self.result['api_response']['Moid'] 424 425 self.result['api_response'] = {} 426 # Get the current state of the resource 427 filter_str = "Name eq '" + self.module.params['name'] + "'" 428 filter_str += "and Organization.Moid eq '" + organization_moid + "'" 429 self.get_resource( 430 resource_path=resource_path, 431 query_params={ 432 '$filter': filter_str, 433 '$expand': 'Organization', 434 } 435 ) 436 437 moid = None 438 resource_values_match = False 439 if self.result['api_response'].get('Moid'): 440 # resource exists and moid was returned 441 moid = self.result['api_response']['Moid'] 442 if self.module.params['state'] == 'present': 443 resource_values_match = compare_values(self.api_body, self.result['api_response']) 444 else: # state == 'absent' 445 self.delete_resource( 446 moid=moid, 447 resource_path=resource_path, 448 ) 449 moid = None 450 451 if self.module.params['state'] == 'present' and not resource_values_match: 452 # remove read-only Organization key 453 self.api_body.pop('Organization') 454 if not moid: 455 # Organization must be set, but can't be changed after initial POST 456 self.api_body['Organization'] = { 457 'Moid': organization_moid, 458 } 459 self.configure_resource( 460 moid=moid, 461 resource_path=resource_path, 462 body=self.api_body, 463 query_params={ 464 '$filter': filter_str 465 } 466 ) 467 if self.result['api_response'].get('Moid'): 468 # resource exists and moid was returned 469 moid = self.result['api_response']['Moid'] 470 471 return moid 472