summaryrefslogtreecommitdiffstats
path: root/roles/openshift_certificate_expiry
diff options
context:
space:
mode:
Diffstat (limited to 'roles/openshift_certificate_expiry')
-rw-r--r--roles/openshift_certificate_expiry/filter_plugins/oo_cert_expiry.py66
-rw-r--r--roles/openshift_certificate_expiry/library/openshift_cert_expiry.py839
-rw-r--r--roles/openshift_certificate_expiry/tasks/main.yml4
-rw-r--r--roles/openshift_certificate_expiry/test/conftest.py119
-rw-r--r--roles/openshift_certificate_expiry/test/test_fakeopensslclasses.py90
-rw-r--r--roles/openshift_certificate_expiry/test/test_load_and_handle_cert.py67
6 files changed, 3 insertions, 1182 deletions
diff --git a/roles/openshift_certificate_expiry/filter_plugins/oo_cert_expiry.py b/roles/openshift_certificate_expiry/filter_plugins/oo_cert_expiry.py
deleted file mode 100644
index 58b228fee..000000000
--- a/roles/openshift_certificate_expiry/filter_plugins/oo_cert_expiry.py
+++ /dev/null
@@ -1,66 +0,0 @@
-#!/usr/bin/python
-# -*- coding: utf-8 -*-
-"""
-Custom filters for use in openshift-ansible
-"""
-
-
-# Disabling too-many-public-methods, since filter methods are necessarily
-# public
-# pylint: disable=too-many-public-methods
-class FilterModule(object):
- """ Custom ansible filters """
-
- @staticmethod
- def oo_cert_expiry_results_to_json(hostvars, play_hosts):
- """Takes results (`hostvars`) from the openshift_cert_expiry role
-check and serializes them into proper machine-readable JSON
-output. This filter parameter **MUST** be the playbook `hostvars`
-variable. The `play_hosts` parameter is so we know what to loop over
-when we're extrating the values.
-
-Returns:
-
-Results are collected into two top-level keys under the `json_results`
-dict:
-
-* `json_results.data` [dict] - Each individual host check result, keys are hostnames
-* `json_results.summary` [dict] - Summary of number of `warning` and `expired`
-certificates
-
-Example playbook usage:
-
- - name: Generate expiration results JSON
- run_once: yes
- delegate_to: localhost
- when: openshift_certificate_expiry_save_json_results|bool
- copy:
- content: "{{ hostvars|oo_cert_expiry_results_to_json() }}"
- dest: "{{ openshift_certificate_expiry_json_results_path }}"
-
- """
- json_result = {
- 'data': {},
- 'summary': {},
- }
-
- for host in play_hosts:
- json_result['data'][host] = hostvars[host]['check_results']['check_results']
-
- total_warnings = sum([hostvars[h]['check_results']['summary']['warning'] for h in play_hosts])
- total_expired = sum([hostvars[h]['check_results']['summary']['expired'] for h in play_hosts])
- total_ok = sum([hostvars[h]['check_results']['summary']['ok'] for h in play_hosts])
- total_total = sum([hostvars[h]['check_results']['summary']['total'] for h in play_hosts])
-
- json_result['summary']['warning'] = total_warnings
- json_result['summary']['expired'] = total_expired
- json_result['summary']['ok'] = total_ok
- json_result['summary']['total'] = total_total
-
- return json_result
-
- def filters(self):
- """ returns a mapping of filters to methods """
- return {
- "oo_cert_expiry_results_to_json": self.oo_cert_expiry_results_to_json,
- }
diff --git a/roles/openshift_certificate_expiry/library/openshift_cert_expiry.py b/roles/openshift_certificate_expiry/library/openshift_cert_expiry.py
deleted file mode 100644
index e355266b0..000000000
--- a/roles/openshift_certificate_expiry/library/openshift_cert_expiry.py
+++ /dev/null
@@ -1,839 +0,0 @@
-#!/usr/bin/python
-# -*- coding: utf-8 -*-
-# pylint: disable=line-too-long,invalid-name
-
-"""For details on this module see DOCUMENTATION (below)"""
-
-import base64
-import datetime
-import io
-import os
-import subprocess
-import yaml
-
-# pylint import-error disabled because pylint cannot find the package
-# when installed in a virtualenv
-from ansible.module_utils.six.moves import configparser # pylint: disable=import-error
-from ansible.module_utils.basic import AnsibleModule
-
-try:
- # You can comment this import out and include a 'pass' in this
- # block if you're manually testing this module on a NON-ATOMIC
- # HOST (or any host that just doesn't have PyOpenSSL
- # available). That will force the `load_and_handle_cert` function
- # to use the Fake OpenSSL classes.
- import OpenSSL.crypto
- HAS_OPENSSL = True
-except ImportError:
- # Some platforms (such as RHEL Atomic) may not have the Python
- # OpenSSL library installed. In this case we will use a manual
- # work-around to parse each certificate.
- #
- # Check for 'OpenSSL.crypto' in `sys.modules` later.
- HAS_OPENSSL = False
-
-DOCUMENTATION = '''
----
-module: openshift_cert_expiry
-short_description: Check OpenShift Container Platform (OCP) and Kube certificate expirations on a cluster
-description:
- - The M(openshift_cert_expiry) module has two basic functions: to flag certificates which will expire in a set window of time from now, and to notify you about certificates which have already expired.
- - When the module finishes, a summary of the examination is returned. Each certificate in the summary has a C(health) key with a value of one of the following:
- - C(ok) - not expired, and outside of the expiration C(warning_days) window.
- - C(warning) - not expired, but will expire between now and the C(warning_days) window.
- - C(expired) - an expired certificate.
- - Certificate flagging follow this logic:
- - If the expiration date is before now then the certificate is classified as C(expired).
- - The certificates time to live (expiration date - now) is calculated, if that time window is less than C(warning_days) the certificate is classified as C(warning).
- - All other conditions are classified as C(ok).
- - The following keys are ALSO present in the certificate summary:
- - C(cert_cn) - The common name of the certificate (additional CNs present in SAN extensions are omitted)
- - C(days_remaining) - The number of days until the certificate expires.
- - C(expiry) - The date the certificate expires on.
- - C(path) - The full path to the certificate on the examined host.
-version_added: "1.0"
-options:
- config_base:
- description:
- - Base path to OCP system settings.
- required: false
- default: /etc/origin
- warning_days:
- description:
- - Flag certificates which will expire in C(warning_days) days from now.
- required: false
- default: 30
- show_all:
- description:
- - Enable this option to show analysis of ALL certificates examined by this module.
- - By default only certificates which have expired, or will expire within the C(warning_days) window will be reported.
- required: false
- default: false
-
-author: "Tim Bielawa (@tbielawa) <tbielawa@redhat.com>"
-'''
-
-EXAMPLES = '''
-# Default invocation, only notify about expired certificates or certificates which will expire within 30 days from now
-- openshift_cert_expiry:
-
-# Expand the warning window to show certificates expiring within a year from now
-- openshift_cert_expiry: warning_days=365
-
-# Show expired, soon to expire (now + 30 days), and all other certificates examined
-- openshift_cert_expiry: show_all=true
-'''
-
-
-class FakeOpenSSLCertificate(object):
- """This provides a rough mock of what you get from
-`OpenSSL.crypto.load_certificate()`. This is a work-around for
-platforms missing the Python OpenSSL library.
- """
- def __init__(self, cert_string):
- """`cert_string` is a certificate in the form you get from running a
-.crt through 'openssl x509 -in CERT.cert -text'"""
- self.cert_string = cert_string
- self.serial = None
- self.subject = None
- self.extensions = []
- self.not_after = None
- self._parse_cert()
-
- def _parse_cert(self):
- """Manually parse the certificate line by line"""
- self.extensions = []
-
- PARSING_ALT_NAMES = False
- PARSING_HEX_SERIAL = False
- for line in self.cert_string.split('\n'):
- l = line.strip()
- if PARSING_ALT_NAMES:
- # We're parsing a 'Subject Alternative Name' line
- self.extensions.append(
- FakeOpenSSLCertificateSANExtension(l))
-
- PARSING_ALT_NAMES = False
- continue
-
- if PARSING_HEX_SERIAL:
- # Hex serials arrive colon-delimited
- serial_raw = l.replace(':', '')
- # Convert to decimal
- self.serial = int('0x' + serial_raw, base=16)
- PARSING_HEX_SERIAL = False
- continue
-
- # parse out the bits that we can
- if l.startswith('Serial Number:'):
- # Decimal format:
- # Serial Number: 11 (0xb)
- # => 11
- # Hex Format (large serials):
- # Serial Number:
- # 0a:de:eb:24:04:75:ab:56:39:14:e9:5a:22:e2:85:bf
- # => 14449739080294792594019643629255165375
- if l.endswith(':'):
- PARSING_HEX_SERIAL = True
- continue
- self.serial = int(l.split()[-2])
-
- elif l.startswith('Not After :'):
- # Not After : Feb 7 18:19:35 2019 GMT
- # => strptime(str, '%b %d %H:%M:%S %Y %Z')
- # => strftime('%Y%m%d%H%M%SZ')
- # => 20190207181935Z
- not_after_raw = l.partition(' : ')[-1]
- # Last item: ('Not After', ' : ', 'Feb 7 18:19:35 2019 GMT')
- not_after_parsed = datetime.datetime.strptime(not_after_raw, '%b %d %H:%M:%S %Y %Z')
- self.not_after = not_after_parsed.strftime('%Y%m%d%H%M%SZ')
-
- elif l.startswith('X509v3 Subject Alternative Name:'):
- PARSING_ALT_NAMES = True
- continue
-
- elif l.startswith('Subject:'):
- # O = system:nodes, CN = system:node:m01.example.com
- self.subject = FakeOpenSSLCertificateSubjects(l.partition(': ')[-1])
-
- def get_serial_number(self):
- """Return the serial number of the cert"""
- return self.serial
-
- def get_subject(self):
- """Subjects must implement get_components() and return dicts or
-tuples. An 'openssl x509 -in CERT.cert -text' with 'Subject':
-
- Subject: Subject: O=system:nodes, CN=system:node:m01.example.com
-
-might return: [('O=system', 'nodes'), ('CN=system', 'node:m01.example.com')]
- """
- return self.subject
-
- def get_extension(self, i):
- """Extensions must implement get_short_name() and return the string
-'subjectAltName'"""
- return self.extensions[i]
-
- def get_extension_count(self):
- """ get_extension_count """
- return len(self.extensions)
-
- def get_notAfter(self):
- """Returns a date stamp as a string in the form
-'20180922170439Z'. strptime the result with format param:
-'%Y%m%d%H%M%SZ'."""
- return self.not_after
-
-
-class FakeOpenSSLCertificateSANExtension(object): # pylint: disable=too-few-public-methods
- """Mocks what happens when `get_extension` is called on a certificate
-object"""
-
- def __init__(self, san_string):
- """With `san_string` as you get from:
-
- $ openssl x509 -in certificate.crt -text
- """
- self.san_string = san_string
- self.short_name = 'subjectAltName'
-
- def get_short_name(self):
- """Return the 'type' of this extension. It's always the same though
-because we only care about subjectAltName's"""
- return self.short_name
-
- def __str__(self):
- """Return this extension and the value as a simple string"""
- return self.san_string
-
-
-# pylint: disable=too-few-public-methods
-class FakeOpenSSLCertificateSubjects(object):
- """Mocks what happens when `get_subject` is called on a certificate
-object"""
-
- def __init__(self, subject_string):
- """With `subject_string` as you get from:
-
- $ openssl x509 -in certificate.crt -text
- """
- self.subjects = []
- for s in subject_string.split(', '):
- name, _, value = s.partition(' = ')
- self.subjects.append((name, value))
-
- def get_components(self):
- """Returns a list of tuples"""
- return self.subjects
-
-
-######################################################################
-def filter_paths(path_list):
- """`path_list` - A list of file paths to check. Only files which exist
-will be returned
- """
- return [p for p in path_list if os.path.exists(os.path.realpath(p))]
-
-
-# pylint: disable=too-many-locals,too-many-branches
-#
-# TODO: Break this function down into smaller chunks
-def load_and_handle_cert(cert_string, now, base64decode=False, ans_module=None):
- """Load a certificate, split off the good parts, and return some
-useful data
-
-Params:
-
-- `cert_string` (string) - a certificate loaded into a string object
-- `now` (datetime) - a datetime object of the time to calculate the certificate 'time_remaining' against
-- `base64decode` (bool) - run base64.b64decode() on the input
-- `ans_module` (AnsibleModule) - The AnsibleModule object for this module (so we can raise errors)
-
-Returns:
-A tuple of the form:
- (cert_subject, cert_expiry_date, time_remaining, cert_serial_number)
- """
- if base64decode:
- _cert_string = base64.b64decode(cert_string).decode('utf-8')
- else:
- _cert_string = cert_string
-
- # Disable this. We 'redefine' the type because we are working
- # around a missing library on the target host.
- #
- # pylint: disable=redefined-variable-type
- if HAS_OPENSSL:
- # No work-around required
- cert_loaded = OpenSSL.crypto.load_certificate(
- OpenSSL.crypto.FILETYPE_PEM, _cert_string)
- else:
- # Missing library, work-around required. Run the 'openssl'
- # command on it to decode it
- cmd = 'openssl x509 -text'
- try:
- openssl_proc = subprocess.Popen(cmd.split(),
- stdout=subprocess.PIPE,
- stdin=subprocess.PIPE)
- except OSError:
- ans_module.fail_json(msg="Error: The 'OpenSSL' python library and CLI command were not found on the target host. Unable to parse any certificates. This host will not be included in generated reports.")
- else:
- openssl_decoded = openssl_proc.communicate(_cert_string.encode('utf-8'))[0].decode('utf-8')
- cert_loaded = FakeOpenSSLCertificate(openssl_decoded)
-
- ######################################################################
- # Read all possible names from the cert
- cert_subjects = []
- for name, value in cert_loaded.get_subject().get_components():
- if isinstance(name, bytes) or isinstance(value, bytes):
- name = name.decode('utf-8')
- value = value.decode('utf-8')
- cert_subjects.append('{}:{}'.format(name, value))
-
- # To read SANs from a cert we must read the subjectAltName
- # extension from the X509 Object. What makes this more difficult
- # is that pyOpenSSL does not give extensions as an iterable
- san = None
- for i in range(cert_loaded.get_extension_count()):
- ext = cert_loaded.get_extension(i)
- if ext.get_short_name() == 'subjectAltName':
- san = ext
-
- if san is not None:
- # The X509Extension object for subjectAltName prints as a
- # string with the alt names separated by a comma and a
- # space. Split the string by ', ' and then add our new names
- # to the list of existing names
- cert_subjects.extend(str(san).split(', '))
-
- cert_subject = ', '.join(cert_subjects)
- ######################################################################
-
- # Grab the expiration date
- not_after = cert_loaded.get_notAfter()
- # example get_notAfter() => 20180922170439Z
- if isinstance(not_after, bytes):
- not_after = not_after.decode('utf-8')
-
- cert_expiry_date = datetime.datetime.strptime(
- not_after,
- '%Y%m%d%H%M%SZ')
-
- time_remaining = cert_expiry_date - now
-
- return (cert_subject, cert_expiry_date, time_remaining, cert_loaded.get_serial_number())
-
-
-def classify_cert(cert_meta, now, time_remaining, expire_window, cert_list):
- """Given metadata about a certificate under examination, classify it
- into one of three categories, 'ok', 'warning', and 'expired'.
-
-Params:
-
-- `cert_meta` dict - A dict with certificate metadata. Required fields
- include: 'cert_cn', 'path', 'expiry', 'days_remaining', 'health'.
-- `now` (datetime) - a datetime object of the time to calculate the certificate 'time_remaining' against
-- `time_remaining` (datetime.timedelta) - a timedelta for how long until the cert expires
-- `expire_window` (datetime.timedelta) - a timedelta for how long the warning window is
-- `cert_list` list - A list to shove the classified cert into
-
-Return:
-- `cert_list` - The updated list of classified certificates
- """
- expiry_str = str(cert_meta['expiry'])
- # Categorization
- if cert_meta['expiry'] < now:
- # This already expired, must NOTIFY
- cert_meta['health'] = 'expired'
- elif time_remaining < expire_window:
- # WARN about this upcoming expirations
- cert_meta['health'] = 'warning'
- else:
- # Not expired or about to expire
- cert_meta['health'] = 'ok'
-
- cert_meta['expiry'] = expiry_str
- cert_meta['serial_hex'] = hex(int(cert_meta['serial']))
- cert_list.append(cert_meta)
- return cert_list
-
-
-def tabulate_summary(certificates, kubeconfigs, etcd_certs, router_certs, registry_certs):
- """Calculate the summary text for when the module finishes
-running. This includes counts of each classification and what have
-you.
-
-Params:
-
-- `certificates` (list of dicts) - Processed `expire_check_result`
- dicts with filled in `health` keys for system certificates.
-- `kubeconfigs` - as above for kubeconfigs
-- `etcd_certs` - as above for etcd certs
-
-Return:
-
-- `summary_results` (dict) - Counts of each cert type classification
- and total items examined.
- """
- items = certificates + kubeconfigs + etcd_certs + router_certs + registry_certs
-
- summary_results = {
- 'system_certificates': len(certificates),
- 'kubeconfig_certificates': len(kubeconfigs),
- 'etcd_certificates': len(etcd_certs),
- 'router_certs': len(router_certs),
- 'registry_certs': len(registry_certs),
- 'total': len(items),
- 'ok': 0,
- 'warning': 0,
- 'expired': 0
- }
-
- summary_results['expired'] = len([c for c in items if c['health'] == 'expired'])
- summary_results['warning'] = len([c for c in items if c['health'] == 'warning'])
- summary_results['ok'] = len([c for c in items if c['health'] == 'ok'])
-
- return summary_results
-
-
-######################################################################
-# This is our module MAIN function after all, so there's bound to be a
-# lot of code bundled up into one block
-#
-# Reason: These checks are disabled because the issue was introduced
-# during a period where the pylint checks weren't enabled for this file
-# Status: temporarily disabled pending future refactoring
-# pylint: disable=too-many-locals,too-many-statements,too-many-branches
-def main():
- """This module examines certificates (in various forms) which compose
-an OpenShift Container Platform cluster
- """
-
- module = AnsibleModule(
- argument_spec=dict(
- config_base=dict(
- required=False,
- default="/etc/origin",
- type='str'),
- warning_days=dict(
- required=False,
- default=30,
- type='int'),
- show_all=dict(
- required=False,
- default=False,
- type='bool')
- ),
- supports_check_mode=True,
- )
-
- # Basic scaffolding for OpenShift specific certs
- openshift_base_config_path = os.path.realpath(module.params['config_base'])
- openshift_master_config_path = os.path.join(openshift_base_config_path,
- "master", "master-config.yaml")
- openshift_node_config_path = os.path.join(openshift_base_config_path,
- "node", "node-config.yaml")
- openshift_cert_check_paths = [
- openshift_master_config_path,
- openshift_node_config_path,
- ]
-
- # Paths for Kubeconfigs. Additional kubeconfigs are conditionally
- # checked later in the code
- master_kube_configs = ['admin', 'openshift-master',
- 'openshift-node', 'openshift-router',
- 'openshift-registry']
-
- kubeconfig_paths = []
- for m_kube_config in master_kube_configs:
- kubeconfig_paths.append(
- os.path.join(openshift_base_config_path, "master", m_kube_config + ".kubeconfig")
- )
-
- # Validate some paths we have the ability to do ahead of time
- openshift_cert_check_paths = filter_paths(openshift_cert_check_paths)
- kubeconfig_paths = filter_paths(kubeconfig_paths)
-
- # etcd, where do you hide your certs? Used when parsing etcd.conf
- etcd_cert_params = [
- "ETCD_CA_FILE",
- "ETCD_CERT_FILE",
- "ETCD_PEER_CA_FILE",
- "ETCD_PEER_CERT_FILE",
- ]
-
- # Expiry checking stuff
- now = datetime.datetime.now()
- # todo, catch exception for invalid input and return a fail_json
- warning_days = int(module.params['warning_days'])
- expire_window = datetime.timedelta(days=warning_days)
-
- # Module stuff
- #
- # The results of our cert checking to return from the task call
- check_results = {}
- check_results['meta'] = {}
- check_results['meta']['warning_days'] = warning_days
- check_results['meta']['checked_at_time'] = str(now)
- check_results['meta']['warn_before_date'] = str(now + expire_window)
- check_results['meta']['show_all'] = str(module.params['show_all'])
- # All the analyzed certs accumulate here
- ocp_certs = []
-
- ######################################################################
- # Sure, why not? Let's enable check mode.
- if module.check_mode:
- check_results['ocp_certs'] = []
- module.exit_json(
- check_results=check_results,
- msg="Checked 0 total certificates. Expired/Warning/OK: 0/0/0. Warning window: %s days" % module.params['warning_days'],
- rc=0,
- changed=False
- )
-
- ######################################################################
- # Check for OpenShift Container Platform specific certs
- ######################################################################
- for os_cert in filter_paths(openshift_cert_check_paths):
- # Open up that config file and locate the cert and CA
- with io.open(os_cert, 'r', encoding='utf-8') as fp:
- cert_meta = {}
- cfg = yaml.load(fp)
- # cert files are specified in parsed `fp` as relative to the path
- # of the original config file. 'master-config.yaml' with certFile
- # = 'foo.crt' implies that 'foo.crt' is in the same
- # directory. certFile = '../foo.crt' is in the parent directory.
- cfg_path = os.path.dirname(fp.name)
- cert_meta['certFile'] = os.path.join(cfg_path, cfg['servingInfo']['certFile'])
- cert_meta['clientCA'] = os.path.join(cfg_path, cfg['servingInfo']['clientCA'])
-
- ######################################################################
- # Load the certificate and the CA, parse their expiration dates into
- # datetime objects so we can manipulate them later
- for v in cert_meta.values():
- with io.open(v, 'r', encoding='utf-8') as fp:
- cert = fp.read()
- (cert_subject,
- cert_expiry_date,
- time_remaining,
- cert_serial) = load_and_handle_cert(cert, now, ans_module=module)
-
- expire_check_result = {
- 'cert_cn': cert_subject,
- 'path': fp.name,
- 'expiry': cert_expiry_date,
- 'days_remaining': time_remaining.days,
- 'health': None,
- 'serial': cert_serial
- }
-
- classify_cert(expire_check_result, now, time_remaining, expire_window, ocp_certs)
-
- ######################################################################
- # /Check for OpenShift Container Platform specific certs
- ######################################################################
-
- ######################################################################
- # Check service Kubeconfigs
- ######################################################################
- kubeconfigs = []
-
- # There may be additional kubeconfigs to check, but their naming
- # is less predictable than the ones we've already assembled.
-
- try:
- # Try to read the standard 'node-config.yaml' file to check if
- # this host is a node.
- with io.open(openshift_node_config_path, 'r', encoding='utf-8') as fp:
- cfg = yaml.load(fp)
-
- # OK, the config file exists, therefore this is a
- # node. Nodes have their own kubeconfig files to
- # communicate with the master API. Let's read the relative
- # path to that file from the node config.
- node_masterKubeConfig = cfg['masterKubeConfig']
- # As before, the path to the 'masterKubeConfig' file is
- # relative to `fp`
- cfg_path = os.path.dirname(fp.name)
- node_kubeconfig = os.path.join(cfg_path, node_masterKubeConfig)
-
- with io.open(node_kubeconfig, 'r', encoding='utf8') as fp:
- # Read in the nodes kubeconfig file and grab the good stuff
- cfg = yaml.load(fp)
-
- c = cfg['users'][0]['user']['client-certificate-data']
- (cert_subject,
- cert_expiry_date,
- time_remaining,
- cert_serial) = load_and_handle_cert(c, now, base64decode=True, ans_module=module)
-
- expire_check_result = {
- 'cert_cn': cert_subject,
- 'path': fp.name,
- 'expiry': cert_expiry_date,
- 'days_remaining': time_remaining.days,
- 'health': None,
- 'serial': cert_serial
- }
-
- classify_cert(expire_check_result, now, time_remaining, expire_window, kubeconfigs)
- except IOError:
- # This is not a node
- pass
-
- for kube in filter_paths(kubeconfig_paths):
- with io.open(kube, 'r', encoding='utf-8') as fp:
- # TODO: Maybe consider catching exceptions here?
- cfg = yaml.load(fp)
-
- # Per conversation, "the kubeconfigs you care about:
- # admin, router, registry should all be single
- # value". Following that advice we only grab the data for
- # the user at index 0 in the 'users' list. There should
- # not be more than one user.
- c = cfg['users'][0]['user']['client-certificate-data']
- (cert_subject,
- cert_expiry_date,
- time_remaining,
- cert_serial) = load_and_handle_cert(c, now, base64decode=True, ans_module=module)
-
- expire_check_result = {
- 'cert_cn': cert_subject,
- 'path': fp.name,
- 'expiry': cert_expiry_date,
- 'days_remaining': time_remaining.days,
- 'health': None,
- 'serial': cert_serial
- }
-
- classify_cert(expire_check_result, now, time_remaining, expire_window, kubeconfigs)
-
- ######################################################################
- # /Check service Kubeconfigs
- ######################################################################
-
- ######################################################################
- # Check etcd certs
- #
- # Two things to check: 'external' etcd, and embedded etcd.
- ######################################################################
- # FIRST: The 'external' etcd
- #
- # Some values may be duplicated, make this a set for now so we
- # unique them all
- etcd_certs_to_check = set([])
- etcd_certs = []
- etcd_cert_params.append('dne')
- try:
- with io.open('/etc/etcd/etcd.conf', 'r', encoding='utf-8') as fp:
- # Add dummy header section.
- config = io.StringIO()
- config.write(u'[ETCD]\n')
- config.write(fp.read().replace('%', '%%'))
- config.seek(0, os.SEEK_SET)
-
- etcd_config = configparser.ConfigParser()
- etcd_config.readfp(config)
-
- for param in etcd_cert_params:
- try:
- etcd_certs_to_check.add(etcd_config.get('ETCD', param))
- except configparser.NoOptionError:
- # That parameter does not exist, oh well...
- pass
- except IOError:
- # No etcd to see here, move along
- pass
-
- for etcd_cert in filter_paths(etcd_certs_to_check):
- with io.open(etcd_cert, 'r', encoding='utf-8') as fp:
- c = fp.read()
- (cert_subject,
- cert_expiry_date,
- time_remaining,
- cert_serial) = load_and_handle_cert(c, now, ans_module=module)
-
- expire_check_result = {
- 'cert_cn': cert_subject,
- 'path': fp.name,
- 'expiry': cert_expiry_date,
- 'days_remaining': time_remaining.days,
- 'health': None,
- 'serial': cert_serial
- }
-
- classify_cert(expire_check_result, now, time_remaining, expire_window, etcd_certs)
-
- ######################################################################
- # Now the embedded etcd
- ######################################################################
- try:
- with io.open('/etc/origin/master/master-config.yaml', 'r', encoding='utf-8') as fp:
- cfg = yaml.load(fp)
- except IOError:
- # Not present
- pass
- else:
- if cfg.get('etcdConfig', {}).get('servingInfo', {}).get('certFile', None) is not None:
- # This is embedded
- etcd_crt_name = cfg['etcdConfig']['servingInfo']['certFile']
- else:
- # Not embedded
- etcd_crt_name = None
-
- if etcd_crt_name is not None:
- # etcd_crt_name is relative to the location of the
- # master-config.yaml file
- cfg_path = os.path.dirname(fp.name)
- etcd_cert = os.path.join(cfg_path, etcd_crt_name)
- with open(etcd_cert, 'r') as etcd_fp:
- (cert_subject,
- cert_expiry_date,
- time_remaining,
- cert_serial) = load_and_handle_cert(etcd_fp.read(), now, ans_module=module)
-
- expire_check_result = {
- 'cert_cn': cert_subject,
- 'path': etcd_fp.name,
- 'expiry': cert_expiry_date,
- 'days_remaining': time_remaining.days,
- 'health': None,
- 'serial': cert_serial
- }
-
- classify_cert(expire_check_result, now, time_remaining, expire_window, etcd_certs)
-
- ######################################################################
- # /Check etcd certs
- ######################################################################
-
- ######################################################################
- # Check router/registry certs
- #
- # These are saved as secrets in etcd. That means that we can not
- # simply read a file to grab the data. Instead we're going to
- # subprocess out to the 'oc get' command. On non-masters this
- # command will fail, that is expected so we catch that exception.
- ######################################################################
- router_certs = []
- registry_certs = []
-
- ######################################################################
- # First the router certs
- try:
- router_secrets_raw = subprocess.Popen('oc get -n default secret router-certs -o yaml'.split(),
- stdout=subprocess.PIPE)
- router_ds = yaml.load(router_secrets_raw.communicate()[0])
- router_c = router_ds['data']['tls.crt']
- router_path = router_ds['metadata']['selfLink']
- except TypeError:
- # YAML couldn't load the result, this is not a master
- pass
- except OSError:
- # The OC command doesn't exist here. Move along.
- pass
- else:
- (cert_subject,
- cert_expiry_date,
- time_remaining,
- cert_serial) = load_and_handle_cert(router_c, now, base64decode=True, ans_module=module)
-
- expire_check_result = {
- 'cert_cn': cert_subject,
- 'path': router_path,
- 'expiry': cert_expiry_date,
- 'days_remaining': time_remaining.days,
- 'health': None,
- 'serial': cert_serial
- }
-
- classify_cert(expire_check_result, now, time_remaining, expire_window, router_certs)
-
- ######################################################################
- # Now for registry
- try:
- registry_secrets_raw = subprocess.Popen('oc get -n default secret registry-certificates -o yaml'.split(),
- stdout=subprocess.PIPE)
- registry_ds = yaml.load(registry_secrets_raw.communicate()[0])
- registry_c = registry_ds['data']['registry.crt']
- registry_path = registry_ds['metadata']['selfLink']
- except TypeError:
- # YAML couldn't load the result, this is not a master
- pass
- except OSError:
- # The OC command doesn't exist here. Move along.
- pass
- else:
- (cert_subject,
- cert_expiry_date,
- time_remaining,
- cert_serial) = load_and_handle_cert(registry_c, now, base64decode=True, ans_module=module)
-
- expire_check_result = {
- 'cert_cn': cert_subject,
- 'path': registry_path,
- 'expiry': cert_expiry_date,
- 'days_remaining': time_remaining.days,
- 'health': None,
- 'serial': cert_serial
- }
-
- classify_cert(expire_check_result, now, time_remaining, expire_window, registry_certs)
-
- ######################################################################
- # /Check router/registry certs
- ######################################################################
-
- res = tabulate_summary(ocp_certs, kubeconfigs, etcd_certs, router_certs, registry_certs)
-
- msg = "Checked {count} total certificates. Expired/Warning/OK: {exp}/{warn}/{ok}. Warning window: {window} days".format(
- count=res['total'],
- exp=res['expired'],
- warn=res['warning'],
- ok=res['ok'],
- window=int(module.params['warning_days']),
- )
-
- # By default we only return detailed information about expired or
- # warning certificates. If show_all is true then we will print all
- # the certificates examined.
- if not module.params['show_all']:
- check_results['ocp_certs'] = [crt for crt in ocp_certs if crt['health'] in ['expired', 'warning']]
- check_results['kubeconfigs'] = [crt for crt in kubeconfigs if crt['health'] in ['expired', 'warning']]
- check_results['etcd'] = [crt for crt in etcd_certs if crt['health'] in ['expired', 'warning']]
- check_results['registry'] = [crt for crt in registry_certs if crt['health'] in ['expired', 'warning']]
- check_results['router'] = [crt for crt in router_certs if crt['health'] in ['expired', 'warning']]
- else:
- check_results['ocp_certs'] = ocp_certs
- check_results['kubeconfigs'] = kubeconfigs
- check_results['etcd'] = etcd_certs
- check_results['registry'] = registry_certs
- check_results['router'] = router_certs
-
- # Sort the final results to report in order of ascending safety
- # time. That is to say, the certificates which will expire sooner
- # will be at the front of the list and certificates which will
- # expire later are at the end. Router and registry certs should be
- # limited to just 1 result, so don't bother sorting those.
- def cert_key(item):
- ''' return the days_remaining key '''
- return item['days_remaining']
-
- check_results['ocp_certs'] = sorted(check_results['ocp_certs'], key=cert_key)
- check_results['kubeconfigs'] = sorted(check_results['kubeconfigs'], key=cert_key)
- check_results['etcd'] = sorted(check_results['etcd'], key=cert_key)
-
- # This module will never change anything, but we might want to
- # change the return code parameter if there is some catastrophic
- # error we noticed earlier
- module.exit_json(
- check_results=check_results,
- summary=res,
- msg=msg,
- rc=0,
- changed=False
- )
-
-
-if __name__ == '__main__':
- main()
diff --git a/roles/openshift_certificate_expiry/tasks/main.yml b/roles/openshift_certificate_expiry/tasks/main.yml
index 8dea2c07f..7062b5060 100644
--- a/roles/openshift_certificate_expiry/tasks/main.yml
+++ b/roles/openshift_certificate_expiry/tasks/main.yml
@@ -16,7 +16,9 @@
- name: Generate the result JSON string
run_once: yes
- set_fact: json_result_string="{{ hostvars|oo_cert_expiry_results_to_json(play_hosts) }}"
+ set_fact:
+ # oo_cert_expiry_results_to_json is a custom filter in role lib_utils
+ json_result_string: "{{ hostvars|oo_cert_expiry_results_to_json(play_hosts) }}"
when: openshift_certificate_expiry_save_json_results|bool
- name: Generate results JSON file
diff --git a/roles/openshift_certificate_expiry/test/conftest.py b/roles/openshift_certificate_expiry/test/conftest.py
deleted file mode 100644
index df948fff0..000000000
--- a/roles/openshift_certificate_expiry/test/conftest.py
+++ /dev/null
@@ -1,119 +0,0 @@
-# pylint: disable=missing-docstring,invalid-name,redefined-outer-name
-import pytest
-from OpenSSL import crypto
-
-# Parameter list for valid_cert fixture
-VALID_CERTIFICATE_PARAMS = [
- {
- 'short_name': 'client',
- 'cn': 'client.example.com',
- 'serial': 4,
- 'uses': b'clientAuth',
- 'dns': [],
- 'ip': [],
- },
- {
- 'short_name': 'server',
- 'cn': 'server.example.com',
- 'serial': 5,
- 'uses': b'serverAuth',
- 'dns': ['kubernetes', 'openshift'],
- 'ip': ['10.0.0.1', '192.168.0.1']
- },
- {
- 'short_name': 'combined',
- 'cn': 'combined.example.com',
- # Verify that HUGE serials parse correctly.
- # Frobs PARSING_HEX_SERIAL in _parse_cert
- # See https://bugzilla.redhat.com/show_bug.cgi?id=1464240
- 'serial': 14449739080294792594019643629255165375,
- 'uses': b'clientAuth, serverAuth',
- 'dns': ['etcd'],
- 'ip': ['10.0.0.2', '192.168.0.2']
- }
-]
-
-# Extract the short_name from VALID_CERTIFICATE_PARAMS to provide
-# friendly naming for the valid_cert fixture
-VALID_CERTIFICATE_IDS = [param['short_name'] for param in VALID_CERTIFICATE_PARAMS]
-
-
-@pytest.fixture(scope='session')
-def ca(tmpdir_factory):
- ca_dir = tmpdir_factory.mktemp('ca')
-
- key = crypto.PKey()
- key.generate_key(crypto.TYPE_RSA, 2048)
-
- cert = crypto.X509()
- cert.set_version(3)
- cert.set_serial_number(1)
- cert.get_subject().commonName = 'test-signer'
- cert.gmtime_adj_notBefore(0)
- cert.gmtime_adj_notAfter(24 * 60 * 60)
- cert.set_issuer(cert.get_subject())
- cert.set_pubkey(key)
- cert.add_extensions([
- crypto.X509Extension(b'basicConstraints', True, b'CA:TRUE, pathlen:0'),
- crypto.X509Extension(b'keyUsage', True,
- b'digitalSignature, keyEncipherment, keyCertSign, cRLSign'),
- crypto.X509Extension(b'subjectKeyIdentifier', False, b'hash', subject=cert)
- ])
- cert.add_extensions([
- crypto.X509Extension(b'authorityKeyIdentifier', False, b'keyid:always', issuer=cert)
- ])
- cert.sign(key, 'sha256')
-
- return {
- 'dir': ca_dir,
- 'key': key,
- 'cert': cert,
- }
-
-
-@pytest.fixture(scope='session',
- ids=VALID_CERTIFICATE_IDS,
- params=VALID_CERTIFICATE_PARAMS)
-def valid_cert(request, ca):
- common_name = request.param['cn']
-
- key = crypto.PKey()
- key.generate_key(crypto.TYPE_RSA, 2048)
-
- cert = crypto.X509()
- cert.set_serial_number(request.param['serial'])
- cert.gmtime_adj_notBefore(0)
- cert.gmtime_adj_notAfter(24 * 60 * 60)
- cert.set_issuer(ca['cert'].get_subject())
- cert.set_pubkey(key)
- cert.set_version(3)
- cert.get_subject().commonName = common_name
- cert.add_extensions([
- crypto.X509Extension(b'basicConstraints', True, b'CA:FALSE'),
- crypto.X509Extension(b'keyUsage', True, b'digitalSignature, keyEncipherment'),
- crypto.X509Extension(b'extendedKeyUsage', False, request.param['uses']),
- ])
-
- if request.param['dns'] or request.param['ip']:
- san_list = ['DNS:{}'.format(common_name)]
- san_list.extend(['DNS:{}'.format(x) for x in request.param['dns']])
- san_list.extend(['IP:{}'.format(x) for x in request.param['ip']])
-
- cert.add_extensions([
- crypto.X509Extension(b'subjectAltName', False, ', '.join(san_list).encode('utf8'))
- ])
- cert.sign(ca['key'], 'sha256')
-
- cert_contents = crypto.dump_certificate(crypto.FILETYPE_PEM, cert)
- cert_file = ca['dir'].join('{}.crt'.format(common_name))
- cert_file.write_binary(cert_contents)
-
- return {
- 'common_name': common_name,
- 'serial': request.param['serial'],
- 'dns': request.param['dns'],
- 'ip': request.param['ip'],
- 'uses': request.param['uses'],
- 'cert_file': cert_file,
- 'cert': cert
- }
diff --git a/roles/openshift_certificate_expiry/test/test_fakeopensslclasses.py b/roles/openshift_certificate_expiry/test/test_fakeopensslclasses.py
deleted file mode 100644
index 8a521a765..000000000
--- a/roles/openshift_certificate_expiry/test/test_fakeopensslclasses.py
+++ /dev/null
@@ -1,90 +0,0 @@
-'''
- Unit tests for the FakeOpenSSL classes
-'''
-import os
-import subprocess
-import sys
-
-import pytest
-
-MODULE_PATH = os.path.realpath(os.path.join(__file__, os.pardir, os.pardir, 'library'))
-sys.path.insert(1, MODULE_PATH)
-
-# pylint: disable=import-error,wrong-import-position,missing-docstring
-# pylint: disable=invalid-name,redefined-outer-name
-from openshift_cert_expiry import FakeOpenSSLCertificate # noqa: E402
-
-
-@pytest.fixture(scope='module')
-def fake_valid_cert(valid_cert):
- cmd = ['openssl', 'x509', '-in', str(valid_cert['cert_file']), '-text',
- '-nameopt', 'oneline']
- cert = subprocess.check_output(cmd)
- return FakeOpenSSLCertificate(cert.decode('utf8'))
-
-
-def test_not_after(valid_cert, fake_valid_cert):
- ''' Validate value returned back from get_notAfter() '''
- real_cert = valid_cert['cert']
-
- # Internal representation of pyOpenSSL is bytes, while FakeOpenSSLCertificate
- # is text, so decode the result from pyOpenSSL prior to comparing
- assert real_cert.get_notAfter().decode('utf8') == fake_valid_cert.get_notAfter()
-
-
-def test_serial(valid_cert, fake_valid_cert):
- ''' Validate value returned back form get_serialnumber() '''
- real_cert = valid_cert['cert']
- assert real_cert.get_serial_number() == fake_valid_cert.get_serial_number()
-
-
-def test_get_subject(valid_cert, fake_valid_cert):
- ''' Validate the certificate subject '''
-
- # Gather the subject components and create a list of colon separated strings.
- # Since the internal representation of pyOpenSSL uses bytes, we need to decode
- # the results before comparing.
- c_subjects = valid_cert['cert'].get_subject().get_components()
- c_subj = ', '.join(['{}:{}'.format(x.decode('utf8'), y.decode('utf8')) for x, y in c_subjects])
- f_subjects = fake_valid_cert.get_subject().get_components()
- f_subj = ', '.join(['{}:{}'.format(x, y) for x, y in f_subjects])
- assert c_subj == f_subj
-
-
-def get_san_extension(cert):
- # Internal representation of pyOpenSSL is bytes, while FakeOpenSSLCertificate
- # is text, so we need to set the value to search for accordingly.
- if isinstance(cert, FakeOpenSSLCertificate):
- san_short_name = 'subjectAltName'
- else:
- san_short_name = b'subjectAltName'
-
- for i in range(cert.get_extension_count()):
- ext = cert.get_extension(i)
- if ext.get_short_name() == san_short_name:
- # return the string representation to compare the actual SAN
- # values instead of the data types
- return str(ext)
-
- return None
-
-
-def test_subject_alt_names(valid_cert, fake_valid_cert):
- real_cert = valid_cert['cert']
-
- san = get_san_extension(real_cert)
- f_san = get_san_extension(fake_valid_cert)
-
- assert san == f_san
-
- # If there are either dns or ip sans defined, verify common_name present
- if valid_cert['ip'] or valid_cert['dns']:
- assert 'DNS:' + valid_cert['common_name'] in f_san
-
- # Verify all ip sans are present
- for ip in valid_cert['ip']:
- assert 'IP Address:' + ip in f_san
-
- # Verify all dns sans are present
- for name in valid_cert['dns']:
- assert 'DNS:' + name in f_san
diff --git a/roles/openshift_certificate_expiry/test/test_load_and_handle_cert.py b/roles/openshift_certificate_expiry/test/test_load_and_handle_cert.py
deleted file mode 100644
index 98792e2ee..000000000
--- a/roles/openshift_certificate_expiry/test/test_load_and_handle_cert.py
+++ /dev/null
@@ -1,67 +0,0 @@
-'''
- Unit tests for the load_and_handle_cert method
-'''
-import datetime
-import os
-import sys
-
-import pytest
-
-MODULE_PATH = os.path.realpath(os.path.join(__file__, os.pardir, os.pardir, 'library'))
-sys.path.insert(1, MODULE_PATH)
-
-# pylint: disable=import-error,wrong-import-position,missing-docstring
-# pylint: disable=invalid-name,redefined-outer-name
-import openshift_cert_expiry # noqa: E402
-
-# TODO: More testing on the results of the load_and_handle_cert function
-# could be implemented here as well, such as verifying subjects
-# match up.
-
-
-@pytest.fixture(params=['OpenSSLCertificate', 'FakeOpenSSLCertificate'])
-def loaded_cert(request, valid_cert):
- """ parameterized fixture to provide load_and_handle_cert results
- for both OpenSSL and FakeOpenSSL parsed certificates
- """
- now = datetime.datetime.now()
-
- openshift_cert_expiry.HAS_OPENSSL = request.param == 'OpenSSLCertificate'
-
- # valid_cert['cert_file'] is a `py.path.LocalPath` object and
- # provides a read_text() method for reading the file contents.
- cert_string = valid_cert['cert_file'].read_text('utf8')
-
- (subject,
- expiry_date,
- time_remaining,
- serial) = openshift_cert_expiry.load_and_handle_cert(cert_string, now)
-
- return {
- 'now': now,
- 'subject': subject,
- 'expiry_date': expiry_date,
- 'time_remaining': time_remaining,
- 'serial': serial,
- }
-
-
-def test_serial(loaded_cert, valid_cert):
- """Params:
-
- * `loaded_cert` comes from the `loaded_cert` fixture in this file
- * `valid_cert` comes from the 'valid_cert' fixture in conftest.py
- """
- valid_cert_serial = valid_cert['cert'].get_serial_number()
- assert loaded_cert['serial'] == valid_cert_serial
-
-
-def test_expiry(loaded_cert):
- """Params:
-
- * `loaded_cert` comes from the `loaded_cert` fixture in this file
- """
- expiry_date = loaded_cert['expiry_date']
- time_remaining = loaded_cert['time_remaining']
- now = loaded_cert['now']
- assert expiry_date == now + time_remaining