Skip to content

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.

Trackbacks

No Trackbacks

Comments

Display comments as Linear | Threaded

No comments

Add Comment

Enclosing asterisks marks text as bold (*word*), underscore are made via _word_.
E-Mail addresses will not be displayed and will only be used for E-Mail notifications.
To leave a comment you must approve it via e-mail, which will be sent to your address after submission.
Form options