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