Added get_certificate module (#41735)
* Added get_certificate module. * Fixed test against bogus_ca.pem file
This commit is contained in:
parent
c802790c90
commit
6469baf460
7 changed files with 345 additions and 0 deletions
192
lib/ansible/modules/crypto/get_certificate.py
Normal file
192
lib/ansible/modules/crypto/get_certificate.py
Normal file
|
@ -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()
|
3
test/integration/targets/get_certificate/aliases
Normal file
3
test/integration/targets/get_certificate/aliases
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
shippable/posix/group1
|
||||||
|
destructive
|
||||||
|
needs/httptester
|
18
test/integration/targets/get_certificate/files/bogus_ca.pem
Normal file
18
test/integration/targets/get_certificate/files/bogus_ca.pem
Normal file
|
@ -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-----
|
|
@ -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)
|
3
test/integration/targets/get_certificate/meta/main.yml
Normal file
3
test/integration/targets/get_certificate/meta/main.yml
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
dependencies:
|
||||||
|
- setup_openssl
|
||||||
|
- prepare_http_tests
|
5
test/integration/targets/get_certificate/tasks/main.yml
Normal file
5
test/integration/targets/get_certificate/tasks/main.yml
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
- block:
|
||||||
|
|
||||||
|
- include_tasks: ../tests/validate.yml
|
||||||
|
|
||||||
|
when: pyopenssl_version.stdout is version('0.15', '>=')
|
99
test/integration/targets/get_certificate/tests/validate.yml
Normal file
99
test/integration/targets/get_certificate/tests/validate.yml
Normal file
|
@ -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
|
Loading…
Reference in a new issue