Create and renew Let's Encrypt certificates using Ansible

Posted by ads' corner on Sunday, 2018-05-27
Posted in [Ansible][Lets-Encrypt][Software]

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
---

# 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.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
---

# 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.


Categories: [Ansible] [Lets-Encrypt] [Software]