Ansible and LetsEncrypt

Overview

More on the host configuration

In my last post on this topic, I had left a to-do to get LetsEncrypt setup and running on the host.

After spending quite a bit of time on the effort and learning what tools were at my disposal, I figured out (pretty late in the process) that.. my initial approach had a considerable flaw:

No automatic renewals.

I think I've read about running Ansible in more of a "continuous" mode on the endpoint, and in this scenario, I can see my initial method working - the playbook could run on a scheduled task and re-acquire and install the certificates on a periodic basis... but that's generally not how I run my stuff, and not how I intend to use this playbook.

Initial attempt - using community.crypto.acme_certificate

When I go looking for setting up LetsEncrypt with Ansible on Debian hosts, it doesn't take long to find community.crypto.acme_certificates. I really enjoy and reference the Ansible documentation frequently - I find that it's well documented, and comes with great examples.

Except this module :P

And maybe that has more to do with "I don't know what I'm doing" with setting up LetsEncrypt from scratch, because I am used to Certbot. So, digging around a bit more, I find a good post on Digital Ocean that details the similar process, but without use of the community collection above. Ok.. at least I've got a starting point!

Inventory

I've created an inventory file with a whole bunch of additional variables included. I've included them as "global" variables - any public host may require this same setup. By moving them around the inventory file, it would be easy to change their scoping.

 1# inventory.yml
 2---
 3all:
 4    hosts:
 5        letest:
 6            ansible_host: letsencrypttest.westus2.cloudapp.azure.com
 7            ansible_user: azureuser
 8            become: root
 9            ansible_ssh_private_key_file: letsencrypttest_key.pem
10            domain_name: "{{ ansible_host }}"
11
12    vars:
13        acme_terms_agreed: yes
14        letsencrypt_dir: /etc/letsencrypt
15        letsencrypt_keys_dir: /etc/letsencrypt/keys
16        letsencrypt_csrs_dir: /etc/letsencrypt/csrs
17        letsencrypt_certs_dir: /etc/letsencrypt/certs
18        letsencrypt_account_key: /etc/letsencrypt/account/account.key
19        acme_challenge_type: http-01
20        acme_directory: https://acme-v02.api.letsencrypt.org/directory
21        acme_version: 2
22        acme_email: [email protected]

Most of the variables in the host: letest: stanza are Ansible specific (except domain_name). The variables for the LetsEncrypt process are in the vars: stanza.

Some initial setup

Getting through the first few steps:

 1# le.yml
 2- name: Lets Encrypt test setup
 3  hosts: letest
 4
 5  tasks:
 6  - name: Ensure some basic tools are installed.
 7    ansible.builtin.apt:
 8      name: aptitude,vim,ntp,ca-certificates,htop,rsync,open-vm-tools,gpg,sudo,iproute2,iptables,git,libnss-libvirt
 9      state: latest
10  - name: Ensure nginx is installed
11    ansible.builtin.apt:
12      name: nginx
13      state: latest
14
15  - name: "Create required directories in /etc/letsencrypt"
16    ansible.builtin.file:
17      path: "/etc/letsencrypt/{{ item }}"
18      state: directory
19      owner: root
20      group: root
21      mode: u=rwx,g=x,o=x
22    with_items:
23    - account
24    - certs
25    - csrs
26    - keys
27
28  - name: Ensure openssl requirements for lets encrypt are satisfied # Require openssl, PyOpenSSL
29    ansible.builtin.apt:
30      name: openssl
31      state: latest
32
33  - name: Ensure python-openssl requirements are satisified (for a python2 install)
34    ansible.builtin.apt:
35      name: python-openssl
36      state: latest
37    when: ansible_python_version is version("3", "lt")
38  - name: Ensure python-openssl requirements are satisified (for a python3 install)
39    ansible.builtin.apt:
40      name: python3-openssl
41      state: latest
42    when: ansible_python_version is version("3", "ge")

There's a bit to unpack here:

  • The first two tasks install some common tools I use.
  • The third task creates a directory structure to store the LetsEncrypt files I need. This uses the default file module, and loops over a set of subdirectories I want made.
  • Step 4 ensures openssl is installed. This could be wrapped up with steps 1 and 2, but in truth, I copied step 1 and 2 from another playbook .. so separate tasks they remain. It'd be faster to run the playbook if these three tasks were merged.
  • Step 5 and 6 installs the python module, PyOpenSSL, depending on what version of Python is installed on the host. I can use the host variable ansible_python_version which is retrieved from the target host to help determine this
    • I'm not .. excited.. about how to check versions here. I would really like to match a major version, but I couldn't figure out how...

Following the Digital Ocean article, I see a lot of references to shell commands, something I tend to try to avoid especially if there's an Ansible module for the same function. I feel that using the supplied modules are more platform agnostic - and may as well on Redhat systems as much as Debian systems.

Create an account key

So in step 2 of the Digital Ocean article, there's a call to create an account key with a shell call to openssl. I've replaced this with:

1# le.yml
2  - name: Generate a Let's Encrypt account key
3    community.crypto.openssl_privatekey:
4      path: "{{ letsencrypt_account_key }}"
5      type: RSA
6      size: 4096

This does the same thing, and affords me some flexibility in terms of the parameters for the key. And it's idempotent too without any work on my part!

Private key for a domain

Step 3 has me generating a private key, and generating a CSR for the domain. In my case, I'll use more modules from the community.crypto collection:

 1# le.yml
 2  - name: Generate a private key for {{ domain_name }}
 3    community.crypto.openssl_privatekey:
 4      path: "{{ letsencrypt_keys_dir }}/{{ domain_name }}.key"
 5      type: RSA
 6      size: 4096
 7
 8  - name: Generate an OpenSSL Certificate Signing Request
 9    community.crypto.openssl_csr:
10      path: "{{ letsencrypt_csrs_dir }}/{{ domain_name }}.csr"
11      privatekey_path: "{{ letsencrypt_keys_dir }}/{{ domain_name }}.key"
12      common_name: "{{ domain_name }}"

The key and CSR files are stored in the directory tree that we've defined and setup in the inventory and early steps of the playbook. So far so good.

Start ACME validation

Step 4 has us starting the ACME validation process. The Digital Ocean article uses the letsencrypt module, which is now the community.crypto module - so this is almost a copy/pate:

 1# le.yml
 2  - name: Create a challenge for {{ domain_name }} using a account key from a variable.
 3    community.crypto.acme_certificate:
 4      terms_agreed: "{{ acme_terms_agreed }}"
 5      acme_version: "{{ acme_version }}"
 6      acme_directory: "{{ acme_directory }}"
 7      account_key_src: "{{ letsencrypt_account_key }}"
 8      csr: "{{ letsencrypt_csrs_dir }}/{{ domain_name }}.csr"
 9      dest: "{{ letsencrypt_certs_dir }}/{{ domain_name }}.crt"
10    register: letsencrypt_challenge

Put the files on the webhost

The output from this module contains the challenge response that I need to put in /.well-known/acme-challenge/ directory on my web server, and is stored in the letsencrypt_challenge variable. I can then write that content out to files with the following:

 1# le.yml
 2  - name: Create the required .well-known structure.
 3    ansible.builtin.file:
 4      dest: /var/www/html/.well-known/acme-challenge
 5      mode: 0755
 6      state: directory
 7
 8  - name: Copy the challenge data to the web sever.
 9    ansible.builtin.copy:
10      dest: /var/www/html/{{ letsencrypt_challenge['challenge_data'][domain_name]['http-01']['resource'] }}
11      content: "{{ letsencrypt_challenge['challenge_data'][domain_name]['http-01']['resource_value'] }}"
12      mode: 0644
13    when: letsencrypt_challenge is changed and domain_name in letsencrypt_challenge['challenge_data']

Get certificate from LetsEncrypt

Finally, it's time to get the issued certificates from LetsEncrypt:

 1# le.yml
 2
 3  - name: Create a challenge for {{ domain_name }} using a account key file.
 4    community.crypto.acme_certificate:
 5      acme_directory: "{{ acme_directory }}"
 6      acme_version: "{{ acme_version }}"
 7      account_key_src: "{{ letsencrypt_account_key }}"
 8      account_email: "{{ acme_email }}"
 9      terms_agreed: "{{ acme_terms_agreed }}"
10      challenge: "{{ acme_challenge_type }}"
11      src: "{{ letsencrypt_csrs_dir }}/{{ domain_name }}.csr"
12      cert: "{{ letsencrypt_certs_dir }}/{{ domain_name }}.crt"
13      fullchain_dest: "{{ letsencrypt_certs_dir }}/fullchain_{{ domain_name }}.crt"
14      remaining_days: 14
15      chain_dest: "{{ letsencrypt_certs_dir }}/{{ domain_name }}-intermediate.crt"
16      data: "{{ letsencrypt_challenge }}"

Done?

Once all complete, the /etc/letsencrypt and /var/www/html directories on the host look like the following:

 1├── letsencrypt -> /etc/letsencrypt/
 2│   ├── account
 3│   │   └── account.key
 4│   ├── certs
 5│   │   ├── fullchain_letsencrypttest.westus2.cloudapp.azure.com.crt
 6│   │   ├── letsencrypttest.westus2.cloudapp.azure.com-intermediate.crt
 7│   │   └── letsencrypttest.westus2.cloudapp.azure.com.crt
 8│   ├── csrs
 9│   │   └── letsencrypttest.westus2.cloudapp.azure.com.csr
10│   └── keys
11│       └── letsencrypttest.westus2.cloudapp.azure.com.key
12└── www -> /var/www/
13    └── html
14        ├── .well-known
15        │   └── acme-challenge
16        │       └── _pc_WT1nA...1efwlv0
17        └── index.nginx-debian.html
18

And this is the point that I realised ... I don't know how I will handle automatic renewals.

Now, obviously there are a few more things I'd like to do with this bit of playbook - support SAN's, multiple domain names per host, support a non-standard file path for the .well-known/ directory, update the nginx configuration and so on... but I more or less stopped at this point.

Round 2, now with certbot

I've made good use of Certbot at the console many times before, so wrapping a role around Certbot seems like something that would be pretty easy to do. Or.. I could just use someone else's wrapper.

I found that the installation with snap failes, but the role documentation is pretty clear it's experimental, so I wasn't overly surprised.

This .. wound up being considerably simpler, and came along with automatic renewal too.

 1# le-certbot.yml
 2---
 3- name: Lets Encrypt test setup - with certbot
 4  hosts: letest
 5
 6  vars:
 7    #certbot_install_method: snap
 8    certbot_create_if_missing: true
 9    certbot_admin_email: [email protected]
10    certbot_certs:
11      - domains:
12          - "{{ domain_name }}"
13  roles:
14    - geerlingguy.certbot

and.. I'm done. In fact, I can go through and strip out a lot of extra stuff from inventory that were there to support the directories needed for each manual step of the certificate request process.

Done .. I think?

I'll have to see how this changes the configuration in nginx, but it seems that I'm left with a set of valid certificates, and an unmodified set of server configurations. This is nice, because now I can deploy a "final" set of server configurations without having to be concerned that certbot is going to overwrite them - or that I'm going to overwrite something that certbot has done already.

Posts in this Series