1#!/usr/local/bin/python3.8 2 3# (c) 2020, NetApp, Inc 4# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 5from __future__ import absolute_import, division, print_function 6__metaclass__ = type 7 8 9DOCUMENTATION = """ 10module: na_santricity_server_certificate 11short_description: NetApp E-Series manage the storage system's server SSL certificates. 12description: Manage NetApp E-Series storage system's server SSL certificates. 13author: Nathan Swartz (@ndswartz) 14extends_documentation_fragment: 15 - netapp_eseries.santricity.santricity.santricity_doc 16options: 17 controller: 18 description: 19 - The controller that owns the port you want to configure. 20 - Controller names are represented alphabetically, with the first controller as A, the second as B, and so on. 21 - Current hardware models have either 1 or 2 available controllers, but that is not a guaranteed hard limitation and could change in the future. 22 - I(controller) must be specified unless managing SANtricity Web Services Proxy (ie I(ssid="proxy")) 23 choices: 24 - A 25 - B 26 type: str 27 required: false 28 certificates: 29 description: 30 - Unordered list of all server certificate files which include PEM and DER encoded certificates as well as private keys. 31 - When I(certificates) is not defined then a self-signed certificate will be expected. 32 type: list 33 required: false 34 passphrase: 35 description: 36 - Passphrase for PEM encoded private key encryption. 37 - If I(passphrase) is not supplied then Ansible will prompt for private key certificate. 38 type: str 39 required: false 40notes: 41 - Set I(ssid=='0') or I(ssid=='proxy') to specifically reference SANtricity Web Services Proxy. 42 - Certificates can be the following filetypes - PEM (.pem, .crt, .cer, or .key) or DER (.der or .cer) 43 - When I(certificates) is not defined then a self-signed certificate will be expected. 44requirements: 45 - cryptography 46""" 47EXAMPLES = """ 48- name: Ensure signed certificate is installed. 49 na_santricity_server_certificate: 50 ssid: 1 51 api_url: https://192.168.1.100:8443/devmgr/v2 52 api_username: admin 53 api_password: adminpass 54 controller: A 55 certificates: 56 - 'root_auth_cert.pem' 57 - 'intermediate_auth1_cert.pem' 58 - 'intermediate_auth2_cert.pem' 59 - 'public_cert.pem' 60 - 'private_key.pem' 61 passphrase: keypass 62- name: Ensure signed certificate bundle is installed. 63 na_santricity_server_certificate: 64 ssid: 1 65 api_url: https://192.168.1.100:8443/devmgr/v2 66 api_username: admin 67 api_password: adminpass 68 controller: B 69 certificates: 70 - 'cert_bundle.pem' 71 passphrase: keypass 72- name: Ensure storage system generated self-signed certificate is installed. 73 na_santricity_server_certificate: 74 ssid: 1 75 api_url: https://192.168.1.100:8443/devmgr/v2 76 api_username: admin 77 api_password: adminpass 78 controller: A 79""" 80RETURN = """ 81changed: 82 description: Whether changes have been made. 83 type: bool 84 returned: always 85 sample: true 86signed_server_certificate: 87 description: Whether the public server certificate is signed. 88 type: bool 89 returned: always 90 sample: true 91added_certificates: 92 description: Any SSL certificates that were added. 93 type: list 94 returned: always 95 sample: ['added_certificiate.crt'] 96removed_certificates: 97 description: Any SSL certificates that were removed. 98 type: list 99 returned: always 100 sample: ['removed_certificiate.crt'] 101""" 102 103import binascii 104import random 105import re 106 107from ansible.module_utils import six 108from ansible_collections.netapp_eseries.santricity.plugins.module_utils.santricity import NetAppESeriesModule 109from ansible.module_utils._text import to_native 110from time import sleep 111 112try: 113 import cryptography 114 from cryptography import x509 115 from cryptography.hazmat.primitives import serialization 116 from cryptography.hazmat.backends import default_backend 117except ImportError: 118 HAS_CRYPTOGRAPHY = False 119else: 120 HAS_CRYPTOGRAPHY = True 121 122 123def create_multipart_formdata(file_details): 124 """Create the data for a multipart/form request for a certificate.""" 125 boundary = "---------------------------" + "".join([str(random.randint(0, 9)) for x in range(30)]) 126 data_parts = list() 127 data = None 128 129 if six.PY2: # Generate payload for Python 2 130 newline = "\r\n" 131 for name, filename, content in file_details: 132 data_parts.extend(["--%s" % boundary, 133 'Content-Disposition: form-data; name="%s"; filename="%s"' % (name, filename), 134 "Content-Type: application/octet-stream", 135 "", 136 content]) 137 data_parts.extend(["--%s--" % boundary, ""]) 138 data = newline.join(data_parts) 139 140 else: 141 newline = six.b("\r\n") 142 for name, filename, content in file_details: 143 data_parts.extend([six.b("--%s" % boundary), 144 six.b('Content-Disposition: form-data; name="%s"; filename="%s"' % (name, filename)), 145 six.b("Content-Type: application/octet-stream"), 146 six.b(""), 147 content]) 148 data_parts.extend([six.b("--%s--" % boundary), b""]) 149 data = newline.join(data_parts) 150 151 headers = { 152 "Content-Type": "multipart/form-data; boundary=%s" % boundary, 153 "Content-Length": str(len(data))} 154 155 return headers, data 156 157 158class NetAppESeriesServerCertificate(NetAppESeriesModule): 159 RESET_SSL_CONFIG_TIMEOUT_SEC = 3 * 60 160 161 def __init__(self): 162 ansible_options = dict(controller=dict(type="str", required=False, choices=["A", "B"]), 163 certificates=dict(type="list", required=False), 164 passphrase=dict(type="str", required=False, no_log=True)) 165 166 super(NetAppESeriesServerCertificate, self).__init__(ansible_options=ansible_options, 167 web_services_version="05.00.0000.0000", 168 supports_check_mode=True) 169 args = self.module.params 170 self.controller = args["controller"] 171 self.certificates = args["certificates"] if "certificates" in args.keys() else list() 172 self.passphrase = args["passphrase"] if "passphrase" in args.keys() else None 173 174 # Check whether request needs to be forwarded on to the controller web services rest api. 175 self.url_path_prefix = "" 176 self.url_path_suffix = "" 177 if self.is_proxy(): 178 if self.ssid.lower() in ["0", "proxy"]: 179 self.url_path_suffix = "?controller=auto" 180 elif self.controller is not None: 181 self.url_path_prefix = "storage-systems/%s/forward/devmgr/v2/" % self.ssid 182 self.url_path_suffix = "?controller=%s" % self.controller.lower() 183 else: 184 self.module.fail_json(msg="Invalid options! You must specify which controller's certificates to modify. Array [%s]." % self.ssid) 185 elif self.controller is None: 186 self.module.fail_json(msg="Invalid options! You must specify which controller's certificates to modify. Array [%s]." % self.ssid) 187 188 self.cache_get_current_certificates = None 189 self.cache_is_controller_alternate = None 190 self.cache_is_public_server_certificate_signed = None 191 192 def get_controllers(self): 193 """Retrieve a mapping of controller labels to their controller slot.""" 194 controllers_dict = {} 195 controllers = [] 196 try: 197 rc, controllers = self.request("storage-systems/%s/controllers" % self.ssid) 198 except Exception as error: 199 self.module.fail_json(msg="Failed to retrieve the controller settings. Array Id [%s]. Error [%s]." % (self.ssid, to_native(error))) 200 201 for controller in controllers: 202 slot = controller['physicalLocation']['slot'] 203 letter = chr(slot + 64) 204 controllers_dict.update({letter: slot}) 205 206 return controllers_dict 207 208 def check_controller(self): 209 """Is the effected controller the alternate controller.""" 210 controllers_info = self.get_controllers() 211 try: 212 rc, about = self.request("utils/about", rest_api_path=self.DEFAULT_BASE_PATH) 213 self.url_path_suffix = "?alternate=%s" % ("true" if controllers_info[self.controller] != about["controllerPosition"] else "false") 214 except Exception as error: 215 self.module.fail_json(msg="Failed to retrieve accessing controller slot information. Array [%s]." % self.ssid) 216 217 @staticmethod 218 def sanitize_distinguished_name(dn): 219 """Generate a sorted distinguished name string to account for different formats/orders.""" 220 dn = re.sub(" *= *", "=", dn).lower() 221 dn = re.sub(", *(?=[a-zA-Z]+={1})", "---SPLIT_MARK---", dn) 222 dn_parts = dn.split("---SPLIT_MARK---") 223 dn_parts.sort() 224 return ",".join(dn_parts) 225 226 def certificate_info_from_file(self, path): 227 """Determine the certificate info from the provided filepath.""" 228 certificates_info = {} 229 try: 230 # Treat file as PEM encoded file. 231 with open(path, "r") as fh: 232 line = fh.readline() 233 while line != "": 234 235 # Add public certificates to bundle_info. 236 if re.search("^-+BEGIN CERTIFICATE-+$", line): 237 certificate = line 238 line = fh.readline() 239 while not re.search("^-+END CERTIFICATE-+$", line): 240 if line == "": 241 self.module.fail_json(msg="Invalid certificate! Path [%s]. Array [%s]." % (path, self.ssid)) 242 certificate += line 243 line = fh.readline() 244 certificate += line 245 if not six.PY2: 246 certificate = six.b(certificate) 247 info = x509.load_pem_x509_certificate(certificate, default_backend()) 248 certificates_info.update(self.certificate_info(info, certificate, path)) 249 250 # Add private key to self.private_key. 251 elif re.search("^-+BEGIN.*PRIVATE KEY-+$", line): 252 pkcs8 = "BEGIN PRIVATE KEY" in line 253 pkcs8_encrypted = "BEGIN ENCRYPTED PRIVATE KEY" in line 254 key = line 255 line = fh.readline() 256 while not re.search("^-+END.*PRIVATE KEY-+$", line): 257 if line == "": 258 self.module.fail_json(msg="Invalid certificate! Array [%s]." % self.ssid) 259 key += line 260 line = fh.readline() 261 key += line 262 if not six.PY2: 263 key = six.b(key) 264 if self.passphrase: 265 self.passphrase = six.b(self.passphrase) 266 267 # Check for PKCS8 PEM encoding. 268 if pkcs8 or pkcs8_encrypted: 269 try: 270 if pkcs8: 271 crypto_key = serialization.load_pem_private_key(key, password=None, backend=default_backend()) 272 else: 273 crypto_key = serialization.load_pem_private_key(key, password=self.passphrase, backend=default_backend()) 274 except ValueError as error: 275 self.module.fail_json(msg="Failed to load%sPKCS8 encoded private key. %s" 276 " Error [%s]." % (" encrypted " if pkcs8_encrypted else " ", 277 "Check passphrase." if pkcs8_encrypted else "", error)) 278 279 key = crypto_key.private_bytes(encoding=serialization.Encoding.PEM, 280 format=serialization.PrivateFormat.TraditionalOpenSSL, 281 encryption_algorithm=serialization.NoEncryption()) 282 283 # Check whether multiple private keys have been provided and fail if different 284 if "private_key" in certificates_info.keys() and certificates_info["private_key"] != key: 285 self.module.fail_json(msg="Multiple private keys have been provided! Array [%s]" % self.ssid) 286 else: 287 certificates_info.update({"private_key": key}) 288 289 line = fh.readline() 290 291 # Throw exception when no PEM certificates have been discovered. 292 if len(certificates_info) == 0: 293 raise Exception("Failed to discover a valid PEM encoded certificate or private key!") 294 295 except Exception as error: 296 # Treat file as DER encoded certificate 297 try: 298 with open(path, "rb") as fh: 299 cert_info = x509.load_der_x509_certificate(fh.read(), default_backend()) 300 cert_data = cert_info.public_bytes(serialization.Encoding.PEM) 301 certificates_info.update(self.certificate_info(cert_info, cert_data, path)) 302 303 # Throw exception when no DER encoded certificates have been discovered. 304 if len(certificates_info) == 0: 305 raise Exception("Failed to discover a valid DER encoded certificate!") 306 except Exception as error: 307 308 # Treat file as DER encoded private key 309 try: 310 with open(path, "rb") as fh: 311 crypto_key = serialization.load_der_public_key(fh.read(), backend=default_backend()) 312 key = crypto_key.private_bytes(encoding=serialization.Encoding.PEM, 313 format=serialization.PrivateFormat.TraditionalOpenSSL, 314 encryption_algorithm=serialization.NoEncryption()) 315 certificates_info.update({"private_key": key}) 316 except Exception as error: 317 self.module.fail_json(msg="Invalid file type! File is neither PEM or DER encoded certificate/private key." 318 " Path [%s]. Array [%s]. Error [%s]." % (path, self.ssid, to_native(error))) 319 320 return certificates_info 321 322 def certificate_info(self, info, data, path): 323 """Load x509 certificate that is either encoded DER or PEM encoding and return the certificate fingerprint.""" 324 fingerprint = binascii.hexlify(info.fingerprint(info.signature_hash_algorithm)).decode("utf-8") 325 return {self.sanitize_distinguished_name(info.subject.rfc4514_string()): {"alias": fingerprint, "fingerprint": fingerprint, 326 "certificate": data, "path": path, 327 "issuer": self.sanitize_distinguished_name(info.issuer.rfc4514_string())}} 328 329 def get_current_certificates(self): 330 """Determine the server certificates that exist on the storage system.""" 331 if self.cache_get_current_certificates is None: 332 current_certificates = [] 333 try: 334 rc, current_certificates = self.request(self.url_path_prefix + "certificates/server%s" % self.url_path_suffix) 335 except Exception as error: 336 self.module.fail_json(msg="Failed to retrieve server certificates. Array [%s]." % self.ssid) 337 338 self.cache_get_current_certificates = {} 339 for certificate in current_certificates: 340 certificate.update({"issuer": self.sanitize_distinguished_name(certificate["issuerDN"])}) 341 self.cache_get_current_certificates.update({self.sanitize_distinguished_name(certificate["subjectDN"]): certificate}) 342 343 return self.cache_get_current_certificates 344 345 def is_public_server_certificate_signed(self): 346 """Return whether the public server certificate is signed.""" 347 if self.cache_is_public_server_certificate_signed is None: 348 current_certificates = self.get_current_certificates() 349 350 for certificate in current_certificates: 351 if current_certificates[certificate]["alias"] == "jetty": 352 self.cache_is_public_server_certificate_signed = current_certificates[certificate]["type"] == "caSigned" 353 break 354 355 return self.cache_is_public_server_certificate_signed 356 357 def get_expected_certificates(self): 358 """Determine effected certificates and return certificate list in the required submission order.""" 359 certificates_info = {} 360 existing_certificates = self.get_current_certificates() 361 362 private_key = None 363 if self.certificates: 364 for path in self.certificates: 365 info = self.certificate_info_from_file(path) 366 if "private_key" in info.keys(): 367 if private_key is not None and info["private_key"] != private_key: 368 self.module.fail_json(msg="Multiple private keys have been provided! Array [%s]" % self.ssid) 369 else: 370 private_key = info.pop("private_key") 371 certificates_info.update(info) 372 373 # Determine bundle certificate ordering. 374 ordered_certificates_info = [dict] * len(certificates_info) 375 ordered_certificates_info_index = len(certificates_info) - 1 376 while certificates_info: 377 for certificate_subject in certificates_info.keys(): 378 379 # Determine all remaining issuers. 380 remaining_issuer_list = [info["issuer"] for subject, info in existing_certificates.items()] 381 for subject, info in certificates_info.items(): 382 remaining_issuer_list.append(info["issuer"]) 383 384 # Search for the next certificate that is not an issuer of the remaining certificates in certificates_info dictionary. 385 if certificate_subject not in remaining_issuer_list: 386 ordered_certificates_info[ordered_certificates_info_index] = certificates_info[certificate_subject] 387 certificates_info.pop(certificate_subject) 388 ordered_certificates_info_index -= 1 389 break 390 else: # Add remaining root certificate if one exists. 391 for certificate_subject in certificates_info.keys(): 392 ordered_certificates_info[ordered_certificates_info_index] = certificates_info[certificate_subject] 393 ordered_certificates_info_index -= 1 394 break 395 return {"private_key": private_key, "certificates": ordered_certificates_info} 396 397 def determine_changes(self): 398 """Determine certificates that need to be added or removed from storage system's server certificates database.""" 399 if not self.is_proxy(): 400 self.check_controller() 401 existing_certificates = self.get_current_certificates() 402 expected = self.get_expected_certificates() 403 certificates = expected["certificates"] 404 405 changes = {"change_required": False, 406 "signed_cert": True if certificates else False, 407 "private_key": expected["private_key"], 408 "public_cert": None, 409 "add_certs": [], 410 "remove_certs": []} 411 412 # Determine whether any expected certificates are missing from the storage system's database. 413 if certificates: 414 415 # Create a initial remove_cert list. 416 for existing_certificate_subject, existing_certificate in existing_certificates.items(): 417 changes["remove_certs"].append(existing_certificate["alias"]) 418 419 # Determine expected certificates 420 last_certificate_index = len(certificates) - 1 421 for certificate_index, certificate in enumerate(certificates): 422 for existing_certificate_subject, existing_certificate in existing_certificates.items(): 423 424 if certificate_index == last_certificate_index: 425 if existing_certificate["alias"] == "jetty": 426 if (certificate["fingerprint"] != existing_certificate["shaFingerprint"] and 427 certificate["fingerprint"] != existing_certificate["sha256Fingerprint"]): 428 changes["change_required"] = True 429 changes["public_cert"] = certificate 430 changes["remove_certs"].remove(existing_certificate["alias"]) 431 break 432 433 elif certificate["alias"] == existing_certificate["alias"]: 434 if (certificate["fingerprint"] != existing_certificate["shaFingerprint"] and 435 certificate["fingerprint"] != existing_certificate["sha256Fingerprint"]): 436 changes["add_certs"].append(certificate) 437 changes["change_required"] = True 438 changes["remove_certs"].remove(existing_certificate["alias"]) 439 break 440 441 else: 442 changes["add_certs"].append(certificate) 443 changes["change_required"] = True 444 445 # Determine whether new self-signed certificate needs to be generated. 446 elif self.is_public_server_certificate_signed(): 447 changes["change_required"] = True 448 449 return changes 450 451 def apply_self_signed_certificate(self): 452 """Install self-signed server certificate which is generated by the storage system itself.""" 453 try: 454 rc, resp = self.request(self.url_path_prefix + "certificates/reset%s" % self.url_path_suffix, method="POST") 455 except Exception as error: 456 self.module.fail_json(msg="Failed to reset SSL configuration back to a self-signed certificate! Array [%s]. Error [%s]." % (self.ssid, error)) 457 458 def apply_signed_certificate(self, public_cert, private_key): 459 """Install authoritative signed server certificate whether csr is generated by storage system or not.""" 460 if private_key is None: 461 headers, data = create_multipart_formdata([("file", "signed_server_certificate", public_cert["certificate"])]) 462 else: 463 headers, data = create_multipart_formdata([("file", "signed_server_certificate", public_cert["certificate"]), 464 ("privateKey", "private_key", private_key)]) 465 466 try: 467 rc, resp = self.request(self.url_path_prefix + "certificates/server%s&replaceMainServerCertificate=true" % self.url_path_suffix, 468 method="POST", headers=headers, data=data) 469 except Exception as error: 470 self.module.fail_json(msg="Failed to upload signed server certificate! Array [%s]. Error [%s]." % (self.ssid, error)) 471 472 def upload_authoritative_certificates(self, certificate): 473 """Install all authoritative certificates.""" 474 headers, data = create_multipart_formdata([["file", certificate["alias"], certificate["certificate"]]]) 475 476 try: 477 rc, resp = self.request(self.url_path_prefix + "certificates/server%s&alias=%s" % (self.url_path_suffix, certificate["alias"]), 478 method="POST", headers=headers, data=data) 479 except Exception as error: 480 self.module.fail_json(msg="Failed to upload certificate authority! Array [%s]. Error [%s]." % (self.ssid, error)) 481 482 def remove_authoritative_certificates(self, alias): 483 """Delete all authoritative certificates.""" 484 try: 485 rc, resp = self.request(self.url_path_prefix + "certificates/server/%s%s" % (alias, self.url_path_suffix), method="DELETE") 486 except Exception as error: 487 self.module.fail_json(msg="Failed to delete certificate authority! Array [%s]. Error [%s]." % (self.ssid, error)) 488 489 def reload_ssl_configuration(self): 490 """Asynchronously reloads the SSL configuration.""" 491 self.request(self.url_path_prefix + "certificates/reload%s" % self.url_path_suffix, method="POST", ignore_errors=True) 492 493 for retry in range(int(self.RESET_SSL_CONFIG_TIMEOUT_SEC / 3)): 494 try: 495 rc, current_certificates = self.request(self.url_path_prefix + "certificates/server%s" % self.url_path_suffix) 496 except Exception as error: 497 sleep(3) 498 continue 499 break 500 else: 501 self.module.fail_json(msg="Failed to retrieve server certificates. Array [%s]." % self.ssid) 502 503 def apply(self): 504 """Apply state changes to the storage array's truststore.""" 505 if not HAS_CRYPTOGRAPHY: 506 self.module.fail_json(msg="Python cryptography package are missing!") 507 508 major, minor, patch = [int(item) for item in str(cryptography.__version__).split(".")] 509 if major < 2 or (major == 2 and minor < 5): 510 self.module.fail_json(msg="Python cryptography package version must greater than version 2.5! Version [%s]." % cryptography.__version__) 511 512 changes = self.determine_changes() 513 if changes["change_required"] and not self.module.check_mode: 514 515 if changes["signed_cert"]: 516 for certificate in changes["add_certs"]: 517 self.upload_authoritative_certificates(certificate) 518 for certificate_alias in changes["remove_certs"]: 519 self.remove_authoritative_certificates(certificate_alias) 520 if changes["public_cert"]: 521 self.apply_signed_certificate(changes["public_cert"], changes["private_key"]) 522 self.reload_ssl_configuration() 523 else: 524 self.apply_self_signed_certificate() 525 self.reload_ssl_configuration() 526 527 self.module.exit_json(changed=changes["change_required"], 528 signed_server_certificate=changes["signed_cert"], 529 added_certificates=[cert["alias"] for cert in changes["add_certs"]], 530 removed_certificates=changes["remove_certs"]) 531 532 533def main(): 534 client_certs = NetAppESeriesServerCertificate() 535 client_certs.apply() 536 537 538if __name__ == "__main__": 539 main() 540