diff --git a/.github/actions/setup-step-ca/action.yml b/.github/actions/setup-step-ca/action.yml deleted file mode 100644 index 1f95236d7f..0000000000 --- a/.github/actions/setup-step-ca/action.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Setup Step CA ACME server -description: Installs and runs an ACME compatible server via step-ca -inputs: - path: - description: 'step-ca path' - required: false - default: /root/.step -runs: - using: composite - steps: - - name: Set STEP_CA_PATH env - run: echo STEP_CA_PATH=${{ inputs.path }} >> $GITHUB_ENV - shell: bash - - name: Download packages - run: | - wget -q https://dl.step.sm/gh-release/cli/docs-ca-install/v0.18.1/step-cli_0.18.1_amd64.deb - wget -q https://dl.step.sm/gh-release/certificates/docs-ca-install/v0.18.1/step-ca_0.18.1_amd64.deb - shell: bash - - name: Install packages - run: | - sudo dpkg -i step-cli_0.18.1_amd64.deb - sudo dpkg -i step-ca_0.18.1_amd64.deb - shell: bash - - name: Create password file - run: | - sudo mkdir $STEP_CA_PATH && sudo touch $STEP_CA_PATH/password.txt - echo $(openssl rand -hex 12) | sudo tee $STEP_CA_PATH/password.txt - shell: bash - - name: Initialize - run: | - sudo step ca init --name trellis-local-ca --dns 127.0.0.1 --address :8443 --provisioner admin --password-file $STEP_CA_PATH/password.txt --provisioner-password-file $STEP_CA_PATH/password.txt - sudo step ca provisioner add acme --type ACME - shell: bash - - name: Install certificate to system - run: | - sudo step certificate install $STEP_CA_PATH/certs/root_ca.crt - shell: bash - - name: Run service - run: | - sudo cp .github/files/step-ca.service /etc/systemd/system/step-ca.service - sudo systemctl start step-ca - shell: bash diff --git a/.github/files/wordpress_sites.yml b/.github/files/wordpress_sites.yml index 8fa390b191..6cd0743b76 100644 --- a/.github/files/wordpress_sites.yml +++ b/.github/files/wordpress_sites.yml @@ -1,4 +1,4 @@ -letsencrypt_contact_emails: +acme_ca_contact_emails: - admin@example.com wordpress_sites: @@ -14,7 +14,6 @@ wordpress_sites: enabled: false ssl: enabled: false - provider: letsencrypt cache: enabled: true example-https.com: @@ -29,6 +28,5 @@ wordpress_sites: enabled: false ssl: enabled: true - provider: letsencrypt cache: enabled: false diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 284f8dab0e..c09ac1a0a6 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -31,7 +31,6 @@ jobs: - uses: actions/setup-python@v2 with: python-version: '3.9' - - uses: ./.github/actions/setup-step-ca - uses: roots/setup-trellis-cli@v1 with: ansible-vault-password: 'fake' @@ -50,7 +49,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_ca_force_local_server=true" 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/dev.yml b/dev.yml index 2ef8d4eaac..7e1d8a4319 100644 --- a/dev.yml +++ b/dev.yml @@ -16,6 +16,7 @@ - { role: xdebug, tags: [php, xdebug] } - { role: memcached, tags: [memcached] } - { role: nginx, tags: [nginx] } + - { role: ssl_certificates, tags: [ssl_certificates, ssl], when: sites_using_ssl | count } - { role: logrotate, tags: [logrotate] } - { role: composer, tags: [composer] } - { role: wp-cli, tags: [wp-cli] } diff --git a/group_vars/all/helpers.yml b/group_vars/all/helpers.yml index 10101a4da7..0c39d280e7 100644 --- a/group_vars/all/helpers.yml +++ b/group_vars/all/helpers.yml @@ -10,7 +10,13 @@ wordpress_env_defaults: domain_current_site: "{{ site_hosts_canonical | first }}" wp_debug_log: "{{ www_root }}/{{ item.key }}/logs/debug.log" +ssl_defaults: + acme: + challenge: + type: http-01 + site_env: "{{ wordpress_env_defaults | combine(vault_wordpress_env_defaults | default({}), item.value.env | default({}), vault_wordpress_sites[item.key].env) }}" +site_ssl: "{{ ssl_defaults | combine(item.value.ssl | default({}) ) }}" 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 1a3d9f3bd2..95a0777a30 100644 --- a/group_vars/development/main.yml +++ b/group_vars/development/main.yml @@ -1,4 +1,4 @@ -acme_tiny_challenges_directory: "{{ www_root }}/letsencrypt" env: development +acme_ca_server: 'https://127.0.0.1:8443/acme/acme/directory' mysql_root_password: "{{ vault_mysql_root_password }}" # Define this variable in group_vars/development/vault.yml web_user: vagrant diff --git a/group_vars/development/wordpress_sites.yml b/group_vars/development/wordpress_sites.yml index 90009265e2..b73fc4cee2 100644 --- a/group_vars/development/wordpress_sites.yml +++ b/group_vars/development/wordpress_sites.yml @@ -14,6 +14,5 @@ wordpress_sites: enabled: false ssl: enabled: false - provider: self-signed cache: enabled: false diff --git a/group_vars/production/wordpress_sites.yml b/group_vars/production/wordpress_sites.yml index e8a875d1ca..cfe3b50651 100644 --- a/group_vars/production/wordpress_sites.yml +++ b/group_vars/production/wordpress_sites.yml @@ -16,6 +16,5 @@ wordpress_sites: enabled: false ssl: enabled: false - provider: letsencrypt cache: enabled: false diff --git a/group_vars/staging/wordpress_sites.yml b/group_vars/staging/wordpress_sites.yml index 054770ea7a..210634a8dd 100644 --- a/group_vars/staging/wordpress_sites.yml +++ b/group_vars/staging/wordpress_sites.yml @@ -16,6 +16,5 @@ wordpress_sites: enabled: false ssl: enabled: false - provider: letsencrypt cache: enabled: false 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 3d924c6678..0000000000 --- a/roles/letsencrypt/defaults/main.yml +++ /dev/null @@ -1,40 +0,0 @@ -sites_using_letsencrypt: "[{% for name, site in wordpress_sites.items() | list if site.ssl.enabled and site.ssl.provider | default('manual') == 'letsencrypt' %}'{{ name }}',{% endfor %}]" -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: "{ {% for item in (generate_cert_ids | default({'results':[{'skipped':True}]})).results if item is not skipped %}'{{ item.item.key }}':'{{ item.stdout }}', {% endfor %} }" - -acme_tiny_repo: 'https://github.com/diafygi/acme-tiny.git' -acme_tiny_commit: 'cb094cf3efa34acef8c7139c8480e2135422e755' - -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 8d5899e745..0000000000 --- a/roles/letsencrypt/library/test_challenges.py +++ /dev/null @@ -1,82 +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)) - 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 110af4219e..0000000000 --- a/roles/letsencrypt/tasks/certificates.yml +++ /dev/null @@ -1,52 +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 - with_dict: "{{ wordpress_sites }}" - -- name: Ensure correct permissions on private keys - file: - path: "{{ letsencrypt_keys_dir }}/{{ item.key }}.key" - mode: '0600' - when: site_uses_letsencrypt - with_dict: "{{ wordpress_sites }}" - -- 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 - with_dict: "{{ wordpress_sites }}" - 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 - with_dict: "{{ wordpress_sites }}" - -- 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/setup.yml b/roles/letsencrypt/tasks/setup.yml deleted file mode 100644 index c23ba5918d..0000000000 --- a/roles/letsencrypt/tasks/setup.yml +++ /dev/null @@ -1,63 +0,0 @@ ---- -- name: Fail if letsencrypt_contact_emails is not defined - fail: - msg: > - Error: the required `letsencrypt_contact_emails` variable is not defined or invalid. - - - Please define it in `groups_vars/all/main.yml` with at least one email (as a list/array, *not* a string): - - letsencrypt_contact_emails: - - changeme@example.com - - The contact email is used by Let's Encrypt to send expiry notices when a certificate is coming up for renewal. - - - See https://letsencrypt.org/docs/expiration-emails/ for more information. - - - Since Trellis attempts to renew certificates after {{ letsencrypt_min_renewal_age }} days ({{ 90 - letsencrypt_min_renewal_age }} days before renewal), - getting an expiry notice email means something has gone wrong giving you enough notice to fix the problem. - - when: (letsencrypt_contact_emails is not defined) or (letsencrypt_contact_emails is string) - -- name: Create directories and set permissions - file: - mode: "{{ item.mode | default(omit) }}" - path: "{{ item.path }}" - state: directory - with_items: - - 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' - -- 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/renew-certs.py b/roles/letsencrypt/templates/renew-certs.py deleted file mode 100644 index b13ed8efa6..0000000000 --- a/roles/letsencrypt/templates/renew-certs.py +++ /dev/null @@ -1,61 +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 {} ' - '--contact {{ letsencrypt_contact_emails | map('regex_replace', '(^.*$)', 'mailto:\\1') | join (' ') }} ' - '--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/tasks/main.yml b/roles/nginx/tasks/main.yml index 676679ce1b..81331b0431 100644 --- a/roles/nginx/tasks/main.yml +++ b/roles/nginx/tasks/main.yml @@ -24,12 +24,6 @@ - sites-available - sites-enabled -- name: Create SSL directory - file: - mode: '0700' - path: "{{ nginx_path }}/ssl" - state: directory - - name: Copy h5bp configs copy: src: templates/h5bp diff --git a/roles/ssl_certificates/defaults/main.yml b/roles/ssl_certificates/defaults/main.yml new file mode 100644 index 0000000000..9e452abff7 --- /dev/null +++ b/roles/ssl_certificates/defaults/main.yml @@ -0,0 +1,20 @@ +sites_using_ssl: "{{ wordpress_sites.values() | map(attribute='ssl') | selectattr('enabled', 'equalto', true) }}" +sites_using_acme_ssl: "{{ sites_using_ssl | rejectattr('manual', 'defined') | map(attribute='acme') | map('default', ssl_defaults.acme )}}" +sites_using_manual_ssl: "{{ sites_using_ssl | selectattr('manual', 'defined') }}" +site_uses_acme: "{{ (ssl_enabled and 'acme' in site_ssl) | bool }}" +site_uses_acme_http_challenge: "{{ (ssl_enabled and site_ssl.acme.challenge.type == 'http-01') | 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(' ')) }}" + +acme_ca_server: 'https://acme-v02.api.letsencrypt.org' +local_acme_ca_server: 'https://127.0.0.1:8443/acme/acme/directory' +acme_challenges_path: "{{ www_root }}/letsencrypt" + +step_ca_path: /etc/step-ca + +# the minimum age (in days) after which a certificate will be renewed +ssl_renewal_min_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). +ssl_renewal_cronjob_daysofmonth: 1,11,21 diff --git a/roles/ssl_certificates/handlers/main.yml b/roles/ssl_certificates/handlers/main.yml new file mode 100644 index 0000000000..fed0208555 --- /dev/null +++ b/roles/ssl_certificates/handlers/main.yml @@ -0,0 +1,7 @@ +- name: Start step CA + systemd: + daemon_reload: yes + name: step-ca + enabled: yes + state: started + become: yes diff --git a/roles/ssl_certificates/tasks/acme.yml b/roles/ssl_certificates/tasks/acme.yml new file mode 100644 index 0000000000..c45f22b6e4 --- /dev/null +++ b/roles/ssl_certificates/tasks/acme.yml @@ -0,0 +1,42 @@ +- include_tasks: + file: local_ca.yml + apply: + become: yes + when: sites_using_acme_ssl | count and (env == 'development' or acme_ca_server == local_acme_ca_server) + +- name: Install certbot packages + apt: + name: + - certbot + - python3-certbot + state: present + +- import_tasks: nginx.yml + +- name: Register with ACME CA server + shell: certbot register --agree-tos --no-eff-email --email {{ acme_ca_contact_emails | join(",") }} --server {{ acme_ca_server }} + become: yes + register: acme_registration + failed_when: + - acme_registration.rc != 0 + - '"There is an existing account" not in acme_registration.stderr' + +- name: Install cronjob for certificate renewal + cron: + cron_file: acme-certificate-renewal + name: ACME certificate renewal + user: root + job: certbot renew -q --deploy-hook "/usr/sbin/service nginx reload" + day: "{{ ssl_renewal_cronjob_daysofmonth }}" + hour: "4" + minute: "30" + state: present + +- name: Generate SSL certificates + include_tasks: + file: "{{ site_ssl.acme.challenge.type }}.yml" + apply: + become: yes + when: site_uses_acme + with_dict: "{{ wordpress_sites }}" + tags: [wordpress, wordpress-setup, wordpress-setup-nginx, nginx-includes] diff --git a/roles/ssl_certificates/tasks/dns-01.yml b/roles/ssl_certificates/tasks/dns-01.yml new file mode 100644 index 0000000000..13e7bbfc10 --- /dev/null +++ b/roles/ssl_certificates/tasks/dns-01.yml @@ -0,0 +1,12 @@ +- name: Generate certificate via dns-01 challenge + shell: > + TODO FIX + certbot certonly + --noninteractive + --agree-tos + --email {{ acme_ca_contact_emails | join(",") }} + --cert-name {{ item.key }} + --server {{ acme_ca_server }} + --dns-{{ site_ssl.acme.challenge.options.provider }} + {{ site_ssl.acme.challenge.options.map('regex_replace', '(^.*$)', '--dns-\\1') | join (' ') }} + --domains {{ site_hosts | join(",") }} diff --git a/roles/ssl_certificates/tasks/http-01.yml b/roles/ssl_certificates/tasks/http-01.yml new file mode 100644 index 0000000000..902e708914 --- /dev/null +++ b/roles/ssl_certificates/tasks/http-01.yml @@ -0,0 +1,11 @@ +- name: Generate certificate via http-01 challenge + shell: > + certbot certonly + --noninteractive + --agree-tos + --email {{ acme_ca_contact_emails | join(",") }} + --cert-name {{ item.key }} + --server {{ acme_ca_server }} + --webroot + --webroot-path {{ acme_challenges_path }} + --domains {{ site_hosts | join(",") }} diff --git a/roles/ssl_certificates/tasks/local_ca.yml b/roles/ssl_certificates/tasks/local_ca.yml new file mode 100644 index 0000000000..9874902aa1 --- /dev/null +++ b/roles/ssl_certificates/tasks/local_ca.yml @@ -0,0 +1,62 @@ +- name: Download step packages + get_url: + url: "{{ item.url }}" + dest: "{{ item.dest }}" + with_items: + - { url: "https://github.com/smallstep/cli/releases/download/v0.20.0/step-cli_0.20.0_amd64.deb", dest: "/tmp/step-cli_0.20.0_amd64.deb" } + - { url: "https://github.com/smallstep/certificates/releases/download/v0.20.0/step-ca_0.20.0_amd64.deb", dest: "/tmp/step-ca_0.20.0_amd64.deb" } + +- name: Install packages + apt: + deb: "{{ item }}" + with_items: + - /tmp/step-cli_0.20.0_amd64.deb + - /tmp/step-ca_0.20.0_amd64.deb + +- name: Create step-ca directories + file: + path: "{{ item }}" + state: directory + with_items: + - "{{ step_ca_path }}" + +- name: Generate password file + shell: + cmd: "echo $(openssl rand -hex 12) > {{ step_ca_path }}/password.txt" + creates: "{{ step_ca_path }}/password.txt" + +- name: Initialize step certificate authority + shell: + cmd: | + step ca init --name trellis-local-ca --dns 127.0.0.1 --address :8443 --provisioner admin --password-file {{ step_ca_path }}/password.txt --provisioner-password-file {{ step_ca_path }}/password.txt + step ca provisioner add acme --type ACME + creates: "{{ step_ca_path }}/certs/root_ca.crt" + environment: + STEPPATH: "{{ step_ca_path }}" + +- name: Install local root certificate + command: + cmd: step certificate install {{ step_ca_path }}/certs/root_ca.crt + environment: + STEPPATH: "{{ step_ca_path }}" + +- name: Copy systemd unit + template: + src: step-ca.service.j2 + dest: /etc/systemd/system/step-ca.service + +- name: Enable step-ca service + service: + name: step-ca + enabled: yes + state: started + use: service + +- name: Get root cert fingerprint + shell: step certificate fingerprint {{ step_ca_path }}/certs/root_ca.crt + register: fingerprint + changed_when: false + +- name: Print fingerprint + debug: + var: fingerprint.stdout diff --git a/roles/ssl_certificates/tasks/main.yml b/roles/ssl_certificates/tasks/main.yml new file mode 100644 index 0000000000..314da28e70 --- /dev/null +++ b/roles/ssl_certificates/tasks/main.yml @@ -0,0 +1,46 @@ +- name: Fail if acme_ca_contact_emails is not defined + fail: + msg: > + Error: the required `acme_ca_contact_emails` variable is not defined or invalid. + + + Please define it in `groups_vars/all/main.yml` with at least one email (as a list/array, *not* a string): + + acme_ca_contact_emails: + - changeme@example.com + + The contact email is used by the ACME certificate authority (usually Let's Encrypt) to send expiry notices when a certificate is coming up for renewal. + + + See https://letsencrypt.org/docs/expiration-emails/ for more information. + + + Since Trellis attempts to renew certificates after {{ ssl_renewal_min_age }} days ({{ 90 - ssl_renewal_min_age }} days before renewal), + getting an expiry notice email means something has gone wrong giving you enough notice to fix the problem. + + when: (acme_ca_contact_emails is not defined) or (acme_ca_contact_emails is string) + +- name: Create Nginx SSL directory + file: + mode: '0700' + path: "{{ nginx_path }}/ssl" + state: directory + +- include_tasks: + file: acme.yml + when: + - sites_using_acme_ssl | count + - not acme_ca_force_local_server + +- include_tasks: + file: acme.yml + vars: + acme_ca_server: "{{ local_acme_ca_server }}" + when: + - sites_using_acme_ssl | count + - acme_ca_force_local_server + +- include_tasks: + file: manual.yml + when: + - sites_using_manual_ssl | count diff --git a/roles/ssl_certificates/tasks/manual.yml b/roles/ssl_certificates/tasks/manual.yml new file mode 100644 index 0000000000..f3ed84e47a --- /dev/null +++ b/roles/ssl_certificates/tasks/manual.yml @@ -0,0 +1,24 @@ +- name: Copy manual SSL certificate + copy: + src: "{{ site_ssl.manual.certificate }}" + dest: "{{ nginx_ssl_path }}/{{ site_ssl.manual.certificate | basename }}" + mode: '0640' + with_dict: "{{ wordpress_sites }}" + notify: reload nginx + +- name: Copy manual SSL key + copy: + src: "{{ site_ssl.manual.key }}" + dest: "{{ nginx_ssl_path }}/{{ site_ssl.manual.key | basename }}" + mode: '0600' + with_dict: "{{ wordpress_sites }}" + notify: reload nginx + +- name: Download SSL client certificate + get_url: + url: "{{ site_ssl.manual.client_certificate_url }}" + dest: "{{ nginx_ssl_path }}/client-{{ (site_ssl.client_certificate_url | hash('md5'))[:7] }}.crt" + mode: '0640' + with_dict: "{{ wordpress_sites }}" + when: site_ssl.manual.client_certificate_url is defined + tags: wordpress-setup-nginx-client-cert diff --git a/roles/letsencrypt/tasks/nginx.yml b/roles/ssl_certificates/tasks/nginx.yml similarity index 51% rename from roles/letsencrypt/tasks/nginx.yml rename to roles/ssl_certificates/tasks/nginx.yml index 877ed09c92..732a490a63 100644 --- a/roles/letsencrypt/tasks/nginx.yml +++ b/roles/ssl_certificates/tasks/nginx.yml @@ -1,5 +1,13 @@ --- -- name: Create Nginx conf for challenges location +- name: Create directories and set permissions + file: + path: "{{ acme_challenges_path }}/.well-known/acme-challenge" + state: directory + mode: '0755' + owner: "{{ web_user }}" + group: "{{ web_group }}" + +- name: Create Nginx conf for ACME challenges location template: src: acme-challenge-location.conf.j2 dest: "{{ nginx_path }}/acme-challenge-location.conf" @@ -11,7 +19,9 @@ 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 + when: + - site_uses_acme + - site_uses_acme_http_challenge with_dict: "{{ wordpress_sites }}" - name: Create needed Nginx confs for challenges @@ -21,7 +31,8 @@ mode: '0644' register: challenge_site_confs when: - - site_uses_letsencrypt + - site_uses_acme + - site_uses_acme_http_challenge - missing_hosts | count with_dict: "{{ wordpress_sites }}" @@ -32,35 +43,10 @@ state: link register: challenge_sites_enabled when: - - site_uses_letsencrypt + - site_uses_acme + - site_uses_acme_http_challenge - missing_hosts | count with_dict: "{{ wordpress_sites }}" - 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 - with_dict: "{{ wordpress_sites }}" - -- 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 - with_items: "{{ letsencrypt_test_challenges.results }}" diff --git a/roles/ssl_certificates/templates/acme-challenge-location.conf.j2 b/roles/ssl_certificates/templates/acme-challenge-location.conf.j2 new file mode 100644 index 0000000000..b3cca7cb4f --- /dev/null +++ b/roles/ssl_certificates/templates/acme-challenge-location.conf.j2 @@ -0,0 +1,8 @@ +location ^~ /.well-known/acme-challenge/ { + default_type "text/plain"; + root {{ acme_challenges_path }}; +} + +location = /.well-known/acme-challenge/ { + return 404; +} diff --git a/roles/letsencrypt/templates/nginx-challenge-site.conf.j2 b/roles/ssl_certificates/templates/nginx-challenge-site.conf.j2 similarity index 100% rename from roles/letsencrypt/templates/nginx-challenge-site.conf.j2 rename to roles/ssl_certificates/templates/nginx-challenge-site.conf.j2 diff --git a/roles/ssl_certificates/templates/step-ca.service.j2 b/roles/ssl_certificates/templates/step-ca.service.j2 new file mode 100644 index 0000000000..11f252f9eb --- /dev/null +++ b/roles/ssl_certificates/templates/step-ca.service.j2 @@ -0,0 +1,15 @@ +[Unit] +Description=step-ca service +After=network.target +StartLimitIntervalSec=0 + +[Service] +Type=simple +Restart=always +RestartSec=1 +Environment=STEPPATH=/etc/step-ca +WorkingDirectory=/etc/step-ca +ExecStart=/usr/bin/step-ca config/ca.json --password-file password.txt + +[Install] +WantedBy=multi-user.target diff --git a/roles/wordpress-setup/tasks/main.yml b/roles/wordpress-setup/tasks/main.yml index ac1fd8a735..a6ea3461fe 100644 --- a/roles/wordpress-setup/tasks/main.yml +++ b/roles/wordpress-setup/tasks/main.yml @@ -1,10 +1,6 @@ --- - import_tasks: database.yml tags: wordpress-setup-database -- import_tasks: self-signed-certificate.yml - tags: wordpress-setup-self-signed-certificate -- import_tasks: nginx-client-cert.yml - tags: wordpress-setup-nginx-client-cert - name: Create web root file: diff --git a/roles/wordpress-setup/tasks/nginx-client-cert.yml b/roles/wordpress-setup/tasks/nginx-client-cert.yml deleted file mode 100644 index 69f7026a22..0000000000 --- a/roles/wordpress-setup/tasks/nginx-client-cert.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -- name: Download client cert - get_url: - url: "{{ item.value.ssl.client_cert_url }}" - dest: "{{ nginx_ssl_path }}/client-{{ (item.value.ssl.client_cert_url | hash('md5'))[:7] }}.crt" - mode: '0640' - with_dict: "{{ wordpress_sites }}" - when: ssl_enabled and item.value.ssl.client_cert_url is defined diff --git a/roles/wordpress-setup/tasks/nginx.yml b/roles/wordpress-setup/tasks/nginx.yml index ebeb80c49b..4788e40125 100644 --- a/roles/wordpress-setup/tasks/nginx.yml +++ b/roles/wordpress-setup/tasks/nginx.yml @@ -1,22 +1,4 @@ --- -- name: Copy SSL cert - copy: - src: "{{ item.value.ssl.cert }}" - dest: "{{ nginx_ssl_path }}/{{ item.value.ssl.cert | basename }}" - mode: '0640' - with_dict: "{{ wordpress_sites }}" - when: ssl_enabled and item.value.ssl.cert is defined - notify: reload nginx - -- name: Copy SSL key - copy: - src: "{{ item.value.ssl.key }}" - dest: "{{ nginx_ssl_path }}/{{ item.value.ssl.key | basename }}" - mode: '0600' - with_dict: "{{ wordpress_sites }}" - when: ssl_enabled and item.value.ssl.key is defined - notify: reload nginx - - import_tasks: "{{ playbook_dir }}/roles/common/tasks/disable_challenge_sites.yml" - name: Create Nginx available sites @@ -51,7 +33,7 @@ - name: Create Nginx conf for challenges location template: - src: "{{ playbook_dir }}/roles/letsencrypt/templates/acme-challenge-location.conf.j2" + src: "{{ playbook_dir }}/roles/ssl_certificates/templates/acme-challenge-location.conf.j2" dest: "{{ nginx_path }}/acme-challenge-location.conf" mode: '0644' notify: reload nginx diff --git a/roles/wordpress-setup/tasks/self-signed-certificate.yml b/roles/wordpress-setup/tasks/self-signed-certificate.yml deleted file mode 100644 index eef2b29775..0000000000 --- a/roles/wordpress-setup/tasks/self-signed-certificate.yml +++ /dev/null @@ -1,37 +0,0 @@ ---- -- name: Ensure openssl configs directory are present - file: - path: "{{ nginx_ssl_path }}/self-signed-openssl-configs/" - state: directory - mode: '0755' - -- name: Template openssl configs - template: - src: self-signed-openssl-config.j2 - dest: "{{ nginx_ssl_path }}/self-signed-openssl-configs/{{ item.key }}.cnf" - mode: '0644' - with_dict: "{{ wordpress_sites }}" - when: - - sites_use_ssl | bool - - ssl_enabled | bool - - item.value.ssl.provider | default('manual') == 'self-signed' - -- name: Generate self-signed certificates - command: "openssl req -new -newkey rsa:2048 \ - -days 825 -nodes -x509 -sha256 \ - -extensions req_ext -config {{ nginx_ssl_path }}/self-signed-openssl-configs/{{ item.key }}.cnf \ - -keyout {{ item.key | quote }}.key -out {{ item.key | quote }}.cert" - args: - chdir: "{{ nginx_ssl_path }}" - creates: "{{ item.key }}.*" - with_dict: "{{ wordpress_sites }}" - when: - - sites_use_ssl | bool - - ssl_enabled | bool - - item.value.ssl.provider | default('manual') == 'self-signed' - notify: reload nginx - -- name: Clean up openssl configs directory - file: - path: "{{ nginx_ssl_path }}/self-signed-openssl-configs/" - state: absent diff --git a/roles/wordpress-setup/templates/self-signed-openssl-config.j2 b/roles/wordpress-setup/templates/self-signed-openssl-config.j2 deleted file mode 100644 index 9ba1054aef..0000000000 --- a/roles/wordpress-setup/templates/self-signed-openssl-config.j2 +++ /dev/null @@ -1,7 +0,0 @@ -[req] -prompt = no -distinguished_name = req_dn -[req_dn] -commonName = {{ item.value.site_hosts[0].canonical }} -[req_ext] -subjectAltName = {{ site_hosts | union(multisite_subdomains_wildcards) | map('regex_replace', '(^.*$)', 'DNS:\\1') | join(',') }} diff --git a/roles/wordpress-setup/templates/wordpress-site.conf.j2 b/roles/wordpress-setup/templates/wordpress-site.conf.j2 index a5cf322d1b..259f1e19ac 100644 --- a/roles/wordpress-setup/templates/wordpress-site.conf.j2 +++ b/roles/wordpress-setup/templates/wordpress-site.conf.j2 @@ -77,27 +77,23 @@ server { ssl_buffer_size 1400; # 1400 bytes to fit in one MTU - {% if item.value.ssl.provider | default('manual') != 'self-signed' -%} + {% if env != 'development' -%} add_header Strict-Transport-Security "max-age={{ [hsts_max_age, hsts_include_subdomains, hsts_preload] | reject('none') | join('; ') }}"; {% endif -%} - {% if item.value.ssl.client_cert_url is defined -%} - ssl_verify_client on; - ssl_client_certificate {{ nginx_ssl_path }}/client-{{ (item.value.ssl.client_cert_url | hash('md5'))[:7] }}.crt; + {% if site_ssl.manual is defined and site_ssl.manual.client_cert_url is defined -%} + ssl_verify_client on + ssl_client_certificate {{ nginx_ssl_path }}/client-{{ (site_ssl.manual.client_cert_url | hash('md5'))[:7] }}.crt; {% endif -%} - {% if item.value.ssl.provider | default('manual') == 'manual' and item.value.ssl.cert is defined and item.value.ssl.key is defined -%} - ssl_certificate {{ nginx_path }}/ssl/{{ item.value.ssl.cert | basename }}; - ssl_certificate_key {{ nginx_path }}/ssl/{{ item.value.ssl.key | basename }}; + {% if 'manual' in site_ssl and site_ssl.manual.ssl_certificate is defined and site_ssl.manual.key is defined -%} + ssl_certificate {{ nginx_path }}/ssl/{{ + site_ssl.manual.ssl_certificate | basename }}; + ssl_certificate_key {{ nginx_path }}/ssl/{{ site_ssl.manual.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; - - {% elif item.value.ssl.provider | default('manual') == 'self-signed' -%} - ssl_certificate {{ nginx_path }}/ssl/{{ item.key }}.cert; - ssl_trusted_certificate {{ nginx_path }}/ssl/{{ item.key }}.cert; - ssl_certificate_key {{ nginx_path }}/ssl/{{ item.key }}.key; + {% elif site_ssl.acme is defined -%} + ssl_certificate /etc/letsencrypt/live/{{ item.key }}/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/{{ item.key }}/privkey.pem; {% endif -%} {% endif -%} diff --git a/server.yml b/server.yml index be9a456eb4..bf0c908510 100644 --- a/server.yml +++ b/server.yml @@ -28,5 +28,5 @@ - { role: logrotate, tags: [logrotate] } - { role: composer, tags: [composer] } - { role: wp-cli, tags: [wp-cli] } - - { role: letsencrypt, tags: [letsencrypt], when: sites_using_letsencrypt | count } - - { role: wordpress-setup, tags: [wordpress, wordpress-setup, letsencrypt] } + - { role: ssl_certificates, tags: [ssl, ssl_certificates], when: sites_using_ssl | count } + - { role: wordpress-setup, tags: [wordpress, wordpress-setup, ssl] }