diff --git a/lib/ansible/modules/crypto/get_certificate.py b/lib/ansible/modules/crypto/get_certificate.py new file mode 100644 index 0000000000..c1246b6522 --- /dev/null +++ b/lib/ansible/modules/crypto/get_certificate.py @@ -0,0 +1,192 @@ +#!/usr/bin/python +# coding: utf-8 -*- + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = ''' +--- +module: get_certificate +author: "John Westcott IV (@john-westcott-iv)" +version_added: "2.8" +short_description: Get a certificate from a host:port +description: + - Makes a secure connection and returns information about the presented certificate +options: + host: + description: + - The host to get the cert for (IP is fine) + required: True + ca_certs: + description: + - A PEM file containing a list of root certificates; if present, the cert will be validated against these root certs. + - Note that this only validates the certificate is signed by the chain; not that the cert is valid for the host presenting it. + required: False + port: + description: + - The port to connect to + required: True + timeout: + description: + - The timeout in seconds + required: False + default: 10 + +notes: + - When using ca_certs on OS X it has been reported that in some conditions the validate will always succeed. + +requirements: + - "python >= 2.6" + - "python-pyOpenSSL >= 0.15" +''' + +RETURN = ''' +cert: + description: The certificate retrieved from the port + returned: success + type: string +expired: + description: Boolean indicating if the cert is expired + returned: success + type: bool +extensions: + description: Extensions applied to the cert + returned: success + type: list +issuer: + description: Information about the issuer of the cert + returned: success + type: dict +not_after: + description: Expiration date of the cert + returned: success + type: string +not_before: + description: Issue date of the cert + returned: success + type: string +serial_number: + description: The serial number of the cert + returned: success + type: string +signature_algorithm: + description: The algorithm used to sign the cert + returned: success + type: string +subject: + description: Information about the subject of the cert (OU, CN, etc) + returned: success + type: dict +version: + description: The version number of the certificate + returned: success + type: string +''' + +EXAMPLES = ''' +- name: Get the cert from an RDP port + get_certificate: + host: "1.2.3.4" + port: 3389 + delegate_to: localhost + run_once: true + register: cert + +- name: Get a cert from an https port + get_certificate: + host: "www.google.com" + port: 443 + delegate_to: localhost + run_once: true + register: cert +''' + +from ansible.module_utils.basic import AnsibleModule + +from os.path import isfile +from ssl import get_server_certificate +from socket import setdefaulttimeout + +try: + from OpenSSL import crypto +except ImportError: + pyopenssl_found = False +else: + pyopenssl_found = True + + +def main(): + module = AnsibleModule( + argument_spec=dict( + ca_certs=dict(required=False, type='path', default=None), + host=dict(required=True), + port=dict(required=True, type='int'), + timeout=dict(required=False, type='int', default=10), + ), + ) + + ca_certs = module.params.get('ca_certs') + host = module.params.get('host') + port = module.params.get('port') + timeout = module.params.get('timeout') + + result = dict( + changed=False, + ) + + if not pyopenssl_found: + module.fail_json(msg='the python pyOpenSSL module is required') + + if timeout: + setdefaulttimeout(timeout) + + if ca_certs: + if not isfile(ca_certs): + module.fail_json(msg="ca_certs file does not exist") + + try: + cert = get_server_certificate((host, port), ca_certs=ca_certs) + x509 = crypto.load_certificate(crypto.FILETYPE_PEM, cert) + except Exception as e: + module.fail_json(msg="Failed to get cert from port with error: {0}".format(e)) + + result['cert'] = cert + result['subject'] = {} + for component in x509.get_subject().get_components(): + result['subject'][component[0]] = component[1] + + result['expired'] = x509.has_expired() + + result['extensions'] = [] + extension_count = x509.get_extension_count() + for index in range(0, extension_count): + extension = x509.get_extension(index) + result['extensions'].append({ + 'critical': extension.get_critical(), + 'asn1_data': extension.get_data(), + 'name': extension.get_short_name(), + }) + + result['issuer'] = {} + for component in x509.get_issuer().get_components(): + result['issuer'][component[0]] = component[1] + + result['not_after'] = x509.get_notAfter() + result['not_before'] = x509.get_notBefore() + + result['serial_number'] = x509.get_serial_number() + result['signature_algorithm'] = x509.get_signature_algorithm() + + result['version'] = x509.get_version() + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/test/integration/targets/get_certificate/aliases b/test/integration/targets/get_certificate/aliases new file mode 100644 index 0000000000..db2a5672c1 --- /dev/null +++ b/test/integration/targets/get_certificate/aliases @@ -0,0 +1,3 @@ +shippable/posix/group1 +destructive +needs/httptester diff --git a/test/integration/targets/get_certificate/files/bogus_ca.pem b/test/integration/targets/get_certificate/files/bogus_ca.pem new file mode 100644 index 0000000000..16119c9edb --- /dev/null +++ b/test/integration/targets/get_certificate/files/bogus_ca.pem @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC+DCCAeACCQCWuDvGDH3otTANBgkqhkiG9w0BAQsFADA+MQswCQYDVQQGEwJV +UzEOMAwGA1UECAwFQm9ndXMxEDAOBgNVBAcMB0JhbG9uZXkxDTALBgNVBAoMBEFD +TUUwHhcNMTgwNzEyMTgxNDA0WhcNMjMwNzExMTgxNDA0WjA+MQswCQYDVQQGEwJV +UzEOMAwGA1UECAwFQm9ndXMxEDAOBgNVBAcMB0JhbG9uZXkxDTALBgNVBAoMBEFD +TUUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDLTGCpn8b+/2qdpkvK +iwXU8PMOXBOmRa+GmzxsxMr1QZcY0m6pY3uuIvqErMFf4qp4BMxQF+VpDLVJUJX/ +1oKCM7J3hEfgmKRD4RmKhBlnWVv5YGZmvlXRJBl1AsDTONZy8iKJB5NYnB3ZyrJq +H2GAgyJ55aYckoU55vwjRzKp49dZmzX5YS04Kzzzw/SmOuW8kMypZV5TJH+NXqKc +pw3u3cJ4yJ9DHSU5pnhC5BeKl8XDMO42jRWt5/7C7JDiCbZ9lu5jQiv/4DhsRsHF +A8/Lgl47sNDaBMbha786I9laPHLlVycpYaP6pwtizhN9ZRTdDOHmWi/vjiamERLL +FjjLAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAA+1uj3tHaCai+A1H/kOgTN5e0eW +/wmaxu8gNK5eiHrecNJNAlFxVTrCwhvv4nUW7NXVcW/1WUqSO0QMiPJhCsSLVAMF +8MuYH73B+ctRqAGdeOAWF+ftCywZTEj5h5F0XiWB+TmkPlTVNShMiPFelDJpLy7u +9MfiPEJjo4sZotQl8/pZ6R9cY6GpEXWnttcuhLJCEuiB8fWO7epiWYCt/Ak+CVmZ +OzfI/euV6Upaen22lNu8V3ZwWEFtmU5CioKJ3S8DK5Mw/LJIJw1ZY9E+fTtn8x0k +xlI4e7urD2FYhTdv2fFUG8Z5arb/3bICgsUYQZ+G1c3wjWtJg9zcy8hpnZQ= +-----END CERTIFICATE----- diff --git a/test/integration/targets/get_certificate/files/process_certs.py b/test/integration/targets/get_certificate/files/process_certs.py new file mode 100644 index 0000000000..6948b78477 --- /dev/null +++ b/test/integration/targets/get_certificate/files/process_certs.py @@ -0,0 +1,25 @@ +from sys import argv +from subprocess import Popen, PIPE, STDOUT + +p = Popen(["openssl", "s_client", "-host", argv[1], "-port", "443", "-prexit", "-showcerts"], stdin=PIPE, stdout=PIPE, stderr=STDOUT) +stdout = p.communicate(input=b'\n')[0] +data = stdout.decode() + +certs = [] +cert = "" +capturing = False +for line in data.split('\n'): + if line == '-----BEGIN CERTIFICATE-----': + capturing = True + + if capturing: + cert = "{0}{1}\n".format(cert, line) + + if line == '-----END CERTIFICATE-----': + capturing = False + certs.append(cert) + cert = "" + +with open(argv[2], 'w') as f: + for cert in set(certs): + f.write(cert) diff --git a/test/integration/targets/get_certificate/meta/main.yml b/test/integration/targets/get_certificate/meta/main.yml new file mode 100644 index 0000000000..54be4e6d4d --- /dev/null +++ b/test/integration/targets/get_certificate/meta/main.yml @@ -0,0 +1,3 @@ +dependencies: + - setup_openssl + - prepare_http_tests diff --git a/test/integration/targets/get_certificate/tasks/main.yml b/test/integration/targets/get_certificate/tasks/main.yml new file mode 100644 index 0000000000..827b852a84 --- /dev/null +++ b/test/integration/targets/get_certificate/tasks/main.yml @@ -0,0 +1,5 @@ +- block: + + - include_tasks: ../tests/validate.yml + + when: pyopenssl_version.stdout is version('0.15', '>=') diff --git a/test/integration/targets/get_certificate/tests/validate.yml b/test/integration/targets/get_certificate/tests/validate.yml new file mode 100644 index 0000000000..6d5c46c9d3 --- /dev/null +++ b/test/integration/targets/get_certificate/tests/validate.yml @@ -0,0 +1,99 @@ +- name: Get servers certificate + get_certificate: + host: "{{ httpbin_host }}" + port: 443 + register: result + +- debug: var=result + +- assert: + that: + # This module should never change anything + - result is not changed + - result is not failed + # We got the correct ST from the cert + - "'North Carolina' == result.subject.ST" + +- name: Connect to http port (will fail because there is no SSL cert to get) + get_certificate: + host: "{{ httpbin_host }}" + port: 80 + register: result + ignore_errors: true + +- assert: + that: + - result is not changed + - result is failed + # We got the expected error message + - "'The handshake operation timed out' in result.msg or 'unknown protocol' in result.msg or 'wrong version number' in result.msg" + +- name: Test timeout option + get_certificate: + host: "{{ httpbin_host }}" + port: 1234 + timeout: 1 + register: result + ignore_errors: true + +- assert: + that: + - result is not changed + - result is failed + # We got the expected error message + - "'Failed to get cert from port with error: timed out' == result.msg or 'Connection refused' in result.msg" + +- name: Test failure if ca_certs is not a valid file + get_certificate: + host: "{{ httpbin_host }}" + port: 443 + ca_certs: dn.e + register: result + ignore_errors: true + +- assert: + that: + - result is not changed + - result is failed + # We got the correct response from the module + - "'ca_certs file does not exist' == result.msg" + +- name: Download CA Cert as pem from server + get_url: + url: "http://ansible.http.tests/cacert.pem" + dest: "{{ output_dir }}/temp.pem" + +- name: Get servers certificate comparing it to its own ca_cert file + get_certificate: + ca_certs: '{{ output_dir }}/temp.pem' + host: "{{ httpbin_host }}" + port: 443 + register: result + +- assert: + that: + - result is not changed + - result is not failed + +- name: Get a temp directory + tempfile: + state: directory + register: my_temp_dir + +- name: Deploy the bogus_ca.pem file + copy: + src: "bogus_ca.pem" + dest: "{{ my_temp_dir.path }}/bogus_ca.pem" + +- name: Get servers certificate comparing it to an invalid ca_cert file + get_certificate: + ca_certs: '{{ my_temp_dir.path }}/bogus_ca.pem' + host: "{{ httpbin_host }}" + port: 443 + register: result + ignore_errors: true + +- assert: + that: + - result is not changed + - result.failed