Create and renew Let's Encrypt certificats using Ansible
Update: Blog post using the "acme_certificate" module can be found here.
Ansible comes with a plugin which allows to create and renew Let's Encrypt certificates. Documentation is sparse, so I decided to post about my own Playbook.
For my case, I use the http-01 challenge, but the plugin allows to use dns-01 and tls-sni-02 challenges as well. When using the plugin, it needs to be called twice in a Playbook. The first run will create the challenge, and the second time - using the input from the first run - will answer the challenge and create the certificates.
To keep things easy, I don't combine multiple hostnames into a certificate, but create a single certificate per host. If that approach is used for a large number of hostnames, one might run into the Let's Encrypt rate limit. The challenge handling is done on the Ansible host, not on the remote host.
Also my Playbook is split into two parts. One part just controls the hostnames, and includes another file which does the actual work.
---
# Let's Encrypt certificates
- hosts: letsencrypt
become: yes
gather_facts: True
vars:
letsencrypt_account_email: "your@email.here"
# staging/testing
letsencrypt_acme_directory: "https://acme-staging.api.letsencrypt.org/directory"
# production
#letsencrypt_acme_directory: "https://acme-v01.api.letsencrypt.org/directory"
letsencrypt_acme_version: 1
letsencrypt_challenge: "http-01"
letsencrypt_agreement: "https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf"
letsencrypt_remaining_days: 45
letsencrypt_cert_dir: "{{ playbook_dir }}/letsencrypt-certificates"
tasks:
# this will delete local outdated challenge files
- name: Find outdated challenge files
local_action:
module: find
paths: "{{ playbook_dir }}/letsencrypt-challenges/"
recurse: no
file_type: file
age: "1h"
register: outdated_challenge_files
become: no
run_once: true
- name: Delete outdated challenge files
local_action:
module: file
state: absent
path: "{{ item.path }}"
with_items: "{{ outdated_challenge_files.files }}"
become: no
run_once: true
- name: Handle certificate
include: "{{ playbook_dir }}/letsencrypt.yml"
with_items:
- host1.com
- www.host1.com
- host2.com
- www.host2.com
loop_control:
loop_var: domain
handlers:
- name: reload apache2
service: name=apache2 state=restarted
This first part will delete all old challenge files (older than one hour) from the Ansible host. That's done by using the "local_action" module. It also set's a number of variables, most notably the email address, the used ACME version and the endpoint. To keep things safe, the plugin will connect to the staging endpoint by default - this results in certificates which are not recognized by the browsers, but that's fine for testing. Once a production ready certificate is desired, the endpoint (specified in "letsencrypt_acme_directory") must be adjusted.
All the other work is done in the letsencrypt.yml file, and the hostname is specified in the {{ domain }} variable.
---
# create the domain directory for the cert files on localhost
- name: create local cert directory
local_action:
module: file
path: "{{ letsencrypt_cert_dir }}/{{ domain }}"
mode: 0700
state: directory
become: no
run_once: true
- name: Create web domain directory
file:
path: "/srv/www/{{ domain }}"
state: directory
owner: root
group: root
mode: 0755
- name: Create cert domain directory
file:
path: "/etc/certs/{{ domain }}"
state: directory
owner: www-data
group: www-data
mode: 0750
- name: Create web domain acme-challenge directory
file:
path: "/srv/www/{{ domain }}/.well-known/acme-challenge"
state: directory
owner: root
group: root
mode: 0755
- name: Find outdated challenge files
find:
paths: "/srv/www/{{ domain }}/.well-known/acme-challenge"
recurse: no
file_type: file
age: "1h"
register: outdated_challenge_files
- name: Delete outdated challenge files
file:
state: absent
path: "{{ item.path }}"
with_items: "{{ outdated_challenge_files.files }}"
# generate account key, if necessary
- name: Find account.key
local_action:
module: stat
path: "{{ letsencrypt_cert_dir }}/{{ domain }}/account.key"
register: account_key
become: no
run_once: true
- name: Generate account.key
local_action: shell openssl genrsa 4096 > "{{ letsencrypt_cert_dir }}/{{ domain }}/account.key"
become: no
run_once: true
when: account_key.stat.exists == False
# generate domain key, if necessary
- name: Find domain.key
local_action:
module: stat
path: "{{ letsencrypt_cert_dir }}/{{ domain }}/domain.key"
register: domain_key
become: no
run_once: true
- name: Generate domain.key
local_action: shell openssl genrsa 4096 > "{{ letsencrypt_cert_dir }}/{{ domain }}/domain.key"
become: no
run_once: true
when: domain_key.stat.exists == False
# generate domain csr, if necessary
- name: Find domain.csr
local_action:
module: stat
path: "{{ letsencrypt_cert_dir }}/{{ domain }}/domain.csr"
register: domain_csr
become: no
run_once: true
- name: Generate domain.csr
local_action: shell openssl req -new -sha256 -key "{{ letsencrypt_cert_dir }}/{{ domain }}/domain.key" -subj "/CN={{ domain }}" > "{{ letsencrypt_cert_dir }}/{{ domain }}/domain.csr"
become: no
run_once: true
when: domain_csr.stat.exists == False
- name: Create challenge
local_action:
module: letsencrypt
account_key_src: "{{ letsencrypt_cert_dir }}/{{ domain }}/account.key"
csr: "{{ letsencrypt_cert_dir }}/{{ domain }}/domain.csr"
dest: "{{ letsencrypt_cert_dir }}/{{ domain }}/signed.crt"
chain_dest: "{{ letsencrypt_cert_dir }}/{{ domain }}/intermediate.pem"
fullchain_dest: "{{ letsencrypt_cert_dir }}/{{ domain }}/combined.pem"
account_email: "{{ letsencrypt_account_email }}"
acme_directory: "{{ letsencrypt_acme_directory }}"
acme_version: "{{ letsencrypt_acme_version }}"
challenge: "{{ letsencrypt_challenge }}"
#agreement: "{{ letsencrypt_agreement }}"
remaining_days: "{{ letsencrypt_remaining_days }}"
become: no
run_once: true
register: create_challenge
- block:
- name: Create challenge file
local_action:
module: copy
content: "{{ create_challenge.challenge_data[domain]['http-01']['resource_value'] }}"
dest: "{{ playbook_dir }}/letsencrypt-challenges/{{ create_challenge.challenge_data[domain]['http-01']['resource'] | basename }}"
become: no
run_once: true
register: create_challenge_files
- name: Copy all challenge files to webserver
copy:
src: "{{ playbook_dir }}/letsencrypt-challenges/{{ challenge | basename }}"
dest: "/srv/www/{{ domain }}/.well-known/acme-challenge/{{ challenge | basename }}"
owner: www-data
group: www-data
mode: 0555
with_fileglob:
- "{{ playbook_dir }}/letsencrypt-challenges/*"
loop_control:
loop_var: challenge
register: challenges_copied
- name: Create certificate
local_action:
module: letsencrypt
account_key_src: "{{ letsencrypt_cert_dir }}/{{ domain }}/account.key"
csr: "{{ letsencrypt_cert_dir }}/{{ domain }}/domain.csr"
dest: "{{ letsencrypt_cert_dir }}/{{ domain }}/signed.crt"
chain_dest: "{{ letsencrypt_cert_dir }}/{{ domain }}/intermediate.pem"
fullchain_dest: "{{ letsencrypt_cert_dir }}/{{ domain }}/combined.pem"
account_email: "{{ letsencrypt_account_email }}"
acme_directory: "{{ letsencrypt_acme_directory }}"
acme_version: "{{ letsencrypt_acme_version }}"
challenge: "{{ letsencrypt_challenge }}"
#agreement: "{{ letsencrypt_agreement }}"
remaining_days: "{{ letsencrypt_remaining_days }}"
data: "{{ create_challenge }}"
become: no
run_once: true
when: create_challenge is changed
register: verify_challenge
when: create_challenge is changed
- name: Copy all cert files to webserver
copy:
src: "{{ letsencrypt_cert_dir }}/{{ domain }}/{{ cert }}"
dest: "/etc/certs/{{ domain }}/{{ cert }}"
owner: root
group: root
mode: 0555
with_items:
- combined.pem
- domain.key
- intermediate.pem
- signed.crt
loop_control:
loop_var: cert
notify:
- reload apache2
- name: Delete old challenge file
file:
state: absent
path: "/srv/www/{{ domain }}/.well-known/acme-challenge/{{ create_challenge.challenge_data[domain]['http-01']['resource'] | basename }}"
The challenge file is created locally first (for debugging reasons), if that is not necessary a few more steps can be removed and the "copy" module can be used to create the challenge file directly on the remote host. Also if more than one hostname is specified, a loop over create_challenge.challenge_data is necessary to create all challenge files. In the end, the certificate files are copied to /etc/certs/{{ domain }}, but that might vary for your setup. Also the certificates are stored locally, and then copied to the webserver - in case the webserver must be setup again, a local copy of the certificates is available.
Comments
Display comments as Linear | Threaded