Rewrote playbook as a role.

This commit is contained in:
Brian Lee 2023-05-23 08:45:42 -07:00
commit c8086b5d82
11 changed files with 336 additions and 0 deletions

0
.gitignore vendored Normal file
View File

17
LICENSE Normal file
View File

@ -0,0 +1,17 @@
MIT No Attribution License
Copyright (c) 2023 Brian Lee
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the “Software”), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

47
README.md Normal file
View File

@ -0,0 +1,47 @@
# Ansible Role: lego
This role runs [acme-lego](https://go-acme.github.io/lego/) on the localhost, such that the acme account and DNS api credentials are never communicated to the server. It also creates boilerplate nginx configuration in accordance with the Mozilla's recomended TLS configuration.
## Requirements
The `nginx_config` role which is distributed in the nginx_core collection.
```yaml
collections:
- name: nginxinc.nginx_core
```
It's not listed in the `meta` dependencies since that would run the role out of sequence.
## Role Variables
You can configure multiple providers and domains with a single ACME account.
```yaml
acme_email: acme@example.com
acme_domains:
- { domain: myhost.example.com, provider: easydns }
```
## Secrets
The api keys are sprinkled throughout the task as environment variables until I come up with a smarter way to do that.
## File Permissions
File access to the certificates and keys are controlled by way of unix permissions. The files are owned by the acme system user/group, and each service that needs to use the certificates just need to belong to the acme group.
The playbook could be used to renew certificates from multiple DNS providers. The only provider I'm using currently is Name cheap. You will need to edit the environment variables in the following files if you want to use other providers:
* main.yml
* tasks/certificates.yml
You can test your results: ssllabs.com/ssltest
## Example Playbook
```yaml
- hosts: all
roles:
- bleetube.lego
```

43
defaults/example.yml Normal file
View File

@ -0,0 +1,43 @@
---
acme_email: acme@example.com
acme_domains:
- { domain: "{{ inventory_hostname }}", provider: namecheap }
nginx_config_dhparam: /etc/ssl/dhparams.pem
nginx_config_http_template_enable: true
nginx_config_http_template:
- template_file: http/default.conf.j2
deployment_location: "/etc/nginx/acme_{{ acme_domains[0].domain }}.conf"
backup: false
config:
core:
server_name: "{{ acme_domains[0].domain }}"
ssl:
certificate: "{{ acme_path }}/certificates/{{ acme_domains[0].domain }}.crt"
certificate_key: "{{ acme_path }}/certificates/{{ acme_domains[0].domain }}.key"
trusted_certificate: "{{ acme_path }}/certificates/{{ acme_domains[0].domain }}.issuer.crt"
ciphers: ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
dhparam: "{{ nginx_config_dhparam }}"
ecdh_curve: X25519:secp521r1:secp384r1
prefer_server_ciphers: true
protocols:
- TLSv1.2
- TLSv1.3
session_cache:
shared:
name: "{{ acme_domains[0].domain }}"
size: 1M
session_tickets: false
session_timeout: 1d
ocsp: true
ocsp_cache:
name: cache
size: 64k
stapling: true
stapling_verify: true
ocsp_responder: http://r3.o.lencr.org
headers:
add_headers:
- name: Strict-Transport-Security
value: '"max-age=7776000"'
always: true

15
defaults/main.yml Normal file
View File

@ -0,0 +1,15 @@
---
acme_path: /var/acme
acme_system_user: acme
acme_system_group: acme
nginx_config_dhparam: "{{ acme_path }}/dhparams.pem"
nginx_user: nginx
#EASYDNS_TOKEN: "{{ lookup('ansible.builtin.env', 'EASYDNS_TOKEN') }}"
#EASYDNS_KEY: "{{ lookup('ansible.builtin.env', 'EASYDNS_KEY') }}"
NAMECHEAP_API_USER: "{{ lookup('ansible.builtin.env', 'NAMECHEAP_API_USER') }}"
NAMECHEAP_API_KEY: "{{ lookup('ansible.builtin.env', 'NAMECHEAP_API_KEY') }}"
# lego_path refers to the local ansible user's home directory, used in delegate_to: localhost
lego_path: ~/.secrets/acme
# This might work if the playbook is executing as the local user:
#lego_path: "{{ ansible_env.HOME }}/.secrets/acme/certificates"

2
meta/main.yml Normal file
View File

@ -0,0 +1,2 @@
---
dependencies: []

75
tasks/certificates.yml Normal file
View File

@ -0,0 +1,75 @@
---
# These tasks run in a loop for each domain so that we can check for existing certificates
# and only order new ones if they don't already exist.
- name: "Check for an existing certificate for {{ acme_domain.domain }}"
ansible.builtin.stat:
path: "{{ lego_path }}/certificates/{{ acme_domain.domain }}.crt"
register: lego_cert
delegate_to: localhost
tags: lego
- name: Instruct lego to register an account and order a new certificate if one doesn't already exist.
set_fact:
lego_command: "{{ 'renew' if lego_cert.stat.exists else 'run'}}"
delegate_to: localhost
tags: lego
- name: Order acme certificates without waiting for propogation of TXT record to all authoritative name servers.
ansible.builtin.command:
cmd: >
lego --path {{ lego_path }} --dns {{ acme_domain.provider }} --domains {{ acme_domain.domain }} --email {{ acme_email }} --dns.disable-cp --accept-tos {{ lego_command }}
register: lego_result
delegate_to: localhost
changed_when: False
ignore_errors: true
tags: lego
environment:
# EASYDNS_TOKEN: "{{ EASYDNS_TOKEN }}"
# EASYDNS_KEY: "{{ EASYDNS_KEY }}"
NAMECHEAP_API_USER: "{{ NAMECHEAP_API_USER }}"
NAMECHEAP_API_KEY: "{{ NAMECHEAP_API_KEY }}"
- name: Print lego output with dns.disable-cp
ansible.builtin.debug:
var: lego_result
delegate_to: localhost
tags: lego
# --dns.disable-cp: disables the need to wait the propagation of the TXT record to all authoritative name servers.
# I haven't yet figured out why it only works sporadically with or without this option.
- name: Retry the last command if necessary, but wait for propogation of TXT record to all authoritative name servers.
ansible.builtin.command:
cmd: >
lego --path {{ lego_path }} --dns {{ acme_domain.provider }} --domains {{ acme_domain.domain }} --email {{ acme_email }} --accept-tos {{ lego_command }}
when: lego_result.failed
register: lego_result
delegate_to: localhost
changed_when: False
tags: lego
environment:
# EASYDNS_TOKEN: "{{ EASYDNS_TOKEN }}"
# EASYDNS_KEY: "{{ EASYDNS_KEY }}"
NAMECHEAP_API_USER: "{{ NAMECHEAP_API_USER }}"
NAMECHEAP_API_KEY: "{{ NAMECHEAP_API_KEY }}"
- name: Print lego output without dns.disable-cp
ansible.builtin.debug:
var: lego_result
delegate_to: localhost
tags: lego
- name: "Copy certificate files for {{ acme_domain.domain }}."
ansible.builtin.copy:
src: "{{ lego_path }}/certificates/{{ acme_domain.domain }}.{{ file_extension }}"
dest: "{{ acme_path }}/certificates/"
owner: "{{ acme_system_user }}"
group: "{{ acme_system_group }}"
mode: '0640'
tags: lego
loop:
- crt
- key
- issuer.crt
loop_control:
loop_var: file_extension

21
tasks/dhparams.yml Normal file
View File

@ -0,0 +1,21 @@
---
- name: Check for a pre-generated dhparams file.
ansible.builtin.stat:
path: files/dhparams.pem
register: dhparams
delegate_to: localhost
tags: dhparams
- name: Use previously generated dhparams to reduce deployment time by several minutes.
ansible.builtin.copy:
src: dhparams.pem
dest: "{{ acme_path }}/dhparams.pem"
force: false
when: dhparams.stat.exists
tags: dhparams
# https://docs.ansible.com/ansible/latest/collections/community/crypto/openssl_dhparam_module.html
- name: Generate Diffie-Hellman parameters with the default size (4096 bits)
community.crypto.openssl_dhparam:
path: "{{ acme_path }}/dhparams.pem"
tags: dhparams

37
tasks/main.yml Normal file
View File

@ -0,0 +1,37 @@
---
- name: Assert all secrets have been configured.
ansible.builtin.assert:
that:
# - EASYDNS_TOKEN != ''
# - EASYDNS_KEY != ''
- NAMECHEAP_API_USER != ''
- NAMECHEAP_API_KEY != ''
fail_msg: "FAILED: Secrets have not been configured."
no_log: true
- name: Set up the acme system user and group.
import_tasks: setup-acme.yml
- name: Add nginx user to the acme group.
ansible.builtin.user:
name: "{{ nginx_user }}"
groups: "{{ acme_system_group }}"
append: true
when: acme_system_user != "root"
- name: Run lego looped task to order or renew certificates for all acme domains.
include_tasks: certificates.yml
loop: "{{ acme_domains }}"
loop_control:
loop_var: acme_domain
tags: lego
- name: Loop through the domain list (again) to configure nginx for each ACME domain
include_tasks: nginx_conf.yml
loop: "{{ acme_domains }}"
loop_control:
loop_var: acme_domain
tags: nginx
- import_tasks: dhparams.yml
tags: dhparams

44
tasks/nginx_conf.yml Normal file
View File

@ -0,0 +1,44 @@
---
- name: Configure nginx TLSv1.2 for {{ acme_domain.domain }}
ansible.builtin.import_role:
name: nginxinc.nginx_core.nginx_config
allow_duplicates: true
tags: nginx
vars:
nginx_config_http_template_enable: true
nginx_config_http_template:
- template_file: http/default.conf.j2
deployment_location: "/etc/nginx/acme_{{ acme_domain.domain }}.conf"
backup: false
config:
core:
server_name: "{{ acme_domain.domain }}"
ssl:
certificate: "{{ acme_path }}/certificates/{{ acme_domain.domain }}.crt"
certificate_key: "{{ acme_path }}/certificates/{{ acme_domain.domain }}.key"
trusted_certificate: "{{ acme_path }}/certificates/{{ acme_domain.domain }}.issuer.crt"
ciphers: ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
dhparam: "{{ nginx_config_dhparam }}"
ecdh_curve: X25519:secp521r1:secp384r1
prefer_server_ciphers: true
protocols:
- TLSv1.2
- TLSv1.3
session_cache:
shared:
name: "{{ acme_domain.domain }}"
size: 1M
session_tickets: false
session_timeout: 1d
ocsp: true
ocsp_cache:
name: cache
size: 64k
stapling: true
stapling_verify: true
ocsp_responder: http://r3.o.lencr.org
headers:
add_headers:
- name: Strict-Transport-Security
value: '"max-age=7776000"'
always: true

35
tasks/setup-acme.yml Normal file
View File

@ -0,0 +1,35 @@
---
- name: Get nologin path for acme user
ansible.builtin.find:
paths:
- /bin
- /sbin
- /usr/bin
- /usr/sbin
patterns: nologin
register: nologin_bin
- name: Create the acme group
ansible.builtin.group:
name: "{{ acme_system_group }}"
state: present
system: true
when: acme_system_group != "root"
- name: Create the acme system user
ansible.builtin.user:
name: "{{ acme_system_user }}"
groups: "{{ acme_system_group }}"
shell: "{{ nologin_bin.files[0].path }}"
system: true
create_home: false
home: "{{ acme_path }}"
when: acme_system_user != "root"
- name: Ensure acme_path exists.
ansible.builtin.file:
path: "{{ acme_path }}"
owner: "{{ acme_system_user }}"
group: "{{ acme_system_group }}"
state: directory
mode: '0750'