I used to have acme.sh handling much of the routine work of updating SSL certificates, but as I've moved automation into ansible/AWX, that has included let's encrypt certificates.
This post provides an example of updating certificates using acme-dns with DNS hosted through google cloud platform. It's designed to run in AWX with the appropriate GCP and machine credentials attached to the job. This is a rather technical post providing details without a lot of explanation, but it illustrates the process and was crafted with a preference for ansible modules over raw shell commands for things such as openssl, with the obvious exception of the check_ssl.sh script.
For starters, I declare some host variables. It depends on where the host is imported to AWX as to how this is accomplished. As one example, I have some proxmox virtual machines imported with an inventory script. As part of this import, it offers host variables defined in the notes field, so hosts can include notes such as:
{
"Notice": "values should be changed to suit your needs"
"domain": "FQDN for the cert",
"nginx": true,
"gcp": { "zone" : "zone name", "domain": "DNS name of the zone" }
}The following is the let's encrypt playbook:
---
- hosts: all
tasks:
- name: Check whether the certificate needs to be renewed
shell: bash {{role_path}}/files/check_ssl.sh {{domain}}:{{ssl_port |default("443")}}
register: renew
changed_when: false
- name: End the play for this host if the certificate does not need to be renewed
when:
- renew.stdout|default(omit) == ""
- not force|default(false)|bool
block:
- debug:
msg: ended play early because the certificate is not expiring yet
- meta: end_host
- name: Generate keys via ACME DNS
delegate_to: localhost
import_role:
name: acme-gDNS
- name: Install the new certificate
when: hostvars[inventory_hostname].changed
become: true
block:
- name: Copy the files
copy:
src : "{{ sslkey.src }}"
dest : "{{ sslkey.dest }}"
owner: "{{ssl_owner |default('root')}}"
group: "{{ssl_group |default('root')}}"
mode : "{{ssl_mode |default('0640')}}"
loop_control:
loop_var: sslkey
loop:
- src : "{{hostvars[inventory_hostname].cert}}"
dest: "{{ssl_dir |default('/etc/ssl/LE/')}}{{domain}}.crt"
- src : "{{hostvars[inventory_hostname].fcrt}}"
dest: "{{ssl_dir |default('/etc/ssl/LE/')}}{{domain}}.pem"
- src : "{{hostvars[inventory_hostname].dkey}}"
dest: "{{ssl_dir |default('/etc/ssl/LE/')}}{{domain}}.key"
- name: Reload nginx
when: nginx|default(false)|bool
systemd:
name : nginx
state: reloaded
- name: Reload apache2
when: apache2|default(false)|bool
systemd:
name : apache2
state: reloaded
- name: Reload UNMS
when: unms|default(false)|bool
shell: >-
/root/unms_install.sh --update
--ssl-cert-dir {{ssl_dir}}
--ssl-cert {{domain}}.pem
--ssl-cert-key {{domain}}.keyThe script below requires openssl, which means the AWX task runner needs to be customised to include it, but that's a topic for a different post. This is the check_ssl.sh script called by the playbook.
#!/bin/bash
TARGET=$1
if [[ ! $TARGET =~ ":" ]]; then
TARGET=$TARGET:443
fi
DDAYS=7
DAYS=${2:-$DDAYS}
expirationdate=$(date -d "$(: | openssl s_client -connect $TARGET -servername $TARGET 2>/dev/null \
| openssl x509 -text 2>/dev/null \
| grep 'Not After' \
| awk '{print $4,$5,$7}')" '+%s') inXdays=$(($(date +%s) + (86400*$DAYS)))
if [ $inXdays -gt $expirationdate ]; then
# Either the certificate is nearing expiration, or does not exist.
echo "SSL on $TARGET needs renew (expires $(date -d @$expirationdate '+%Y-%m-%d'))"
fiThe following variables are declared for the acme-gDNS role:
acme_url: https://acme-v02.api.letsencrypt.org/directory
dest_dir: "{{role_path}}/files/certs/{{domain}}"
# Required variables:
# acme_account_key: openssl genrsa 4096
# acme_email :
And this is the tasks of the acme-gDNS role:
- name: Use testing certificates
when: testing|default(false)|bool
set_fact:
acme_url: https://acme-staging-v02.api.letsencrypt.org/directory
- name: Request wildcard certificate
when: wildcard|default(false)|bool
set_fact:
san:
- "DNS:{{domain}}"
- "DNS:*.{{domain}}"
- name: Check for working directory
file:
path : "{{role_path}}/files/{{fpath}}"
mode : 0755
state: directory
loop_control:
loop_var : fpath
loop:
- certs
- certs/{{domain}}
- name: Make sure account exists and has given contacts. We agree to TOS.
acme_account:
acme_version: 2
acme_directory: "{{acme_url}}"
account_key_content: "{{lookup('file', acme_account_key)}}"
state: present
terms_agreed: yes
contact:
- mailto:{{acme_email}}
- name: Create private key for domain
openssl_privatekey:
path: "{{dest_dir}}/{{domain}}.key"
- name: Create CSR
openssl_csr:
CN: "{{domain}}"
E : "{{acme_email}}"
path: "{{dest_dir}}/{{domain}}.csr"
privatekey_path: "{{dest_dir}}/{{domain}}.key"
# A bit messy, but necesary for wildcards
subjectAltName: "{{san |default([ 'DNS:'+domain ])}}"
- name: Create a challenge for the new request
register: req
acme_certificate:
account_key_content: "{{lookup('file', acme_account_key)}}"
account_email: "{{acme_email}}"
src: "{{dest_dir}}/{{domain}}.csr"
# Signed reply. Not necessary at this stage, but they're required values
cert: "{{dest_dir}}/{{domain}}.crt"
fullchain: "{{dest_dir}}/{{domain}}_f.crt"
chain: "{{dest_dir}}/{{domain}}_i.crt"
# Protocol:
challenge: dns-01
acme_directory: "{{acme_url}}"
acme_version: 2
# Renew if the certificate is at least 30 days old
remaining_days: 60
terms_agreed: yes
- name: Continue to process new certificate
when:
- req is changed
- req.challenge_data[domain] is defined
block:
- name: create the challenge record
loop_control:
loop_var : dns_challenge
loop: "{{req.challenge_data_dns |dict2items}}"
gcp_dns_resource_record_set:
managed_zone:
name: "{{gcp.zone}}"
dnsName: "{{gcp.domain}}"
name: "{{dns_challenge.key}}."
type: TXT
ttl: 60
target: "{{ dns_challenge.value }}"
state: present
- name: Wait for the challenge record to become active
pause:
minutes: 2
- name: Validate the challenge and retrieve the certificates
register: res
acme_certificate:
account_key_content: "{{lookup('file', acme_account_key)}}"
account_email: "{{acme_email}}"
src: "{{dest_dir}}/{{domain}}.csr"
cert: "{{dest_dir}}/{{domain}}.crt"
fullchain: "{{dest_dir}}/{{domain}}_f.crt"
chain: "{{dest_dir}}/{{domain}}_i.crt"
challenge: dns-01
acme_directory: "{{ acme_url }}"
acme_version: 2
remaining_days: 60
data: "{{ req }}"
- name: Remove the challenge record
loop_control:
loop_var : dns_challenge
loop: "{{req.challenge_data_dns |dict2items}}"
gcp_dns_resource_record_set:
managed_zone:
name: "{{gcp.zone}}"
dnsName: "{{gcp.domain}}"
name: "{{dns_challenge.key}}."
type: TXT
state: absent
- name: export certificates
# So it can be accessed after we exit the role
set_fact:
changed: "{{ req.changed and res.changed }}"
cert: "{{dest_dir}}/{{domain}}.crt"
fcrt: "{{dest_dir}}/{{domain}}_f.crt"
icrt: "{{dest_dir}}/{{domain}}_i.crt"
dkey: "{{dest_dir}}/{{domain}}.key"