Skip to content

Certificate expiration date in Ansible

In one of my Ansible Playbooks I'm updating Let's Encrypt certificates. Because the Playbook is rather long, I want to make sure that Ansible is not spending cycles on a certificate which is not about to expire. Hence I wrote a small filter plugin, which receives the certificate path and the number of days the certificate must be still valid.

This plugin is used to filter out any "good" certificate which does not need to be updated.

 

Let's start with the plugin itself:

 

#!/usr/bin/env python

from ansible.errors import AnsibleError
import os
import sys
from ansible.module_utils._text import to_native
from ansible.module_utils._text import to_text
import subprocess


class FilterModule(object):
    ''' Query filter '''

    def filters(self):
        return {
            'check_cert_age': self.check_cert_age
        }


    def check_cert_age(self, cert_path, remaining_days):
        '''Query a certificate (1st parameter) and check if the remaining valid
           time is at least as many days as specified in the 2nd parameter
           Returns 0 if the cert is valid for the specified number of days
           Returns 0 if the cert is not valid for that time
        '''

        # first check if the cert exists
        if (os.path.exists(cert_path) is False):
            raise AnsibleError('Not found: %s' % to_native(cert_path))
        if (os.access(cert_path, os.R_OK) is False):
            raise AnsibleError('No access: %s' % to_native(cert_path))

        # there is a slight chance that the file goes away between the time
        # existence and access is checked, but that's ok
        # raising the proper error in the majority of cases is more important

        remaining_time = int(remaining_days) * 86400
        d0 = open(os.devnull, 'w')
        result = subprocess.call(['/usr/bin/openssl', 'x509', '-in', cert_path, '-noout', '-checkend', str(remaining_time)], stdout = d0, stderr = d0)
        if (result != 0):
            # make sure we return '1' for an expiring certificate, or for every error
            result = 1

        return(to_text(str(result)))

 

The function name is check_cert_age(), and the resulting file (check_cert_age.py) must be placed in the "filter_plugins" folder in the Playbook directory. If the directory does not exist, create it. Ansible will load all plugins from this directory.

 

How can this plugin be used?

In my case, there is a list of websites defined in a separate file websites.yml:

web.site1:
    enabled: True
    letsencrypt: True

web.site2:
    enabled: True
    letsencrypt: True

The list is rather long, but the above example is enough to give an idea how it works. This file is read into a $website variable in my Playbook:

- name: Read websites
  include_vars:
    file: "{{ playbook_dir }}/websites.yml"
    name: websites

 

I also define a variable which holds the number days the certificate must still be valid: $letsencrypt_remaining_days. It's set to "45" in my case - half the time Let's Encrypt uses as certificate expiration. If you have a look at the actual code, you see that openssl uses number of seconds, not number of days. But the plugin takes care of that.

Plus I need a number of additional temporary variables:

- name: Predefined variables
  set_fact:
    websites_tmp: { }
    letsencrypt_remaining_days: 45
    letsencrypt_cert_dir: "/path/to/certificates"

 

The certificate magic happens in the next step:

- name: Extract websites which need certificate renewal
  set_fact:
    websites_tmp: "{{ websites_tmp | combine( { website.key: websites[website.key] } ) }}"
  with_dict: "{{ websites }}"
  loop_control:
    loop_var: website
    label: "{{ website.key }}"
  when:
    - website.value.enabled|default(True) == True
    - website.value.letsencrypt|default(True) == True
    - (letsencrypt_cert_dir + '/' + website.key + '/signed.crt')|check_cert_age(letsencrypt_remaining_days) == "1"


- name: Copy extracted websites
  set_fact:
    websites: "{{ websites_tmp }}"

 

This part loops over all entries in $websites, and only extracts websites which meet 3 conditions:

  1. Website is enabled (easy way to disable websites, without actually removing the entry)
  2. Website is using Let's Encrypt
  3. The existing certificate is "old enough" and meets the $letsencrypt_remaining_days condition

Finally the remaining data is copied back from $websites_tmp into $websites.

Now I can loop over the actual Playbook doing all the work:

- name: Handle websites
  include: "certificates.yml"
  with_dict: "{{ websites }}"
  loop_control:
    loop_var: website
    label: "{{ website.key }}"

 

Trackbacks

No Trackbacks

Comments

Display comments as Linear | Threaded

No comments

Add Comment

Enclosing asterisks marks text as bold (*word*), underscore are made via _word_.
E-Mail addresses will not be displayed and will only be used for E-Mail notifications.
To leave a comment you must approve it via e-mail, which will be sent to your address after submission.
Form options