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:
- Website is enabled (easy way to disable websites, without actually removing the entry)
- Website is using Let's Encrypt
- 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 }}"
Comments
Display comments as Linear | Threaded