diff options
Diffstat (limited to 'roles/openshift_certificate_expiry')
15 files changed, 2459 insertions, 0 deletions
diff --git a/roles/openshift_certificate_expiry/README.md b/roles/openshift_certificate_expiry/README.md new file mode 100644 index 000000000..f19a421cb --- /dev/null +++ b/roles/openshift_certificate_expiry/README.md @@ -0,0 +1,508 @@ +# OpenShift Certificate Expiration Checker + +OpenShift certificate expiration checking. Be warned of certificates +expiring within a configurable window of days, and notified of +certificates which have already expired. Certificates examined +include: + +* Master/Node Service Certificates +* Router/Registry Service Certificates from etcd secrets +* Master/Node/Router/Registry/Admin `kubeconfig`s +* Etcd certificates (including embedded) + +This role pairs well with the redeploy certificates playbook: + +* [Redeploying Certificates Documentation](https://docs.openshift.com/container-platform/latest/install_config/redeploying_certificates.html) + +Just like the redeploying certificates playbook, this role is intended +to be used with an inventory that is representative of the +cluster. For best results run `ansible-playbook` with the `-v` option. + + +# Role Variables + +Core variables in this role: + +| Name | Default value | Description | +|-------------------------------------------------------|--------------------------------|-----------------------------------------------------------------------| +| `openshift_certificate_expiry_config_base` | `/etc/origin` | Base openshift config directory | +| `openshift_certificate_expiry_warning_days` | `30` | Flag certificates which will expire in this many days from now | +| `openshift_certificate_expiry_show_all` | `no` | Include healthy (non-expired and non-warning) certificates in results | + +Optional report/result saving variables in this role: + +| Name | Default value | Description | +|-------------------------------------------------------|--------------------------------|-----------------------------------------------------------------------| +| `openshift_certificate_expiry_generate_html_report` | `no` | Generate an HTML report of the expiry check results | +| `openshift_certificate_expiry_html_report_path` | `/tmp/cert-expiry-report.html` | The full path to save the HTML report as | +| `openshift_certificate_expiry_save_json_results` | `no` | Save expiry check results as a json file | +| `openshift_certificate_expiry_json_results_path` | `/tmp/cert-expiry-report.json` | The full path to save the json report as | + + +# Using this Role + +How to use the Certificate Expiration Checking Role. + +> **NOTE:** In the examples shown below, ensure you change **HOSTS** +> to the path of your inventory file. + +## Run with ansible-playbook + +Run one of the example playbooks using an inventory file +representative of your existing cluster. Some example playbooks are +included in this role, or you can [read on below for more examples](#more-example-playbooks) +to help you craft you own. + +``` +$ ansible-playbook -v -i HOSTS playbooks/byo/openshift-checks/certificate_expiry/easy-mode.yaml +``` + +Using the `easy-mode.yaml` playbook will produce: + +* Reports including healthy and unhealthy hosts +* A JSON report in `/tmp/` +* A stylized HTML report in `/tmp/` + + +> **Note:** If you are running from an RPM install use +> `/usr/share/ansible/openshift-ansible/playbooks/byo/openshift-checks/certificate_expiry/easy-mode.yaml` +> instead + +## Run from a container + +The example playbooks that use this role are packaged in the +[container image for openshift-ansible](../../README_CONTAINER_IMAGE.md), so you +can run any of them by setting the `PLAYBOOK_FILE` environment variable when +running an openshift-ansible container. + +There are several [examples](../../examples/README.md) in the `examples` directory that run certificate check playbooks from a container running on OpenShift. + +## More Example Playbooks + +> **Note:** These Playbooks are available to run directly out of the +> [/playbooks/byo/openshift-checks/certificate_expiry/](../../playbooks/byo/openshift-checks/certificate_expiry/) directory. + +### Default behavior + +This playbook just invokes the certificate expiration check role with default options: + + +```yaml +--- +- name: Check cert expirys + hosts: nodes:masters:etcd + become: yes + gather_facts: no + roles: + - role: openshift_certificate_expiry +``` + +**From git:** +``` +$ ansible-playbook -v -i HOSTS playbooks/byo/openshift-checks/certificate_expiry/default.yaml +``` +**From openshift-ansible-playbooks rpm:** +``` +$ ansible-playbook -v -i HOSTS /usr/share/ansible/openshift-ansible/playbooks/byo/openshift-checks/certificate_expiry/default.yaml +``` + +> [View This Playbook](../../playbooks/byo/openshift-checks/certificate_expiry/default.yaml) + +### Easy mode + +This example playbook is great if you're just wanting to **try the +role out**. This playbook enables HTML and JSON reports. All +certificates (healthy or not) are included in the results: + +```yaml +--- +- name: Check cert expirys + hosts: nodes:masters:etcd + become: yes + gather_facts: no + vars: + openshift_certificate_expiry_save_json_results: yes + openshift_certificate_expiry_generate_html_report: yes + openshift_certificate_expiry_show_all: yes + roles: + - role: openshift_certificate_expiry +``` + +**From git:** +``` +$ ansible-playbook -v -i HOSTS playbooks/byo/openshift-checks/certificate_expiry/easy-mode.yaml +``` +**From openshift-ansible-playbooks rpm:** +``` +$ ansible-playbook -v -i HOSTS /usr/share/ansible/openshift-ansible/playbooks/byo/openshift-checks/certificate_expiry/easy-mode.yaml +``` + +> [View This Playbook](../../playbooks/byo/openshift-checks/certificate_expiry/easy-mode.yaml) + +### Easy mode and upload reports to masters + +This example builds on top of [easy-mode.yaml](#easy-mode) and additionally +uploads a copy of the generated reports to the masters, with a timestamp in the +file names. + +This is specially useful when the playbook runs from within a container, because +the reports are generated inside the container and we need a way to access them. +Uploading a copy of the reports to the masters is one way to make it easy to +access them. Alternatively you can use the +[role variables](#role-variables) that control the path of the generated reports +to point to a container volume (see the [playbook with custom paths](#generate-html-and-json-reports-in-a-custom-path) for an example). + +With the container use case in mind, this playbook allows control over some +options via environment variables: + + - `CERT_EXPIRY_WARN_DAYS`: sets `openshift_certificate_expiry_warning_days`, overriding the role's default. + - `COPY_TO_PATH`: path in the masters where generated reports are uploaded. + +```yaml +--- +- name: Generate certificate expiration reports + hosts: nodes:masters:etcd + gather_facts: no + vars: + openshift_certificate_expiry_save_json_results: yes + openshift_certificate_expiry_generate_html_report: yes + openshift_certificate_expiry_show_all: yes + openshift_certificate_expiry_warning_days: "{{ lookup('env', 'CERT_EXPIRY_WARN_DAYS') | default('45', true) }}" + roles: + - role: openshift_certificate_expiry + +- name: Upload reports to master + hosts: masters + gather_facts: no + vars: + destination_path: "{{ lookup('env', 'COPY_TO_PATH') | default('/etc/origin/certificate_expiration_report', true) }}" + timestamp: "{{ lookup('pipe', 'date +%Y%m%d') }}" + tasks: + - name: Create directory in masters + file: + path: "{{ destination_path }}" + state: directory + - name: Copy the reports to the masters + copy: + dest: "{{ destination_path }}/{{ timestamp }}-{{ item }}" + src: "/tmp/{{ item }}" + with_items: + - "cert-expiry-report.html" + - "cert-expiry-report.json" +``` + +**From git:** +``` +$ ansible-playbook -v -i HOSTS playbooks/byo/openshift-checks/certificate_expiry/easy-mode-upload.yaml +``` +**From openshift-ansible-playbooks rpm:** +``` +$ ansible-playbook -v -i HOSTS /usr/share/ansible/openshift-ansible/playbooks/byo/openshift-checks/certificate_expiry/easy-mode-upload.yaml +``` + +> [View This Playbook](../../playbooks/byo/openshift-checks/certificate_expiry/easy-mode-upload.yaml) + +### Generate HTML and JSON artifacts in their default paths + +```yaml +--- +- name: Check cert expirys + hosts: nodes:masters:etcd + become: yes + gather_facts: no + vars: + openshift_certificate_expiry_generate_html_report: yes + openshift_certificate_expiry_save_json_results: yes + roles: + - role: openshift_certificate_expiry +``` + +**From git:** +``` +$ ansible-playbook -v -i HOSTS playbooks/byo/openshift-checks/certificate_expiry/html_and_json_default_paths.yaml +``` +**From openshift-ansible-playbooks rpm:** +``` +$ ansible-playbook -v -i HOSTS /usr/share/ansible/openshift-ansible/playbooks/byo/openshift-checks/certificate_expiry/html_and_json_default_paths.yaml +``` + +> [View This Playbook](../../playbooks/byo/openshift-checks/certificate_expiry/html_and_json_default_paths.yaml) + +### Generate HTML and JSON reports in a custom path + +This example customizes the report generation path to point to a specific path (`/var/lib/certcheck`) and uses a date timestamp for the generated files. This allows you to reuse a certain location to keep multiple copies of the reports. + +```yaml +--- +- name: Check cert expirys + hosts: nodes:masters:etcd + become: yes + gather_facts: no + vars: + openshift_certificate_expiry_generate_html_report: yes + openshift_certificate_expiry_save_json_results: yes + timestamp: "{{ lookup('pipe', 'date +%Y%m%d') }}" + openshift_certificate_expiry_html_report_path: "/var/lib/certcheck/{{ timestamp }}-cert-expiry-report.html" + openshift_certificate_expiry_json_results_path: "/var/lib/certcheck/{{ timestamp }}-cert-expiry-report.json" + roles: + - role: openshift_certificate_expiry +``` + +**From git:** +``` +$ ansible-playbook -v -i HOSTS playbooks/byo/openshift-checks/certificate_expiry/html_and_json_timestamp.yaml +``` +**From openshift-ansible-playbooks rpm:** +``` +$ ansible-playbook -v -i HOSTS /usr/share/ansible/openshift-ansible/playbooks/byo/openshift-checks/certificate_expiry/html_and_json_timestamp.yaml +``` + +> [View This Playbook](../../playbooks/byo/openshift-checks/certificate_expiry/html_and_json_timestamp.yaml) + +### Long warning window + +Change the expiration warning window to 1500 days (good for testing +the module out): + +```yaml +--- +- name: Check cert expirys + hosts: nodes:masters:etcd + become: yes + gather_facts: no + vars: + openshift_certificate_expiry_warning_days: 1500 + roles: + - role: openshift_certificate_expiry +``` + +**From git:** +``` +$ ansible-playbook -v -i HOSTS playbooks/byo/openshift-checks/certificate_expiry/longer_warning_period.yaml +``` +**From openshift-ansible-playbooks rpm:** +``` +$ ansible-playbook -v -i HOSTS /usr/share/ansible/openshift-ansible/playbooks/byo/openshift-checks/certificate_expiry/longer_warning_period.yaml +``` + +> [View This Playbook](../../playbooks/byo/openshift-checks/certificate_expiry/longer_warning_period.yaml) + +### Long warning window and JSON report + +Change the expiration warning window to 1500 days (good for testing +the module out) and save the results as a JSON file: + +```yaml +--- +- name: Check cert expirys + hosts: nodes:masters:etcd + become: yes + gather_facts: no + vars: + openshift_certificate_expiry_warning_days: 1500 + openshift_certificate_expiry_save_json_results: yes + roles: + - role: openshift_certificate_expiry +``` + +**From git:** +``` +$ ansible-playbook -v -i HOSTS playbooks/byo/openshift-checks/certificate_expiry/longer-warning-period-json-results.yaml +``` +**From openshift-ansible-playbooks rpm:** +``` +$ ansible-playbook -v -i HOSTS /usr/share/ansible/openshift-ansible/playbooks/byo/openshift-checks/certificate_expiry/longer-warning-period-json-results.yaml +``` + +> [View This Playbook](../../playbooks/byo/openshift-checks/certificate_expiry/longer-warning-period-json-results.yaml) + + + +# Output Formats + +As noted above there are two ways to format your check report. In +`json` format for machine parsing, or as a stylized `html` page for +easy skimming. These options are shown below. + +## HTML Report + +![HTML Expiration Report](examples/cert-expiry-report-html.png) + +For an example of the HTML report you can browse, save +[examples/cert-expiry-report.html](examples/cert-expiry-report.html) +and then open the file in your browser. + + +## JSON Report + +There are two top-level keys in the saved JSON results, `data` and +`summary`. + +The `data` key is a hash where the keys are the names of each host +examined and the values are the check results for the certificates +identified on each respective host. + +The `summary` key is a hash that summarizes the total number of +certificates: + +* examined on the entire cluster +* OK +* expiring within the configured warning window +* already expired + +For an example of the full JSON report, see [examples/cert-expiry-report.json](examples/cert-expiry-report.json). + +The example below is abbreviated to save space. + +```json +{ + "data": { + "m01.example.com": { + "etcd": [ + { + "cert_cn": "CN:172.30.0.1, DNS:kubernetes, DNS:kubernetes.default, DNS:kubernetes.default.svc,...", + "days_remaining": 722, + "expiry": "2019-01-09 17:00:03", + "health": "warning", + "path": "/etc/origin/master/etcd.server.crt", + "serial": 7, + "serial_hex": "0x7" + } + ], + "kubeconfigs": [ + { + "cert_cn": "O:system:nodes, CN:system:node:m01.example.com", + "days_remaining": 722, + "expiry": "2019-01-09 17:03:28", + "health": "warning", + "path": "/etc/origin/node/system:node:m01.example.com.kubeconfig", + "serial": 11, + "serial_hex": "0xb" + } + ], + "meta": { + "checked_at_time": "2017-01-17 10:36:25.230920", + "show_all": "True", + "warn_before_date": "2021-02-25 10:36:25.230920", + "warning_days": 1500 + }, + "ocp_certs": [ + { + "cert_cn": "CN:172.30.0.1, DNS:kubernetes, DNS:kubernetes.default, DNS:kubernetes.default.svc,...", + "days_remaining": 722, + "expiry": "2019-01-09 17:00:02", + "health": "warning", + "path": "/etc/origin/master/master.server.crt", + "serial": 4, + "serial_hex": "0x4" + } + ], + "registry": [ + { + "cert_cn": "CN:172.30.242.251, DNS:docker-registry-default.router.default.svc.cluster.local,...", + "days_remaining": 722, + "expiry": "2019-01-09 17:05:54", + "health": "warning", + "path": "/api/v1/namespaces/default/secrets/registry-certificates", + "serial": 13, + "serial_hex": "0xd" + } + ], + "router": [ + { + "cert_cn": "CN:router.default.svc, DNS:router.default.svc, DNS:router.default.svc.cluster.local", + "days_remaining": 722, + "expiry": "2019-01-09 17:05:46", + "health": "warning", + "path": "/api/v1/namespaces/default/secrets/router-certs", + "serial": 5050662940948454653, + "serial_hex": "0x46178f2f6b765cfd" + } + ] + }, + "n01.example.com": { + "etcd": [], + "kubeconfigs": [ + { + "cert_cn": "O:system:nodes, CN:system:node:n01.example.com", + "days_remaining": 722, + "expiry": "2019-01-09 17:03:28", + "health": "warning", + "path": "/etc/origin/node/system:node:n01.example.com.kubeconfig", + "serial": 11, + "serial_hex": "0xb" + } + ], + "meta": { + "checked_at_time": "2017-01-17 10:36:25.217103", + "show_all": "True", + "warn_before_date": "2021-02-25 10:36:25.217103", + "warning_days": 1500 + }, + "ocp_certs": [ + { + "cert_cn": "CN:192.168.124.11, DNS:n01.example.com, DNS:192.168.124.11, IP Address:192.168.124.11", + "days_remaining": 722, + "expiry": "2019-01-09 17:03:29", + "health": "warning", + "path": "/etc/origin/node/server.crt", + "serial": 12, + "serial_hex": "0xc" + } + ], + "registry": [], + "router": [] + } + }, + "summary": { + "expired": 0, + "ok": 3, + "total": 15, + "warning": 12 + } +} +``` + +The `summary` from the json data can be easily checked for +warnings/expirations using a variety of command-line tools. + +For exampe, using `grep` we can look for the word `summary` and print +out the 2 lines **after** the match (`-A2`): + +``` +$ grep -A2 summary /tmp/cert-expiry-report.json + "summary": { + "warning": 16, + "expired": 0 +``` + +If available, the [jq](https://stedolan.github.io/jq/) tool can also +be used to pick out specific values. Example 1 and 2 below show how to +select just one value, either `warning` or `expired`. Example 3 shows +how to select both values at once: + +``` +$ jq '.summary.warning' /tmp/cert-expiry-report.json +16 +$ jq '.summary.expired' /tmp/cert-expiry-report.json +0 +$ jq '.summary.warning,.summary.expired' /tmp/cert-expiry-report.json +16 +0 +``` + + +# Requirements +* None + + +# Dependencies +* None + + +# License +Apache License, Version 2.0 + + +# Author Information +Tim Bielawa (tbielawa@redhat.com) diff --git a/roles/openshift_certificate_expiry/defaults/main.yml b/roles/openshift_certificate_expiry/defaults/main.yml new file mode 100644 index 000000000..6d7b19298 --- /dev/null +++ b/roles/openshift_certificate_expiry/defaults/main.yml @@ -0,0 +1,8 @@ +--- +openshift_certificate_expiry_config_base: "/etc/origin" +openshift_certificate_expiry_warning_days: 30 +openshift_certificate_expiry_show_all: no +openshift_certificate_expiry_generate_html_report: no +openshift_certificate_expiry_html_report_path: "/tmp/cert-expiry-report.html" +openshift_certificate_expiry_save_json_results: no +openshift_certificate_expiry_json_results_path: "/tmp/cert-expiry-report.json" diff --git a/roles/openshift_certificate_expiry/examples/cert-expiry-report-html.png b/roles/openshift_certificate_expiry/examples/cert-expiry-report-html.png Binary files differnew file mode 100644 index 000000000..799131659 --- /dev/null +++ b/roles/openshift_certificate_expiry/examples/cert-expiry-report-html.png diff --git a/roles/openshift_certificate_expiry/examples/cert-expiry-report.html b/roles/openshift_certificate_expiry/examples/cert-expiry-report.html new file mode 100644 index 000000000..db03a5060 --- /dev/null +++ b/roles/openshift_certificate_expiry/examples/cert-expiry-report.html @@ -0,0 +1,396 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8" /> + <title>OCP Certificate Expiry Report</title> + <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" /> + <link href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:300,400,700" rel="stylesheet" /> + <style type="text/css"> + body { + font-family: 'Source Sans Pro', sans-serif; + margin-left: 50px; + margin-right: 50px; + margin-bottom: 20px; + padding-top: 70px; + } + table { + border-collapse: collapse; + margin-bottom: 20px; + } + table, th, td { + border: 1px solid black; + } + th, td { + padding: 5px; + } + .cert-kind { + margin-top: 5px; + margin-bottom: 5px; + } + footer { + font-size: small; + text-align: center; + } + tr.odd { + background-color: #f2f2f2; + } + </style> + </head> + <body> + <nav class="navbar navbar-default navbar-fixed-top"> + <div class="container-fluid"> + <div class="navbar-header"> + <a class="navbar-brand" href="#">OCP Certificate Expiry Report</a> + </div> + <div class="collapse navbar-collapse"> + <p class="navbar-text navbar-right"> + <button> + <a href="https://docs.openshift.com/container-platform/latest/install_config/redeploying_certificates.html" + target="_blank" + class="navbar-link"> + <i class="glyphicon glyphicon-book"></i> Redeploying Certificates + </a> + </button> + <button> + <a href="https://github.com/openshift/openshift-ansible/tree/master/roles/openshift_certificate_expiry" + target="_blank" + class="navbar-link"> + <i class="glyphicon glyphicon-book"></i> Expiry Role Documentation + </a> + </button> + </p> + </div> + </div> + </nav> + + <h1>m01.example.com</h1> + + <p> + Checked 12 total certificates. Expired/Warning/OK: 0/10/2. Warning window: 1500 days + </p> + <ul> + <li><b>Expirations checked at:</b> 2017-01-17 10:36:25.230920</li> + <li><b>Warn after date:</b> 2021-02-25 10:36:25.230920</li> + </ul> + + <table border="1" width="100%"> + <tr> + <th colspan="7" style="text-align:center"><h2 class="cert-kind">ocp_certs</h2></th> + </tr> + + <tr> + <th> </th> + <th style="width:33%">Certificate Common/Alt Name(s)</th> + <td>Serial</th> + <th>Health</th> + <th>Days Remaining</th> + <th>Expiration Date</th> + <th>Path</th> + </tr> + + + <tr class="odd"> + <td style="text-align:center"><i class="glyphicon glyphicon-alert"></i></td> + <td style="width:33%">CN:172.30.0.1, DNS:kubernetes, DNS:kubernetes.default, DNS:kubernetes.default.svc, DNS:kubernetes.default.svc.cluster.local, DNS:m01.example.com, DNS:openshift, DNS:openshift.default, DNS:openshift.default.svc, DNS:openshift.default.svc.cluster.local, DNS:172.30.0.1, DNS:192.168.124.148, IP Address:172.30.0.1, IP Address:192.168.124.148</td> + <td><code>int(4)/hex(0x4)</code></td> + <td>warning</td> + <td>722</td> + <td>2019-01-09 17:00:02</td> + <td>/etc/origin/master/master.server.crt</td> + </tr> + + <tr class="even"> + <td style="text-align:center"><i class="glyphicon glyphicon-alert"></i></td> + <td style="width:33%">CN:192.168.124.148, DNS:m01.example.com, DNS:192.168.124.148, IP Address:192.168.124.148</td> + <td><code>int(12)/hex(0xc)</code></td> + <td>warning</td> + <td>722</td> + <td>2019-01-09 17:03:29</td> + <td>/etc/origin/node/server.crt</td> + </tr> + + <tr class="odd"> + <td style="text-align:center"><i class="glyphicon glyphicon-ok"></i></td> + <td style="width:33%">CN:openshift-signer@1483981200</td> + <td><code>int(1)/hex(0x1)</code></td> + <td>ok</td> + <td>1817</td> + <td>2022-01-08 17:00:01</td> + <td>/etc/origin/master/ca.crt</td> + </tr> + + <tr class="even"> + <td style="text-align:center"><i class="glyphicon glyphicon-ok"></i></td> + <td style="width:33%">CN:openshift-signer@1483981200</td> + <td><code>int(1)/hex(0x1)</code></td> + <td>ok</td> + <td>1817</td> + <td>2022-01-08 17:00:01</td> + <td>/etc/origin/node/ca.crt</td> + </tr> + <tr> + <th colspan="7" style="text-align:center"><h2 class="cert-kind">etcd</h2></th> + </tr> + + <tr> + <th> </th> + <th style="width:33%">Certificate Common/Alt Name(s)</th> + <td>Serial</th> + <th>Health</th> + <th>Days Remaining</th> + <th>Expiration Date</th> + <th>Path</th> + </tr> + + + <tr class="odd"> + <td style="text-align:center"><i class="glyphicon glyphicon-alert"></i></td> + <td style="width:33%">CN:172.30.0.1, DNS:kubernetes, DNS:kubernetes.default, DNS:kubernetes.default.svc, DNS:kubernetes.default.svc.cluster.local, DNS:m01.example.com, DNS:openshift, DNS:openshift.default, DNS:openshift.default.svc, DNS:openshift.default.svc.cluster.local, DNS:172.30.0.1, DNS:192.168.124.148, IP Address:172.30.0.1, IP Address:192.168.124.148</td> + <td><code>int(7)/hex(0x7)</code></td> + <td>warning</td> + <td>722</td> + <td>2019-01-09 17:00:03</td> + <td>/etc/origin/master/etcd.server.crt</td> + </tr> + <tr> + <th colspan="7" style="text-align:center"><h2 class="cert-kind">kubeconfigs</h2></th> + </tr> + + <tr> + <th> </th> + <th style="width:33%">Certificate Common/Alt Name(s)</th> + <td>Serial</th> + <th>Health</th> + <th>Days Remaining</th> + <th>Expiration Date</th> + <th>Path</th> + </tr> + + + <tr class="odd"> + <td style="text-align:center"><i class="glyphicon glyphicon-alert"></i></td> + <td style="width:33%">O:system:nodes, CN:system:node:m01.example.com</td> + <td><code>int(11)/hex(0xb)</code></td> + <td>warning</td> + <td>722</td> + <td>2019-01-09 17:03:28</td> + <td>/etc/origin/node/system:node:m01.example.com.kubeconfig</td> + </tr> + + <tr class="even"> + <td style="text-align:center"><i class="glyphicon glyphicon-alert"></i></td> + <td style="width:33%">O:system:cluster-admins, CN:system:admin</td> + <td><code>int(8)/hex(0x8)</code></td> + <td>warning</td> + <td>722</td> + <td>2019-01-09 17:00:03</td> + <td>/etc/origin/master/admin.kubeconfig</td> + </tr> + + <tr class="odd"> + <td style="text-align:center"><i class="glyphicon glyphicon-alert"></i></td> + <td style="width:33%">O:system:masters, CN:system:openshift-master</td> + <td><code>int(3)/hex(0x3)</code></td> + <td>warning</td> + <td>722</td> + <td>2019-01-09 17:00:02</td> + <td>/etc/origin/master/openshift-master.kubeconfig</td> + </tr> + + <tr class="even"> + <td style="text-align:center"><i class="glyphicon glyphicon-alert"></i></td> + <td style="width:33%">O:system:routers, CN:system:openshift-router</td> + <td><code>int(9)/hex(0x9)</code></td> + <td>warning</td> + <td>722</td> + <td>2019-01-09 17:00:03</td> + <td>/etc/origin/master/openshift-router.kubeconfig</td> + </tr> + + <tr class="odd"> + <td style="text-align:center"><i class="glyphicon glyphicon-alert"></i></td> + <td style="width:33%">O:system:registries, CN:system:openshift-registry</td> + <td><code>int(10)/hex(0xa)</code></td> + <td>warning</td> + <td>722</td> + <td>2019-01-09 17:00:03</td> + <td>/etc/origin/master/openshift-registry.kubeconfig</td> + </tr> + <tr> + <th colspan="7" style="text-align:center"><h2 class="cert-kind">router</h2></th> + </tr> + + <tr> + <th> </th> + <th style="width:33%">Certificate Common/Alt Name(s)</th> + <td>Serial</th> + <th>Health</th> + <th>Days Remaining</th> + <th>Expiration Date</th> + <th>Path</th> + </tr> + + + <tr class="odd"> + <td style="text-align:center"><i class="glyphicon glyphicon-alert"></i></td> + <td style="width:33%">CN:router.default.svc, DNS:router.default.svc, DNS:router.default.svc.cluster.local</td> + <td><code>int(5050662940948454653)/hex(0x46178f2f6b765cfd)</code></td> + <td>warning</td> + <td>722</td> + <td>2019-01-09 17:05:46</td> + <td>/api/v1/namespaces/default/secrets/router-certs</td> + </tr> + <tr> + <th colspan="7" style="text-align:center"><h2 class="cert-kind">registry</h2></th> + </tr> + + <tr> + <th> </th> + <th style="width:33%">Certificate Common/Alt Name(s)</th> + <td>Serial</th> + <th>Health</th> + <th>Days Remaining</th> + <th>Expiration Date</th> + <th>Path</th> + </tr> + + + <tr class="odd"> + <td style="text-align:center"><i class="glyphicon glyphicon-alert"></i></td> + <td style="width:33%">CN:172.30.242.251, DNS:docker-registry-default.router.default.svc.cluster.local, DNS:docker-registry.default.svc.cluster.local, DNS:172.30.242.251, IP Address:172.30.242.251</td> + <td><code>int(13)/hex(0xd)</code></td> + <td>warning</td> + <td>722</td> + <td>2019-01-09 17:05:54</td> + <td>/api/v1/namespaces/default/secrets/registry-certificates</td> + </tr> + </table> + <hr /> + <h1>n01.example.com</h1> + + <p> + Checked 3 total certificates. Expired/Warning/OK: 0/2/1. Warning window: 1500 days + </p> + <ul> + <li><b>Expirations checked at:</b> 2017-01-17 10:36:25.217103</li> + <li><b>Warn after date:</b> 2021-02-25 10:36:25.217103</li> + </ul> + + <table border="1" width="100%"> + <tr> + <th colspan="7" style="text-align:center"><h2 class="cert-kind">ocp_certs</h2></th> + </tr> + + <tr> + <th> </th> + <th style="width:33%">Certificate Common/Alt Name(s)</th> + <td>Serial</th> + <th>Health</th> + <th>Days Remaining</th> + <th>Expiration Date</th> + <th>Path</th> + </tr> + + + <tr class="odd"> + <td style="text-align:center"><i class="glyphicon glyphicon-alert"></i></td> + <td style="width:33%">CN:192.168.124.11, DNS:n01.example.com, DNS:192.168.124.11, IP Address:192.168.124.11</td> + <td><code>int(12)/hex(0xc)</code></td> + <td>warning</td> + <td>722</td> + <td>2019-01-09 17:03:29</td> + <td>/etc/origin/node/server.crt</td> + </tr> + + <tr class="even"> + <td style="text-align:center"><i class="glyphicon glyphicon-ok"></i></td> + <td style="width:33%">CN:openshift-signer@1483981200</td> + <td><code>int(1)/hex(0x1)</code></td> + <td>ok</td> + <td>1817</td> + <td>2022-01-08 17:00:01</td> + <td>/etc/origin/node/ca.crt</td> + </tr> + <tr> + <th colspan="7" style="text-align:center"><h2 class="cert-kind">etcd</h2></th> + </tr> + + <tr> + <th> </th> + <th style="width:33%">Certificate Common/Alt Name(s)</th> + <td>Serial</th> + <th>Health</th> + <th>Days Remaining</th> + <th>Expiration Date</th> + <th>Path</th> + </tr> + + <tr> + <th colspan="7" style="text-align:center"><h2 class="cert-kind">kubeconfigs</h2></th> + </tr> + + <tr> + <th> </th> + <th style="width:33%">Certificate Common/Alt Name(s)</th> + <td>Serial</th> + <th>Health</th> + <th>Days Remaining</th> + <th>Expiration Date</th> + <th>Path</th> + </tr> + + + <tr class="odd"> + <td style="text-align:center"><i class="glyphicon glyphicon-alert"></i></td> + <td style="width:33%">O:system:nodes, CN:system:node:n01.example.com</td> + <td><code>int(11)/hex(0xb)</code></td> + <td>warning</td> + <td>722</td> + <td>2019-01-09 17:03:28</td> + <td>/etc/origin/node/system:node:n01.example.com.kubeconfig</td> + </tr> + <tr> + <th colspan="7" style="text-align:center"><h2 class="cert-kind">router</h2></th> + </tr> + + <tr> + <th> </th> + <th style="width:33%">Certificate Common/Alt Name(s)</th> + <td>Serial</th> + <th>Health</th> + <th>Days Remaining</th> + <th>Expiration Date</th> + <th>Path</th> + </tr> + + <tr> + <th colspan="7" style="text-align:center"><h2 class="cert-kind">registry</h2></th> + </tr> + + <tr> + <th> </th> + <th style="width:33%">Certificate Common/Alt Name(s)</th> + <td>Serial</th> + <th>Health</th> + <th>Days Remaining</th> + <th>Expiration Date</th> + <th>Path</th> + </tr> + + </table> + <hr /> + + <footer> + <p> + Expiration report generated by + the <a href="https://github.com/openshift/openshift-ansible" + target="_blank">openshift-ansible</a> + <a href="https://github.com/openshift/openshift-ansible/tree/master/roles/openshift_certificate_expiry" + target="_blank">certificate expiry</a> role. + </p> + <p> + Status icons from bootstrap/glyphicon + </p> + </footer> + </body> +</html> diff --git a/roles/openshift_certificate_expiry/examples/cert-expiry-report.json b/roles/openshift_certificate_expiry/examples/cert-expiry-report.json new file mode 100644 index 000000000..8206e2842 --- /dev/null +++ b/roles/openshift_certificate_expiry/examples/cert-expiry-report.json @@ -0,0 +1,178 @@ +{ + "data": { + "m01.example.com": { + "etcd": [ + { + "cert_cn": "CN:172.30.0.1, DNS:kubernetes, DNS:kubernetes.default, DNS:kubernetes.default.svc, DNS:kubernetes.default.svc.cluster.local, DNS:m01.example.com, DNS:openshift, DNS:openshift.default, DNS:openshift.default.svc, DNS:openshift.default.svc.cluster.local, DNS:172.30.0.1, DNS:192.168.124.148, IP Address:172.30.0.1, IP Address:192.168.124.148", + "days_remaining": 722, + "expiry": "2019-01-09 17:00:03", + "health": "warning", + "path": "/etc/origin/master/etcd.server.crt", + "serial": 7, + "serial_hex": "0x7" + } + ], + "kubeconfigs": [ + { + "cert_cn": "O:system:nodes, CN:system:node:m01.example.com", + "days_remaining": 722, + "expiry": "2019-01-09 17:03:28", + "health": "warning", + "path": "/etc/origin/node/system:node:m01.example.com.kubeconfig", + "serial": 11, + "serial_hex": "0xb" + }, + { + "cert_cn": "O:system:cluster-admins, CN:system:admin", + "days_remaining": 722, + "expiry": "2019-01-09 17:00:03", + "health": "warning", + "path": "/etc/origin/master/admin.kubeconfig", + "serial": 8, + "serial_hex": "0x8" + }, + { + "cert_cn": "O:system:masters, CN:system:openshift-master", + "days_remaining": 722, + "expiry": "2019-01-09 17:00:02", + "health": "warning", + "path": "/etc/origin/master/openshift-master.kubeconfig", + "serial": 3, + "serial_hex": "0x3" + }, + { + "cert_cn": "O:system:routers, CN:system:openshift-router", + "days_remaining": 722, + "expiry": "2019-01-09 17:00:03", + "health": "warning", + "path": "/etc/origin/master/openshift-router.kubeconfig", + "serial": 9, + "serial_hex": "0x9" + }, + { + "cert_cn": "O:system:registries, CN:system:openshift-registry", + "days_remaining": 722, + "expiry": "2019-01-09 17:00:03", + "health": "warning", + "path": "/etc/origin/master/openshift-registry.kubeconfig", + "serial": 10, + "serial_hex": "0xa" + } + ], + "meta": { + "checked_at_time": "2017-01-17 10:36:25.230920", + "show_all": "True", + "warn_before_date": "2021-02-25 10:36:25.230920", + "warning_days": 1500 + }, + "ocp_certs": [ + { + "cert_cn": "CN:172.30.0.1, DNS:kubernetes, DNS:kubernetes.default, DNS:kubernetes.default.svc, DNS:kubernetes.default.svc.cluster.local, DNS:m01.example.com, DNS:openshift, DNS:openshift.default, DNS:openshift.default.svc, DNS:openshift.default.svc.cluster.local, DNS:172.30.0.1, DNS:192.168.124.148, IP Address:172.30.0.1, IP Address:192.168.124.148", + "days_remaining": 722, + "expiry": "2019-01-09 17:00:02", + "health": "warning", + "path": "/etc/origin/master/master.server.crt", + "serial": 4, + "serial_hex": "0x4" + }, + { + "cert_cn": "CN:192.168.124.148, DNS:m01.example.com, DNS:192.168.124.148, IP Address:192.168.124.148", + "days_remaining": 722, + "expiry": "2019-01-09 17:03:29", + "health": "warning", + "path": "/etc/origin/node/server.crt", + "serial": 12, + "serial_hex": "0xc" + }, + { + "cert_cn": "CN:openshift-signer@1483981200", + "days_remaining": 1817, + "expiry": "2022-01-08 17:00:01", + "health": "ok", + "path": "/etc/origin/master/ca.crt", + "serial": 1, + "serial_hex": "0x1" + }, + { + "cert_cn": "CN:openshift-signer@1483981200", + "days_remaining": 1817, + "expiry": "2022-01-08 17:00:01", + "health": "ok", + "path": "/etc/origin/node/ca.crt", + "serial": 1, + "serial_hex": "0x1" + } + ], + "registry": [ + { + "cert_cn": "CN:172.30.242.251, DNS:docker-registry-default.router.default.svc.cluster.local, DNS:docker-registry.default.svc.cluster.local, DNS:172.30.242.251, IP Address:172.30.242.251", + "days_remaining": 722, + "expiry": "2019-01-09 17:05:54", + "health": "warning", + "path": "/api/v1/namespaces/default/secrets/registry-certificates", + "serial": 13, + "serial_hex": "0xd" + } + ], + "router": [ + { + "cert_cn": "CN:router.default.svc, DNS:router.default.svc, DNS:router.default.svc.cluster.local", + "days_remaining": 722, + "expiry": "2019-01-09 17:05:46", + "health": "warning", + "path": "/api/v1/namespaces/default/secrets/router-certs", + "serial": 5050662940948454653, + "serial_hex": "0x46178f2f6b765cfd" + } + ] + }, + "n01.example.com": { + "etcd": [], + "kubeconfigs": [ + { + "cert_cn": "O:system:nodes, CN:system:node:n01.example.com", + "days_remaining": 722, + "expiry": "2019-01-09 17:03:28", + "health": "warning", + "path": "/etc/origin/node/system:node:n01.example.com.kubeconfig", + "serial": 11, + "serial_hex": "0xb" + } + ], + "meta": { + "checked_at_time": "2017-01-17 10:36:25.217103", + "show_all": "True", + "warn_before_date": "2021-02-25 10:36:25.217103", + "warning_days": 1500 + }, + "ocp_certs": [ + { + "cert_cn": "CN:192.168.124.11, DNS:n01.example.com, DNS:192.168.124.11, IP Address:192.168.124.11", + "days_remaining": 722, + "expiry": "2019-01-09 17:03:29", + "health": "warning", + "path": "/etc/origin/node/server.crt", + "serial": 12, + "serial_hex": "0xc" + }, + { + "cert_cn": "CN:openshift-signer@1483981200", + "days_remaining": 1817, + "expiry": "2022-01-08 17:00:01", + "health": "ok", + "path": "/etc/origin/node/ca.crt", + "serial": 1, + "serial_hex": "0x1" + } + ], + "registry": [], + "router": [] + } + }, + "summary": { + "expired": 0, + "ok": 3, + "total": 15, + "warning": 12 + } +} diff --git a/roles/openshift_certificate_expiry/examples/playbooks b/roles/openshift_certificate_expiry/examples/playbooks new file mode 120000 index 000000000..586afb0d5 --- /dev/null +++ b/roles/openshift_certificate_expiry/examples/playbooks @@ -0,0 +1 @@ +../../../playbooks/certificate_expiry
\ No newline at end of file diff --git a/roles/openshift_certificate_expiry/filter_plugins/oo_cert_expiry.py b/roles/openshift_certificate_expiry/filter_plugins/oo_cert_expiry.py new file mode 100644 index 000000000..a2bc9ecdb --- /dev/null +++ b/roles/openshift_certificate_expiry/filter_plugins/oo_cert_expiry.py @@ -0,0 +1,67 @@ +#!/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 + become: no + 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 new file mode 100644 index 000000000..e355266b0 --- /dev/null +++ b/roles/openshift_certificate_expiry/library/openshift_cert_expiry.py @@ -0,0 +1,839 @@ +#!/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/meta/main.yml b/roles/openshift_certificate_expiry/meta/main.yml new file mode 100644 index 000000000..c13b29ba5 --- /dev/null +++ b/roles/openshift_certificate_expiry/meta/main.yml @@ -0,0 +1,16 @@ +--- +galaxy_info: + author: Tim Bielawa + description: OpenShift Certificate Expiry Checker + company: Red Hat, Inc. + license: Apache License, Version 2.0 + min_ansible_version: 2.1 + version: 1.0 + platforms: + - name: EL + versions: + - 7 + categories: + - cloud + - system +dependencies: [] diff --git a/roles/openshift_certificate_expiry/tasks/main.yml b/roles/openshift_certificate_expiry/tasks/main.yml new file mode 100644 index 000000000..b5234bd1e --- /dev/null +++ b/roles/openshift_certificate_expiry/tasks/main.yml @@ -0,0 +1,30 @@ +--- +- name: Check cert expirys on host + openshift_cert_expiry: + warning_days: "{{ openshift_certificate_expiry_warning_days|int }}" + config_base: "{{ openshift_certificate_expiry_config_base }}" + show_all: "{{ openshift_certificate_expiry_show_all|bool }}" + register: check_results + +- name: Generate expiration report HTML + become: no + run_once: yes + template: + src: cert-expiry-table.html.j2 + dest: "{{ openshift_certificate_expiry_html_report_path }}" + delegate_to: localhost + when: openshift_certificate_expiry_generate_html_report|bool + +- name: Generate the result JSON string + run_once: yes + set_fact: 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 + become: no + run_once: yes + template: + src: save_json_results.j2 + dest: "{{ openshift_certificate_expiry_json_results_path }}" + delegate_to: localhost + when: openshift_certificate_expiry_save_json_results|bool diff --git a/roles/openshift_certificate_expiry/templates/cert-expiry-table.html.j2 b/roles/openshift_certificate_expiry/templates/cert-expiry-table.html.j2 new file mode 100644 index 000000000..1d4bb24e9 --- /dev/null +++ b/roles/openshift_certificate_expiry/templates/cert-expiry-table.html.j2 @@ -0,0 +1,139 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8" /> + <title>OCP Certificate Expiry Report</title> + {# For fancy icons and a pleasing font #} + <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" /> + <link href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:300,400,700" rel="stylesheet" /> + <style type="text/css"> + body { + font-family: 'Source Sans Pro', sans-serif; + margin-left: 50px; + margin-right: 50px; + margin-bottom: 20px; + padding-top: 70px; + } + table { + border-collapse: collapse; + margin-bottom: 20px; + } + table, th, td { + border: 1px solid black; + } + th, td { + padding: 5px; + } + .cert-kind { + margin-top: 5px; + margin-bottom: 5px; + } + footer { + font-size: small; + text-align: center; + } + tr.odd { + background-color: #f2f2f2; + } + </style> + </head> + <body> + <nav class="navbar navbar-default navbar-fixed-top"> + <div class="container-fluid"> + <div class="navbar-header"> + <a class="navbar-brand" href="#">OCP Certificate Expiry Report</a> + </div> + <div class="collapse navbar-collapse"> + <p class="navbar-text navbar-right"> + <button> + <a href="https://docs.openshift.com/container-platform/latest/install_config/redeploying_certificates.html" + target="_blank" + class="navbar-link"> + <i class="glyphicon glyphicon-book"></i> Redeploying Certificates + </a> + </button> + <button> + <a href="https://github.com/openshift/openshift-ansible/tree/master/roles/openshift_certificate_expiry" + target="_blank" + class="navbar-link"> + <i class="glyphicon glyphicon-book"></i> Expiry Role Documentation + </a> + </button> + </p> + </div> + </div> + </nav> + + {# Each host has a header and table to itself #} + {% for host in play_hosts %} + <h1>{{ host }}</h1> + + <p> + {{ hostvars[host].check_results.msg }} + </p> + <ul> + <li><b>Expirations checked at:</b> {{ hostvars[host].check_results.check_results.meta.checked_at_time }}</li> + <li><b>Warn after date:</b> {{ hostvars[host].check_results.check_results.meta.warn_before_date }}</li> + </ul> + + <table border="1" width="100%"> + {# These are hard-coded right now, but should be grabbed dynamically from the registered results #} + {%- for kind in ['ocp_certs', 'etcd', 'kubeconfigs', 'router', 'registry'] -%} + <tr> + <th colspan="7" style="text-align:center"><h2 class="cert-kind">{{ kind }}</h2></th> + </tr> + + <tr> + <th> </th> + <th style="width:33%">Certificate Common/Alt Name(s)</th> + <td>Serial</th> + <th>Health</th> + <th>Days Remaining</th> + <th>Expiration Date</th> + <th>Path</th> + </tr> + + {# A row for each certificate examined #} + {%- for v in hostvars[host].check_results.check_results[kind] -%} + + {# Let's add some flair and show status visually with fancy icons #} + {% if v.health == 'ok' %} + {% set health_icon = 'glyphicon glyphicon-ok' %} + {% elif v.health == 'warning' %} + {% set health_icon = 'glyphicon glyphicon-alert' %} + {% else %} + {% set health_icon = 'glyphicon glyphicon-remove' %} + {% endif %} + + <tr class="{{ loop.cycle('odd', 'even') }}"> + <td style="text-align:center"><i class="{{ health_icon }}"></i></td> + <td style="width:33%">{{ v.cert_cn }}</td> + <td><code>int({{ v.serial }})/hex({{ v.serial_hex }})</code></td> + <td>{{ v.health }}</td> + <td>{{ v.days_remaining }}</td> + <td>{{ v.expiry }}</td> + <td>{{ v.path }}</td> + </tr> + {% endfor %} + {# end row generation per cert of this type #} + {% endfor %} + {# end generation for each kind of cert block #} + </table> + <hr /> + {% endfor %} + {# end section generation for each host #} + + <footer> + <p> + Expiration report generated by + the <a href="https://github.com/openshift/openshift-ansible" + target="_blank">openshift-ansible</a> + <a href="https://github.com/openshift/openshift-ansible/tree/master/roles/openshift_certificate_expiry" + target="_blank">certificate expiry</a> role. + </p> + <p> + Status icons from bootstrap/glyphicon + </p> + </footer> + </body> +</html> diff --git a/roles/openshift_certificate_expiry/templates/save_json_results.j2 b/roles/openshift_certificate_expiry/templates/save_json_results.j2 new file mode 100644 index 000000000..c1173d9ea --- /dev/null +++ b/roles/openshift_certificate_expiry/templates/save_json_results.j2 @@ -0,0 +1 @@ +{{ json_result_string | to_nice_json(indent=2)}} diff --git a/roles/openshift_certificate_expiry/test/conftest.py b/roles/openshift_certificate_expiry/test/conftest.py new file mode 100644 index 000000000..df948fff0 --- /dev/null +++ b/roles/openshift_certificate_expiry/test/conftest.py @@ -0,0 +1,119 @@ +# 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 new file mode 100644 index 000000000..8a521a765 --- /dev/null +++ b/roles/openshift_certificate_expiry/test/test_fakeopensslclasses.py @@ -0,0 +1,90 @@ +''' + 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 new file mode 100644 index 000000000..98792e2ee --- /dev/null +++ b/roles/openshift_certificate_expiry/test/test_load_and_handle_cert.py @@ -0,0 +1,67 @@ +''' + 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 |