Ansible Let's Encrypt

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}}.key

The 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'))"
fi

The 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"