commit c8086b5d8208a4bd491075766c899d707739228d Author: Brian Lee Date: Tue May 23 08:45:42 2023 -0700 Rewrote playbook as a role. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4baac1e --- /dev/null +++ b/LICENSE @@ -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. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..2bb04de --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/defaults/example.yml b/defaults/example.yml new file mode 100644 index 0000000..2f73939 --- /dev/null +++ b/defaults/example.yml @@ -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 \ No newline at end of file diff --git a/defaults/main.yml b/defaults/main.yml new file mode 100644 index 0000000..8e02f9b --- /dev/null +++ b/defaults/main.yml @@ -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" \ No newline at end of file diff --git a/meta/main.yml b/meta/main.yml new file mode 100644 index 0000000..6099501 --- /dev/null +++ b/meta/main.yml @@ -0,0 +1,2 @@ +--- +dependencies: [] \ No newline at end of file diff --git a/tasks/certificates.yml b/tasks/certificates.yml new file mode 100644 index 0000000..9ed4240 --- /dev/null +++ b/tasks/certificates.yml @@ -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 diff --git a/tasks/dhparams.yml b/tasks/dhparams.yml new file mode 100644 index 0000000..7fd5162 --- /dev/null +++ b/tasks/dhparams.yml @@ -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 diff --git a/tasks/main.yml b/tasks/main.yml new file mode 100644 index 0000000..c778652 --- /dev/null +++ b/tasks/main.yml @@ -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 diff --git a/tasks/nginx_conf.yml b/tasks/nginx_conf.yml new file mode 100644 index 0000000..2e13e7c --- /dev/null +++ b/tasks/nginx_conf.yml @@ -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 \ No newline at end of file diff --git a/tasks/setup-acme.yml b/tasks/setup-acme.yml new file mode 100644 index 0000000..754081d --- /dev/null +++ b/tasks/setup-acme.yml @@ -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'