From 43efc02f5a5a92bbadb5d9ab00c34c8438146f9b Mon Sep 17 00:00:00 2001 From: Scott Walkinshaw Date: Tue, 13 Jan 2026 21:09:12 -0500 Subject: [PATCH 1/2] Switch to Nginx's built-in Acme module Replaces the custom `letsencrypt` role with Nginx's new automated solution using the `nginx-acme` module. This drastically simplifies Trellis' SSL setup with Let's Encrypt by delegating most of the functionality to Nginx. See https://nginx.org/en/docs/http/ngx_http_acme_module.html --- .github/workflows/integration.yml | 2 +- group_vars/all/helpers.yml | 1 + group_vars/development/main.yml | 1 - roles/common/handlers/main.yml | 3 - .../common/tasks/disable_challenge_sites.yml | 8 -- roles/common/tasks/main.yml | 2 +- roles/letsencrypt/README.md | 9 -- roles/letsencrypt/defaults/main.yml | 46 ---------- roles/letsencrypt/library/test_challenges.py | 84 ------------------- roles/letsencrypt/tasks/certificates.yml | 60 ------------- roles/letsencrypt/tasks/main.yml | 15 ---- roles/letsencrypt/tasks/nginx.yml | 74 ---------------- roles/letsencrypt/tasks/setup.yml | 43 ---------- .../templates/acme-challenge-location.conf.j2 | 4 - .../templates/nginx-challenge-site.conf.j2 | 6 -- roles/letsencrypt/templates/renew-certs.py | 60 ------------- roles/nginx/defaults/main.yml | 12 +++ roles/nginx/tasks/main.yml | 26 ++++++ .../templates/h5bp/directive-only/ssl.conf | 5 ++ roles/nginx/templates/nginx.conf.j2 | 19 +++++ roles/wordpress-setup/tasks/nginx.yml | 9 -- .../templates/wordpress-site.conf.j2 | 8 +- server.yml | 5 +- 23 files changed, 71 insertions(+), 431 deletions(-) delete mode 100644 roles/common/tasks/disable_challenge_sites.yml delete mode 100644 roles/letsencrypt/README.md delete mode 100644 roles/letsencrypt/defaults/main.yml delete mode 100644 roles/letsencrypt/library/test_challenges.py delete mode 100644 roles/letsencrypt/tasks/certificates.yml delete mode 100644 roles/letsencrypt/tasks/main.yml delete mode 100644 roles/letsencrypt/tasks/nginx.yml delete mode 100644 roles/letsencrypt/tasks/setup.yml delete mode 100644 roles/letsencrypt/templates/acme-challenge-location.conf.j2 delete mode 100644 roles/letsencrypt/templates/nginx-challenge-site.conf.j2 delete mode 100644 roles/letsencrypt/templates/renew-certs.py diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 6f2bca6d61..9ed14f5b1a 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -54,7 +54,7 @@ jobs: - run: trellis exec ansible-playbook --version working-directory: example.com/trellis - name: Provision - run: trellis provision --extra-vars "web_user=runner letsencrypt_ca=https://127.0.0.1:8443/acme/acme" production + run: trellis provision --extra-vars "web_user=runner acme_server=https://127.0.0.1:8443/acme/acme/directory acme_ssl_verify=off" production working-directory: example.com - name: Deploy non-https site run: trellis deploy --extra-vars "web_user=runner project_git_repo=https://github.com/roots/bedrock.git" production example.com diff --git a/group_vars/all/helpers.yml b/group_vars/all/helpers.yml index 206f78b4b1..d7f4d54682 100644 --- a/group_vars/all/helpers.yml +++ b/group_vars/all/helpers.yml @@ -34,6 +34,7 @@ object_cache_enabled_memcached: "{{ item.value.object_cache.enabled | default(fa # Sites using Redis or Memcached object cache sites_using_redis: "{{ (wordpress_sites | select_sites('object_cache.enabled', 'true') | select_sites('object_cache.provider', 'eq', 'redis')).keys() | list }}" sites_using_memcached: "{{ (wordpress_sites | select_sites('object_cache.enabled', 'true') | select_sites('object_cache.provider', 'eq', 'memcached')).keys() | list }}" +sites_using_letsencrypt: "{{ (wordpress_sites | select_sites('ssl.enabled', 'true') | select_sites('ssl.provider', 'eq', 'letsencrypt')).keys() | list }}" site_hosts_canonical: "{{ item.value.site_hosts | map(attribute='canonical') | list }}" site_hosts_redirects: "{{ item.value.site_hosts | selectattr('redirects', 'defined') | sum(attribute='redirects', start=[]) | list }}" site_hosts: "{{ site_hosts_canonical | union(site_hosts_redirects) }}" diff --git a/group_vars/development/main.yml b/group_vars/development/main.yml index 2723db8565..ecf0558684 100644 --- a/group_vars/development/main.yml +++ b/group_vars/development/main.yml @@ -1,4 +1,3 @@ -acme_tiny_challenges_directory: "{{ www_root }}/letsencrypt" env: development mysql_root_password: "{{ vault_mysql_root_password }}" # Define this variable in group_vars/development/vault.yml web_user: "{{ ansible_user | default ('web') }}" diff --git a/roles/common/handlers/main.yml b/roles/common/handlers/main.yml index 96c4a52c09..222e626c38 100644 --- a/roles/common/handlers/main.yml +++ b/roles/common/handlers/main.yml @@ -1,7 +1,4 @@ --- -- name: disable temporary challenge sites - import_tasks: disable_challenge_sites.yml - - name: restart memcached service: name: memcached diff --git a/roles/common/tasks/disable_challenge_sites.yml b/roles/common/tasks/disable_challenge_sites.yml deleted file mode 100644 index e7999fe65b..0000000000 --- a/roles/common/tasks/disable_challenge_sites.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -- name: disable temporary challenge sites - file: - path: "{{ nginx_path }}/sites-enabled/letsencrypt-{{ item }}.conf" - state: absent - loop: "{{ wordpress_sites.keys() | list }}" - notify: reload nginx - become: yes diff --git a/roles/common/tasks/main.yml b/roles/common/tasks/main.yml index 2c1a3e25ad..0316a52505 100644 --- a/roles/common/tasks/main.yml +++ b/roles/common/tasks/main.yml @@ -12,7 +12,7 @@ loop_control: label: "{{ item.key }}" when: item.value.site_hosts | rejectattr('canonical', 'defined') | list | count > 0 - tags: [letsencrypt, wordpress] + tags: [wordpress] - name: Import PHP version specific vars include_vars: "{{ lookup('first_found', params) }}" diff --git a/roles/letsencrypt/README.md b/roles/letsencrypt/README.md deleted file mode 100644 index 55354b1042..0000000000 --- a/roles/letsencrypt/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# Let’s encrypt/acme-tiny role for Ansible - -## License - -MIT - -## Author Information - -This role was created by Andreas Wolf. Visit my [website](http://a-w.io) and [Github profile](https://github.com/andreaswolf/) or follow me on [Twitter](https://twitter.com/andreaswo). diff --git a/roles/letsencrypt/defaults/main.yml b/roles/letsencrypt/defaults/main.yml deleted file mode 100644 index 4c7ca84aaa..0000000000 --- a/roles/letsencrypt/defaults/main.yml +++ /dev/null @@ -1,46 +0,0 @@ -sites_using_letsencrypt: "{{ (wordpress_sites | select_sites('ssl.enabled', 'true') | select_sites('ssl.provider', 'eq', 'letsencrypt')).keys() | list }}" -site_uses_letsencrypt: "{{ (ssl_enabled and item.value.ssl.provider | default('manual') == 'letsencrypt') | bool }}" -missing_hosts: "{{ site_hosts | difference((current_hosts.results | selectattr('item.key', 'equalto', item.key) | selectattr('stdout_lines', 'defined') | sum(attribute='stdout_lines', start=[]) | map('trim') | list | join(' ')).split(' ')) }}" -letsencrypt_cert_ids: >- - {{ dict((generate_cert_ids | default({'results':[]})).results - | selectattr('stdout', 'defined') - | map(attribute='item.key') - | zip((generate_cert_ids | default({'results':[]})).results - | selectattr('stdout', 'defined') - | map(attribute='stdout'))) }} - -acme_tiny_repo: 'https://github.com/diafygi/acme-tiny.git' -acme_tiny_commit: '1b61d3001cb9c11380557ffebda5d358ce64375c' - -acme_tiny_software_directory: /usr/local/letsencrypt -acme_tiny_data_directory: /var/lib/letsencrypt -acme_tiny_challenges_directory: "{{ www_root }}/letsencrypt" - -# Path to the local file containing the account key to copy to the server. -# Secure this file using Git-crypt for example. -# Leave this blank to generate a new account key that will need to be registered manually with Letsencrypt.org -#letsencrypt_account_key_source_file: /my/account.key - -# Content of the account key to copy to the server. -# Secure this key using Ansible Vault for example. -# Leave this blank to generate a new account key that will need to be registered manually with Letsencrypt.org -#letsencrypt_account_key_source_content: | -# -----BEGIN RSA PRIVATE KEY----- -# MIIJKAJBBBKCaGEA63J7t9dqyua5+Q+P6M3iHtLEKpF/AZcZNBHr1F2Oo8+Hfyvl -# KWXliiWjUORxDxI1c56Rw2VCIExnFjWJAdSLv6/XaQWo2T7U28bkKbAlCF9= -# -----END RSA PRIVATE KEY----- - -letsencrypt_ca: 'https://acme-v02.api.letsencrypt.org' - -letsencrypt_account_key: '{{ acme_tiny_data_directory }}/account.key' - -letsencrypt_keys_dir: "{{ nginx_ssl_path }}/letsencrypt" -letsencrypt_certs_dir: "{{ nginx_ssl_path }}/letsencrypt" - -# the minimum age (in days) after which a certificate will be renewed -letsencrypt_min_renewal_age: 60 - -# the days of a month the cronjob should be run. Make sure to run it rather often, three times per month is a pretty -# good value. It does not harm to run it often, as it will only regenerate certificates that have passed a certain age -# (60 days by default). -letsencrypt_cronjob_daysofmonth: 1,11,21 diff --git a/roles/letsencrypt/library/test_challenges.py b/roles/letsencrypt/library/test_challenges.py deleted file mode 100644 index f9d2299ddc..0000000000 --- a/roles/letsencrypt/library/test_challenges.py +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/python3 -# -*- coding: utf-8 -*- - -import socket -from http.client import HTTPConnection, HTTPException - -DOCUMENTATION = ''' ---- -module: test_challenges -short_description: Tests Let's Encrypt web server challenges -description: - - The M(test_challenges) module verifies a list of hosts can access acme challenges for Let's Encrypt. -options: - hosts: - description: - - A list of hostnames/domains to test. - required: true - default: null - type: list - file: - description: - - The dummy filename in the URL to test. - required: no - default: ping.txt - path: - description: - - The path to the challenges in the URL. - required: no - default: /.well-known/acme-challenge -author: - - Scott Walkinshaw -''' - -EXAMPLES = ''' -# Example from Ansible Playbooks. -- test_challenges: - hosts: - - example.com - - www.example.com - - www.mydomain.com -''' - -def get_status(host, path, file): - try: - conn = HTTPConnection(host) - conn.request('HEAD', '/{0}/{1}'.format(path, file), None, { - 'User-Agent': 'Trellis Ansible test_challenges module' - }) - res = conn.getresponse() - except (HTTPException, socket.timeout, socket.error): - return 0 - else: - return res.status - -def main(): - module = AnsibleModule( - argument_spec = dict( - file = dict(default='ping.txt'), - hosts = dict(required=True, type='list'), - path = dict(default='.well-known/acme-challenge') - ) - ) - - hosts = module.params['hosts'] - path = module.params['path'] - file = module.params['file'] - - failed_hosts = [] - - for host in hosts: - status = get_status(host, path, file) - if int(status) != 200: - failed_hosts.append(host) - - rc = int(len(failed_hosts) > 0) - - module.exit_json( - changed=False, - rc=rc, - failed_hosts=failed_hosts - ) - -from ansible.module_utils.basic import * -main() diff --git a/roles/letsencrypt/tasks/certificates.yml b/roles/letsencrypt/tasks/certificates.yml deleted file mode 100644 index eb330a3fa3..0000000000 --- a/roles/letsencrypt/tasks/certificates.yml +++ /dev/null @@ -1,60 +0,0 @@ ---- -- name: Generate private keys - shell: openssl genrsa 4096 > {{ letsencrypt_keys_dir }}/{{ item.key }}.key - args: - creates: "{{ letsencrypt_keys_dir }}/{{ item.key }}.key" - when: site_uses_letsencrypt - loop: "{{ wordpress_sites | dict2items }}" - loop_control: - label: "{{ item.key }}" - -- name: Ensure correct permissions on private keys - file: - path: "{{ letsencrypt_keys_dir }}/{{ item.key }}.key" - mode: '0600' - when: site_uses_letsencrypt - loop: "{{ wordpress_sites | dict2items }}" - loop_control: - label: "{{ item.key }}" - -- name: Generate Lets Encrypt certificate IDs - shell: | - set -eo pipefail - echo "{{ [site_hosts | join(' '), letsencrypt_ca, acme_tiny_commit] | join('\n') }}" | - cat {{ letsencrypt_account_key }} {{ letsencrypt_keys_dir }}/{{ item.key }}.key - | - md5sum | cut -c -7 - args: - executable: /bin/bash - register: generate_cert_ids - changed_when: false - when: site_uses_letsencrypt - loop: "{{ wordpress_sites | dict2items }}" - loop_control: - label: "{{ item.key }}" - tags: [wordpress, wordpress-setup, wordpress-setup-nginx, nginx-includes] - -- name: Generate CSRs - shell: "openssl req -new -sha256 -key '{{ letsencrypt_keys_dir }}/{{ item.key }}.key' -subj '/' -reqexts SAN -config <(cat /etc/ssl/openssl.cnf <(printf '[SAN]\nsubjectAltName=DNS:{{ site_hosts | join(',DNS:') }}')) > {{ acme_tiny_data_directory }}/csrs/{{ item.key }}-{{ letsencrypt_cert_ids[item.key] }}.csr" - args: - executable: /bin/bash - creates: "{{ acme_tiny_data_directory }}/csrs/{{ item.key }}-{{ letsencrypt_cert_ids[item.key] }}.csr" - when: site_uses_letsencrypt - loop: "{{ wordpress_sites | dict2items }}" - loop_control: - label: "{{ item.key }}" - -- name: Generate certificate renewal script - template: - src: renew-certs.py - dest: "{{ acme_tiny_data_directory }}/renew-certs.py" - mode: '0700' - tags: [wordpress, wordpress-setup, wordpress-setup-nginx, nginx-includes] - -- name: Generate the certificates - command: ./renew-certs.py - args: - chdir: "{{ acme_tiny_data_directory }}" - register: generate_certs - changed_when: generate_certs.stdout is defined and 'Created' in generate_certs.stdout - notify: reload nginx - tags: [wordpress, wordpress-setup, wordpress-setup-nginx, nginx-includes] diff --git a/roles/letsencrypt/tasks/main.yml b/roles/letsencrypt/tasks/main.yml deleted file mode 100644 index b65a534087..0000000000 --- a/roles/letsencrypt/tasks/main.yml +++ /dev/null @@ -1,15 +0,0 @@ ---- -- import_tasks: setup.yml -- import_tasks: nginx.yml -- import_tasks: certificates.yml - -- name: Install cronjob for key generation - cron: - cron_file: letsencrypt-certificate-renewal - name: letsencrypt certificate renewal - user: root - job: cd {{ acme_tiny_data_directory }} && ./renew-certs.py ; /usr/sbin/service nginx reload - day: "{{ letsencrypt_cronjob_daysofmonth }}" - hour: "4" - minute: "30" - state: present diff --git a/roles/letsencrypt/tasks/nginx.yml b/roles/letsencrypt/tasks/nginx.yml deleted file mode 100644 index 3cf03f006f..0000000000 --- a/roles/letsencrypt/tasks/nginx.yml +++ /dev/null @@ -1,74 +0,0 @@ ---- -- name: Create Nginx conf for challenges location - template: - src: acme-challenge-location.conf.j2 - dest: "{{ nginx_path }}/acme-challenge-location.conf" - mode: '0644' - -- name: Get list of hosts in current Nginx conf - shell: | - [ ! -f {{ nginx_path }}/sites-enabled/{{ item.key }}.conf ] || - sed -n -e "/listen 80/,/server_name/{s/server_name \(.*\);/\1/p}" {{ nginx_path }}/sites-enabled/{{ item.key }}.conf - register: current_hosts - changed_when: false - when: site_uses_letsencrypt - loop: "{{ wordpress_sites | dict2items }}" - loop_control: - label: "{{ item.key }}" - -- name: Create needed Nginx confs for challenges - template: - src: nginx-challenge-site.conf.j2 - dest: "{{ nginx_path }}/sites-available/letsencrypt-{{ item.key }}.conf" - mode: '0644' - register: challenge_site_confs - when: - - site_uses_letsencrypt - - missing_hosts | count > 0 - loop: "{{ wordpress_sites | dict2items }}" - loop_control: - label: "{{ item.key }}" - -- name: Enable Nginx sites - file: - src: "{{ nginx_path }}/sites-available/letsencrypt-{{ item.key }}.conf" - dest: "{{ nginx_path }}/sites-enabled/letsencrypt-{{ item.key }}.conf" - state: link - register: challenge_sites_enabled - when: - - site_uses_letsencrypt - - missing_hosts | count > 0 - loop: "{{ wordpress_sites | dict2items }}" - loop_control: - label: "{{ item.key }}" - notify: disable temporary challenge sites - -- import_tasks: "{{ playbook_dir }}/roles/common/tasks/reload_nginx.yml" - when: challenge_site_confs is changed or challenge_sites_enabled is changed - -- name: Create test Acme Challenge file - file: - path: "{{ acme_tiny_challenges_directory }}/ping.txt" - state: touch - mode: '0644' - -- name: Test Acme Challenges - test_challenges: - hosts: "{{ site_hosts }}" - register: letsencrypt_test_challenges - ignore_errors: true - when: site_uses_letsencrypt - loop: "{{ wordpress_sites | dict2items }}" - loop_control: - label: "{{ item.key }}" - -- name: Notify of challenge failures - fail: - msg: > - Could not access the challenge file for the hosts/domains: {{ item.failed_hosts | join(', ') }}. - Let's Encrypt requires every domain/host be publicly accessible. - Make sure that a valid DNS record exists for {{ item.failed_hosts | join(', ') }} and that they point to this server's IP. - If you don't want these domains in your SSL certificate, then remove them from `site_hosts`. - See https://roots.io/trellis/docs/ssl for more details. - when: item is not skipped and item is failed - loop: "{{ letsencrypt_test_challenges.results }}" diff --git a/roles/letsencrypt/tasks/setup.yml b/roles/letsencrypt/tasks/setup.yml deleted file mode 100644 index b78a7b0d76..0000000000 --- a/roles/letsencrypt/tasks/setup.yml +++ /dev/null @@ -1,43 +0,0 @@ ---- -- name: Create directories and set permissions - file: - mode: "{{ item.mode | default(omit) }}" - path: "{{ item.path }}" - state: directory - loop: - - path: "{{ acme_tiny_data_directory }}" - mode: '0700' - - path: "{{ acme_tiny_data_directory }}/csrs" - - path: "{{ acme_tiny_software_directory }}" - - path: "{{ acme_tiny_challenges_directory }}" - - path: "{{ letsencrypt_certs_dir }}" - mode: '0700' - loop_control: - label: "{{ item.path }}" - -- name: Clone acme-tiny repository - git: - dest: "{{ acme_tiny_software_directory }}" - repo: "{{ acme_tiny_repo }}" - version: "{{ acme_tiny_commit }}" - accept_hostkey: yes - -- name: Copy Lets Encrypt account key source file - copy: - src: "{{ letsencrypt_account_key_source_file }}" - dest: "{{ letsencrypt_account_key }}" - mode: '0700' - when: letsencrypt_account_key_source_file is defined - -- name: Copy Lets Encrypt account key source contents - copy: - content: "{{ letsencrypt_account_key_source_content | trim }}" - dest: "{{ letsencrypt_account_key }}" - mode: '0700' - when: letsencrypt_account_key_source_content is defined - -- name: Generate a new account key - shell: openssl genrsa 4096 > {{ letsencrypt_account_key }} - args: - creates: "{{ letsencrypt_account_key }}" - when: letsencrypt_account_key_source_content is not defined and letsencrypt_account_key_source_file is not defined diff --git a/roles/letsencrypt/templates/acme-challenge-location.conf.j2 b/roles/letsencrypt/templates/acme-challenge-location.conf.j2 deleted file mode 100644 index 1a30ce0d7a..0000000000 --- a/roles/letsencrypt/templates/acme-challenge-location.conf.j2 +++ /dev/null @@ -1,4 +0,0 @@ -location ^~ /.well-known/acme-challenge/ { - alias {{ acme_tiny_challenges_directory }}/; - try_files $uri =404; -} diff --git a/roles/letsencrypt/templates/nginx-challenge-site.conf.j2 b/roles/letsencrypt/templates/nginx-challenge-site.conf.j2 deleted file mode 100644 index ad476d9d3d..0000000000 --- a/roles/letsencrypt/templates/nginx-challenge-site.conf.j2 +++ /dev/null @@ -1,6 +0,0 @@ -server { - listen [::]:80; - listen 80; - server_name {{ missing_hosts | join(' ') }}; - include acme-challenge-location.conf; -} diff --git a/roles/letsencrypt/templates/renew-certs.py b/roles/letsencrypt/templates/renew-certs.py deleted file mode 100644 index 7b19561649..0000000000 --- a/roles/letsencrypt/templates/renew-certs.py +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env python3 - -import os -import sys -import time - -from subprocess import CalledProcessError, check_output, STDOUT - -failed = False -letsencrypt_cert_ids = {{ letsencrypt_cert_ids }} - -for site in {{ sites_using_letsencrypt }}: - csr_path = os.path.join('{{ acme_tiny_data_directory }}', 'csrs', '{}-{}.csr'.format(site, letsencrypt_cert_ids[site])) - bundled_cert_path = os.path.join('{{ letsencrypt_certs_dir }}', '{}-bundled.cert'.format(site)) - bundled_hashed_cert_path = os.path.join('{{ letsencrypt_certs_dir }}', '{}-{}-bundled.cert'.format(site, letsencrypt_cert_ids[site])) - - # Generate or update root cert if needed - if not os.access(csr_path, os.F_OK): - failed = True - print('The required CSR file {} does not exist. This could happen if you changed site_hosts and have ' - 'not yet rerun the letsencrypt role. Create the CSR file by re-provisioning (running the Trellis ' - 'server.yml playbook) with `--tags letsencrypt`'.format(csr_path), file=sys.stderr) - continue - - elif os.access(bundled_hashed_cert_path, os.F_OK) and time.time() - os.stat(bundled_hashed_cert_path).st_mtime < {{ letsencrypt_min_renewal_age }} * 86400: - print('Certificate file {} already exists and is younger than {{ letsencrypt_min_renewal_age }} days. ' - 'Not creating a new certificate.'.format(bundled_hashed_cert_path)) - - else: - cmd = ('/usr/bin/env python3 {{ acme_tiny_software_directory }}/acme_tiny.py ' - '--quiet ' - '--ca {{ letsencrypt_ca }} ' - '--account-key {{ letsencrypt_account_key }} ' - '--csr {} ' - '--acme-dir {{ acme_tiny_challenges_directory }}' - ).format(csr_path) - - try: - new_bundled_cert = check_output(cmd, stderr=STDOUT, shell=True, universal_newlines=True) - except CalledProcessError as e: - failed = True - print('Error while generating certificate for {}\n{}'.format(site, e.output), file=sys.stderr) - continue - else: - with open(bundled_hashed_cert_path, 'w') as bundled_hashed_cert_file: - bundled_hashed_cert_file.write(new_bundled_cert) - with open(bundled_cert_path, 'w') as bundled_cert_file: - bundled_cert_file.write(new_bundled_cert) - - if not os.access(bundled_cert_path, os.F_OK): - with open(bundled_hashed_cert_path, 'rb') as bundled_hashed_cert_file: - bundled_hashed_cert = bundled_hashed_cert_file.read() - - with open(bundled_cert_path, 'w') as bundled_cert_file: - bundled_cert_file.write(bundled_hashed_cert) - print('Created bundled certificate {}'.format(bundled_cert_path)) - - -if failed: - sys.exit(1) diff --git a/roles/nginx/defaults/main.yml b/roles/nginx/defaults/main.yml index 155a3e1890..17f7999390 100644 --- a/roles/nginx/defaults/main.yml +++ b/roles/nginx/defaults/main.yml @@ -16,3 +16,15 @@ nginx_cache_key_storage_size: 10m nginx_cache_size: 250m nginx_cache_inactive: 1h nginx_cache_use_stale: error timeout invalid_header http_500 + +# Resolver for ACME and other dynamic operations +nginx_resolver: 1.1.1.1 8.8.8.8 +nginx_resolver_timeout: 5s + +# ACME (Let's Encrypt) params +acme_server: https://acme-v02.api.letsencrypt.org/directory +acme_account_key_type: ecdsa:256 +acme_certificate_key_type: ecdsa:256 +acme_ssl_verify: "on" +nginx_acme_state_path: /var/lib/nginx/acme +nginx_acme_zone_size: 1M diff --git a/roles/nginx/tasks/main.yml b/roles/nginx/tasks/main.yml index 12686d5334..6841dd6df0 100644 --- a/roles/nginx/tasks/main.yml +++ b/roles/nginx/tasks/main.yml @@ -19,6 +19,24 @@ state: "{{ nginx_package_state | default(apt_package_state) }}" update_cache: true +- name: Install Nginx Acme module + ansible.builtin.apt: + name: nginx-module-acme + state: "{{ nginx_package_state | default(apt_package_state) }}" + +- name: Ensure modules-enabled directory exists + file: + path: "{{ nginx_path }}/modules-enabled" + state: directory + mode: '0755' + +- name: Enable Nginx Acme module + copy: + content: "load_module modules/ngx_http_acme_module.so;\n" + dest: "{{ nginx_path }}/modules-enabled/50-mod-http-acme.conf" + mode: '0644' + notify: reload nginx + - name: Ensure site directories exist file: path: "{{ nginx_path }}/{{ item }}" @@ -34,6 +52,14 @@ path: "{{ nginx_path }}/ssl" state: directory +- name: Create ACME state directory + file: + path: "{{ nginx_acme_state_path }}" + state: directory + owner: "{{ nginx_user.split()[0] }}" + mode: '0700' + when: sites_using_letsencrypt | length > 0 + - name: Copy h5bp configs copy: src: templates/h5bp diff --git a/roles/nginx/templates/h5bp/directive-only/ssl.conf b/roles/nginx/templates/h5bp/directive-only/ssl.conf index 19e62f03b7..3414ceb500 100644 --- a/roles/nginx/templates/h5bp/directive-only/ssl.conf +++ b/roles/nginx/templates/h5bp/directive-only/ssl.conf @@ -39,3 +39,8 @@ keepalive_timeout 300s; # up from 75 secs default # Make it a symlink to the most important certificate you have, so that users of IE 8 and below on WinXP can see your main site without SSL errors. #ssl_certificate /etc/nginx/default_ssl.crt; #ssl_certificate_key /etc/nginx/default_ssl.key; + +# Caches SSL certificates and secret keys that are specified by variables +# (specifically used for ACME / Let's Encrypt certificates). +# The more servers you have with SSL, the higher the max value should be. +ssl_certificate_cache max=2 diff --git a/roles/nginx/templates/nginx.conf.j2 b/roles/nginx/templates/nginx.conf.j2 index ff1d06e287..485eb93879 100644 --- a/roles/nginx/templates/nginx.conf.j2 +++ b/roles/nginx/templates/nginx.conf.j2 @@ -67,6 +67,25 @@ http { } {% endblock %} + {% block resolver -%} + resolver {{ nginx_resolver }}; + resolver_timeout {{ nginx_resolver_timeout }}; + {% endblock %} + + {% block acme -%} + {% if sites_using_letsencrypt | length > 0 -%} + acme_issuer letsencrypt { + uri {{ acme_server }}; + state_path {{ nginx_acme_state_path }}; + account_key {{ acme_account_key_type }}; + ssl_verify {{ acme_ssl_verify }}; + accept_terms_of_service; + } + + acme_shared_zone zone=acme:{{ nginx_acme_zone_size }}; + {% endif %} + {% endblock %} + {% block server_tokens -%} # Hide nginx version information. # Default: on diff --git a/roles/wordpress-setup/tasks/nginx.yml b/roles/wordpress-setup/tasks/nginx.yml index 2783766756..7df9e0a832 100644 --- a/roles/wordpress-setup/tasks/nginx.yml +++ b/roles/wordpress-setup/tasks/nginx.yml @@ -21,8 +21,6 @@ when: ssl_enabled and 'key' in item.value.ssl notify: reload nginx -- import_tasks: "{{ playbook_dir }}/roles/common/tasks/disable_challenge_sites.yml" - - name: Copy Nginx Wordpress site include folder copy: src: templates/includes @@ -60,13 +58,6 @@ notify: reload nginx tags: nginx-sites -- name: Create Nginx conf for challenges location - template: - src: "{{ playbook_dir }}/roles/letsencrypt/templates/acme-challenge-location.conf.j2" - dest: "{{ nginx_path }}/acme-challenge-location.conf" - mode: '0644' - notify: reload nginx - - name: Create WordPress configuration for Nginx template: src: "{{ item.value.nginx_wordpress_site_conf | default(nginx_wordpress_site_conf) }}" diff --git a/roles/wordpress-setup/templates/wordpress-site.conf.j2 b/roles/wordpress-setup/templates/wordpress-site.conf.j2 index 1594d757f0..9c5a2118e9 100644 --- a/roles/wordpress-setup/templates/wordpress-site.conf.j2 +++ b/roles/wordpress-setup/templates/wordpress-site.conf.j2 @@ -113,8 +113,10 @@ server { ssl_certificate_key {{ nginx_path }}/ssl/{{ item.value.ssl.key | basename }}; {% elif item.value.ssl.provider | default('manual') == 'letsencrypt' -%} - ssl_certificate {{ nginx_path }}/ssl/letsencrypt/{{ item.key }}-bundled.cert; - ssl_certificate_key {{ nginx_path }}/ssl/letsencrypt/{{ item.key }}.key; + acme_certificate letsencrypt {{ site_hosts | join(' ') }} key={{ acme_certificate_key_type }}; + + ssl_certificate $acme_certificate; + ssl_certificate_key $acme_certificate_key; {% elif item.value.ssl.provider | default('manual') == 'self-signed' -%} ssl_certificate {{ nginx_path }}/ssl/{{ item.key }}.cert; @@ -126,8 +128,6 @@ server { {% endblock -%} {% block acme_challenge -%} - include acme-challenge-location.conf; - {% endblock -%} {% block includes_d -%} diff --git a/server.yml b/server.yml index 69892d4cce..103d35c262 100644 --- a/server.yml +++ b/server.yml @@ -16,7 +16,7 @@ - { role: common, tags: [common] } - { role: swapfile, swapfile_size: 1GB, swapfile_file: /swapfile, tags: [swapfile] } - { role: fail2ban, tags: [fail2ban] } - - { role: ferm, tags: [ferm, letsencrypt] } + - { role: ferm, tags: [ferm] } - { role: hosts, tags: [hosts] } - { role: ntp, tags: [ntp] } - { role: users, tags: [users] } @@ -30,5 +30,4 @@ - { role: logrotate, tags: [logrotate] } - { role: composer, tags: [composer] } - { role: wp-cli, tags: [wp-cli] } - - { role: letsencrypt, tags: [letsencrypt], when: sites_using_letsencrypt | count > 0 } - - { role: wordpress-setup, tags: [wordpress, wordpress-setup, letsencrypt] } + - { role: wordpress-setup, tags: [wordpress, wordpress-setup] } From df639ed15f3e666cba9c0e08db30614de6067110 Mon Sep 17 00:00:00 2001 From: Scott Walkinshaw Date: Sun, 22 Feb 2026 14:23:40 -0500 Subject: [PATCH 2/2] Clean up old cronjob to avoid failures --- roles/nginx/defaults/main.yml | 3 +++ roles/nginx/tasks/main.yml | 6 ++++++ roles/nginx/templates/h5bp/directive-only/ssl.conf | 4 ---- roles/nginx/templates/nginx.conf.j2 | 8 ++++---- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/roles/nginx/defaults/main.yml b/roles/nginx/defaults/main.yml index 17f7999390..70cfb92fdc 100644 --- a/roles/nginx/defaults/main.yml +++ b/roles/nginx/defaults/main.yml @@ -28,3 +28,6 @@ acme_certificate_key_type: ecdsa:256 acme_ssl_verify: "on" nginx_acme_state_path: /var/lib/nginx/acme nginx_acme_zone_size: 1M + +# SSL certificate cache for variable-based certs (ACME) +nginx_ssl_certificate_cache_max: 10 diff --git a/roles/nginx/tasks/main.yml b/roles/nginx/tasks/main.yml index 6841dd6df0..9318d83876 100644 --- a/roles/nginx/tasks/main.yml +++ b/roles/nginx/tasks/main.yml @@ -81,6 +81,12 @@ state: absent notify: reload nginx +- name: Remove legacy Let's Encrypt cron job + file: + path: /etc/cron.d/letsencrypt-certificate-renewal + state: absent + when: sites_using_letsencrypt | length > 0 + - name: Enable Nginx to start on boot service: name: nginx diff --git a/roles/nginx/templates/h5bp/directive-only/ssl.conf b/roles/nginx/templates/h5bp/directive-only/ssl.conf index 3414ceb500..a8cf1f29b4 100644 --- a/roles/nginx/templates/h5bp/directive-only/ssl.conf +++ b/roles/nginx/templates/h5bp/directive-only/ssl.conf @@ -40,7 +40,3 @@ keepalive_timeout 300s; # up from 75 secs default #ssl_certificate /etc/nginx/default_ssl.crt; #ssl_certificate_key /etc/nginx/default_ssl.key; -# Caches SSL certificates and secret keys that are specified by variables -# (specifically used for ACME / Let's Encrypt certificates). -# The more servers you have with SSL, the higher the max value should be. -ssl_certificate_cache max=2 diff --git a/roles/nginx/templates/nginx.conf.j2 b/roles/nginx/templates/nginx.conf.j2 index 485eb93879..95288cb1df 100644 --- a/roles/nginx/templates/nginx.conf.j2 +++ b/roles/nginx/templates/nginx.conf.j2 @@ -67,13 +67,11 @@ http { } {% endblock %} - {% block resolver -%} + {% block acme -%} + {% if sites_using_letsencrypt | length > 0 -%} resolver {{ nginx_resolver }}; resolver_timeout {{ nginx_resolver_timeout }}; - {% endblock %} - {% block acme -%} - {% if sites_using_letsencrypt | length > 0 -%} acme_issuer letsencrypt { uri {{ acme_server }}; state_path {{ nginx_acme_state_path }}; @@ -83,6 +81,8 @@ http { } acme_shared_zone zone=acme:{{ nginx_acme_zone_size }}; + + ssl_certificate_cache max={{ nginx_ssl_certificate_cache_max }}; {% endif %} {% endblock %}