diff --git a/group_vars/all/00_general.yml b/group_vars/all/00_general.yml index 2fb3e186..b218ae13 100644 --- a/group_vars/all/00_general.yml +++ b/group_vars/all/00_general.yml @@ -75,22 +75,16 @@ randomized_delay_sec: "5min" # Runtime Variables for Process Control activate_all_timers: false # Activates all timers, independend if the handlers had been triggered -# One Wildcard Certificate for All Subdomains -# Enables a single Let's Encrypt wildcard certificate for all subdomains instead of individual certificates. -# Default: false (recommended for automatic setup). -# Setting this to true requires additional manual configuration. -# Using a wildcard certificate can improve performance by reducing TLS handshakes. -# To enable, update your inventory file. -# For detailed setup instructions, visit: -# https://github.com/kevinveenbirkenbach/cymais/tree/master/roles/nginx-docker-cert-deploy -enable_wildcard_certificate: false - # This enables debugging in ansible and in the apps # You SHOULD NOT enable this on production servers enable_debug: false # Which ACME method to use: webroot, cloudflare, or hetzner -certbot_acme_challenge_method: "webroot" +certbot_acme_challenge_method: "cloudflare" certbot_credentials_dir: /etc/certbot certbot_credentials_file: "{{ certbot_credentials_dir }}/{{ certbot_acme_challenge_method }}.ini" -# certbot_dns_api_token # Define in inventory file \ No newline at end of file +# certbot_dns_api_token # Define in inventory file +certbot_dns_propagation_wait_seconds: 40 # How long should the script wait for DNS propagation before continuing +certbot_flavor: san # Possible options: san (recommended, with a dns flavor like cloudflare, or hetzner), wildcard(doesn't function with www redirect), deicated +certbot_webroot_path: "/var/lib/letsencrypt/" # Path used by Certbot to serve HTTP-01 ACME challenges +certbot_cert_path: "/etc/letsencrypt/live" # Path containing active certificate symlinks for domains diff --git a/group_vars/all/03_domains.yml b/group_vars/all/03_domains.yml index 734a05aa..595bb1d7 100644 --- a/group_vars/all/03_domains.yml +++ b/group_vars/all/03_domains.yml @@ -80,5 +80,4 @@ defaults_redirect_domain_mappings: - { source: "wordpress.{{primary_domain}}", target: "{{domains.wordpress[0]}}" } # Domains which are deprecated and should be cleaned up -deprecated_domains: [] - +deprecated_domains: [] \ No newline at end of file diff --git a/library/find_cert_folder.py b/library/find_cert_folder.py new file mode 100644 index 00000000..87c24bae --- /dev/null +++ b/library/find_cert_folder.py @@ -0,0 +1,157 @@ +#!/usr/bin/python + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = r''' +--- +module: find_cert_folder +short_description: Find SSL certificate folder covering a given domain +description: + - Searches through certificates to find a folder that covers the given domain. +options: + domain: + description: + - Domain name to search for in the certificates. + required: true + type: str + certbot_flavor: + description: + - Certificate type. Either 'san', 'wildcard', or 'dedicated'. + required: true + type: str + cert_base_path: + description: + - Path where certificates are stored. + required: false + type: str + default: /etc/letsencrypt/live + debug: + description: + - Enable verbose debug output. + required: false + type: bool + default: false +author: + - Kevin Veen-Birkenbach +''' + +EXAMPLES = r''' +- name: Find cert folder for matomo.cymais.cloud + find_cert_folder: + domain: "matomo.cymais.cloud" + certbot_flavor: "san" + cert_base_path: "/etc/letsencrypt/live" + register: result +''' + +RETURN = r''' +folder: + description: The name of the folder covering the domain. + type: str + returned: always +''' + +import os +import subprocess +from ansible.module_utils.basic import AnsibleModule + +def run_openssl(cert_path): + try: + output = subprocess.check_output( + ['openssl', 'x509', '-in', cert_path, '-noout', '-text'], + universal_newlines=True + ) + return output + except subprocess.CalledProcessError: + return "" + +def extract_sans(cert_text): + dns_entries = [] + in_san = False + for line in cert_text.splitlines(): + line = line.strip() + if 'X509v3 Subject Alternative Name:' in line: + in_san = True + continue + if in_san: + if not line: + break + dns_entries += [e.strip().replace('DNS:', '') for e in line.split(',') if e.strip()] + return dns_entries + +def find_matching_folders(domain, cert_files, flavor, debug): + exact_matches = [] + wildcard_matches = [] + + for cert_path in cert_files: + cert_text = run_openssl(cert_path) + if not cert_text: + continue + sans = extract_sans(cert_text) + if debug: + print(f"Checking {cert_path}: {sans}") + for entry in sans: + if entry == domain: + exact_matches.append(os.path.dirname(cert_path)) + elif entry.startswith('*.'): + base = entry[2:] + if domain.endswith('.' + base): + wildcard_matches.append(os.path.dirname(cert_path)) + + if flavor in ('san', 'dedicated'): + return exact_matches or wildcard_matches + elif flavor == 'wildcard': + return wildcard_matches or exact_matches + else: + return [] + +def find_cert_folder(module): + domain = module.params['domain'] + certbot_flavor = module.params['certbot_flavor'] + cert_base_path = module.params['cert_base_path'] + debug = module.params['debug'] + + cert_files = [] + for root, dirs, files in os.walk(cert_base_path): + if 'cert.pem' in files: + cert_files.append(os.path.join(root, 'cert.pem')) + + if debug: + print(f"Found {len(cert_files)} cert.pem files under {cert_base_path}") + + preferred = find_matching_folders(domain, cert_files, certbot_flavor, debug) + + if not preferred and certbot_flavor == 'san': + if debug: + print("Fallback: searching SAN matches without SAN structure parsing") + for cert_path in cert_files: + cert_text = run_openssl(cert_path) + if f"DNS:{domain}" in cert_text: + preferred.append(os.path.dirname(cert_path)) + + if not preferred: + module.fail_json(msg=f"No certificate covering domain {domain} found.") + + preferred = sorted(preferred, key=lambda p: (p.count('-'), len(p))) + folder = os.path.basename(preferred[0]) + + module.exit_json(folder=folder) + +def main(): + module_args = dict( + domain=dict(type='str', required=True), + certbot_flavor=dict(type='str', required=True), + cert_base_path=dict(type='str', required=False, default='/etc/letsencrypt/live'), + debug=dict(type='bool', required=False, default=False), + ) + + module = AnsibleModule( + argument_spec=module_args, + supports_check_mode=True + ) + + find_cert_folder(module) + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/roles/backup-docker-to-local/tasks/main.yml b/roles/backup-docker-to-local/tasks/main.yml index da5b29ee..f0552672 100644 --- a/roles/backup-docker-to-local/tasks/main.yml +++ b/roles/backup-docker-to-local/tasks/main.yml @@ -8,6 +8,7 @@ - name: Retrieve backup-docker-to-local path from pkgmgr command: pkgmgr path backup-docker-to-local register: pkgmgr_output + changed_when: false when: run_once_backup_docker_to_local is not defined - name: Set fact for backup_docker_to_local_folder diff --git a/roles/cleanup-failed-docker-backups/tasks/main.yml b/roles/cleanup-failed-docker-backups/tasks/main.yml index 5982a307..aced5ced 100644 --- a/roles/cleanup-failed-docker-backups/tasks/main.yml +++ b/roles/cleanup-failed-docker-backups/tasks/main.yml @@ -8,6 +8,7 @@ - name: Retrieve backup-docker-to-local path from pkgmgr command: pkgmgr path cleanup-failed-docker-backups register: pkgmgr_output + changed_when: false when: run_once_cleanup_failed_docker_backups is not defined - name: Set fact for backup_docker_to_local_cleanup_script diff --git a/roles/docker-bigbluebutton/tasks/main.yml b/roles/docker-bigbluebutton/tasks/main.yml index 5078d3d1..7d94731b 100644 --- a/roles/docker-bigbluebutton/tasks/main.yml +++ b/roles/docker-bigbluebutton/tasks/main.yml @@ -1,12 +1,12 @@ --- -- name: "include docker-compose role" - include_role: - name: docker-compose - - name: "include role nginx-domain-setup for {{application_id}}" include_role: name: nginx-domain-setup +- name: "include docker-compose role" + include_role: + name: docker-compose + - name: pull docker repository git: repo: "https://github.com/bigbluebutton/docker.git" diff --git a/roles/docker-bigbluebutton/templates/env.j2 b/roles/docker-bigbluebutton/templates/env.j2 index 6bcb92e7..049c9d7a 100644 --- a/roles/docker-bigbluebutton/templates/env.j2 +++ b/roles/docker-bigbluebutton/templates/env.j2 @@ -1,7 +1,6 @@ ENABLE_COTURN=true -{% set ssl_cert_folder = primary_domain if enable_wildcard_certificate | bool and primary_domain in domain else domain %} -COTURN_TLS_CERT_PATH=/etc/letsencrypt/live/{{ssl_cert_folder}}/fullchain.pem -COTURN_TLS_KEY_PATH=/etc/letsencrypt/live/{{ssl_cert_folder}}/privkey.pem +COTURN_TLS_CERT_PATH={{ certbot_cert_path }}/{{ ssl_cert_folder }}/fullchain.pem +COTURN_TLS_KEY_PATH={{ certbot_cert_path }}/{{ ssl_cert_folder }}/privkey.pem ENABLE_GREENLIGHT={{applications.bigbluebutton.enable_greenlight}} # Enable Webhooks diff --git a/roles/docker-ldap/vars/main.yml b/roles/docker-ldap/vars/main.yml index bb7681ea..b1dd793a 100644 --- a/roles/docker-ldap/vars/main.yml +++ b/roles/docker-ldap/vars/main.yml @@ -1,9 +1,6 @@ application_id: "ldap" ldaps_docker_port: 636 ldap_docker_port: 389 - -enable_wildcard_certificate: false # Deactivate Wildcard Certificate - ldif_host_path: "{{docker_compose.directories.volumes}}ldif/" ldif_docker_path: "/tmp/ldif/" ldap.dn.application_roles: "ou=application_roles,{{ldap.dn.root}}" diff --git a/roles/docker-mailu/vars/main.yml b/roles/docker-mailu/vars/main.yml index cfd87410..2aba753c 100644 --- a/roles/docker-mailu/vars/main.yml +++ b/roles/docker-mailu/vars/main.yml @@ -5,7 +5,6 @@ database_password: "{{applications.mailu.credentials.database.password database_type: "mariadb" cert_mount_directory: "{{docker_compose.directories.volumes}}certs/" -enable_wildcard_certificate: false # Use dedicated source for oidc if activated # @see https://github.com/heviat/Mailu-OIDC/tree/2024.06 diff --git a/roles/letsencrypt/README.md b/roles/letsencrypt/README.md new file mode 100644 index 00000000..cf620b53 --- /dev/null +++ b/roles/letsencrypt/README.md @@ -0,0 +1,24 @@ +# Let’s Encrypt SSL for Nginx 🔐 + +## Description +Automates obtaining, configuring, and renewing Let’s Encrypt SSL certificates for Nginx with Certbot. Keeps your sites secure with minimal fuss! 🌐 + +## Overview +This Ansible role sets up the necessary Nginx configuration and Certbot integration to: +- Redirect HTTP traffic to HTTPS +- Serve the ACME challenge for certificate issuance +- Apply strong SSL/TLS defaults +- Schedule automatic renewals + +It’s idempotent: configuration and certificate tasks only run when needed. ✅ + +## Purpose +Ensure all your Nginx-hosted sites use free, trusted SSL certificates from Let’s Encrypt—all managed automatically via Ansible. 🎯 + +## Features +- **Automatic Certificate Issuance**: Uses Certbot’s webroot plugin to request and install certificates. 📜 +- **Nginx Redirect**: Creates a temporary HTTP → HTTPS redirect block. ↪️ +- **ACME‐Challenge Handling**: Configures `/.well-known/acme-challenge/` for Certbot validation. 🔍 +- **Secure SSL Defaults**: Includes modern cipher suites, HSTS, OCSP stapling, and session settings. 🔒 +- **Auto‐Renewal**: Leverages system scheduling (cron or systemd timer) to renew certs before expiration. 🔄 +- **One‐Time Setup**: Tasks guarded by a “run once” fact to avoid re-applying unchanged templates. 🏃‍♂️ \ No newline at end of file diff --git a/roles/letsencrypt/meta/main.yml b/roles/letsencrypt/meta/main.yml index 4f45948e..ad293193 100644 --- a/roles/letsencrypt/meta/main.yml +++ b/roles/letsencrypt/meta/main.yml @@ -1,2 +1,26 @@ +--- +galaxy_info: + author: "Kevin Veen-Birkenbach" + description: "An Ansible role to automate Let’s Encrypt SSL certificate issuance and renewal for Nginx" + license: "CyMaIS NonCommercial License (CNCL)" + license_url: "https://s.veen.world/cncl" + company: | + Kevin Veen-Birkenbach + Consulting & Coaching Solutions + https://www.veen.world + min_ansible_version: "2.9" + platforms: + - name: Archlinux + versions: + - rolling + galaxy_tags: + - letsencrypt + - nginx + - ssl + - certificate + - security + repository: "https://s.veen.world/cymais" + issue_tracker_url: "https://s.veen.world/cymaisissues" + documentation: "https://s.veen.world/cymais" dependencies: -- nginx-certbot + - nginx-certbot diff --git a/roles/letsencrypt/templates/letsencrypt.conf.j2 b/roles/letsencrypt/templates/letsencrypt.conf.j2 index c139b01c..72f96aae 100644 --- a/roles/letsencrypt/templates/letsencrypt.conf.j2 +++ b/roles/letsencrypt/templates/letsencrypt.conf.j2 @@ -9,7 +9,7 @@ server #letsencrypt location ^~ /.well-known/acme-challenge/ { allow all; - root /var/lib/letsencrypt/; + root {{ certbot_webroot_path }}; default_type "text/plain"; try_files $uri =404; } diff --git a/roles/letsencrypt/templates/ssl_credentials.j2 b/roles/letsencrypt/templates/ssl_credentials.j2 index 77e8fac6..ad128f0e 100644 --- a/roles/letsencrypt/templates/ssl_credentials.j2 +++ b/roles/letsencrypt/templates/ssl_credentials.j2 @@ -1,4 +1,3 @@ -{% set ssl_cert_folder = primary_domain if enable_wildcard_certificate | bool and primary_domain in domain else domain %} -ssl_certificate /etc/letsencrypt/live/{{ ssl_cert_folder }}/fullchain.pem; -ssl_certificate_key /etc/letsencrypt/live/{{ ssl_cert_folder }}/privkey.pem; -ssl_trusted_certificate /etc/letsencrypt/live/{{ ssl_cert_folder }}/chain.pem; \ No newline at end of file +ssl_certificate {{ certbot_cert_path }}/{{ ssl_cert_folder }}/fullchain.pem; +ssl_certificate_key {{ certbot_cert_path }}/{{ ssl_cert_folder }}/privkey.pem; +ssl_trusted_certificate {{ certbot_cert_path }}/{{ ssl_cert_folder }}/chain.pem; \ No newline at end of file diff --git a/roles/nginx-docker-cert-deploy/CONFIGURATION.md b/roles/nginx-docker-cert-deploy/CONFIGURATION.md deleted file mode 100644 index bc91b408..00000000 --- a/roles/nginx-docker-cert-deploy/CONFIGURATION.md +++ /dev/null @@ -1,19 +0,0 @@ -# Configuration Options 📋 - -## One Wildcard Certificate for All Subdomains - -By default, each subdomain gets its own certificate. You can **enable a wildcard certificate** by setting: - -```yaml -enable_wildcard_certificate: true -``` - -## Pros & Cons of a Wildcard Certificate -### Pros -- ✅ **Improves performance** by reducing TLS handshakes. -- ✅ **Simplifies certificate management** (one cert for all subdomains). -### Cons -- ⚠ **Requires manual DNS challenge setup** for Let's Encrypt. -- ⚠ **Needs additional configuration for automation** (see below). - -If enabled, update your inventory file and follow the **[manual wildcard certificate setup](SETUP.md)**. diff --git a/roles/nginx-docker-cert-deploy/files/nginx-docker-cert-deploy.sh b/roles/nginx-docker-cert-deploy/files/nginx-docker-cert-deploy.sh index d64dd726..dbdb3e43 100644 --- a/roles/nginx-docker-cert-deploy/files/nginx-docker-cert-deploy.sh +++ b/roles/nginx-docker-cert-deploy/files/nginx-docker-cert-deploy.sh @@ -2,21 +2,21 @@ # Check if the necessary parameters are provided if [ "$#" -ne 2 ]; then - echo "Usage: $0 " + echo "Usage: $0 " exit 1 fi # Assign parameters -domain="$1" +ssl_cert_folder="$1" docker_compose_instance_directory="$2" docker_compose_cert_directory="$docker_compose_instance_directory/volumes/certs" # Copy certificates -cp -RvL "/etc/letsencrypt/live/$domain/"* "$docker_compose_cert_directory" || exit 1 +cp -RvL "/etc/letsencrypt/live/$ssl_cert_folder/"* "$docker_compose_cert_directory" || exit 1 # This code is optimized for mailu -cp -v "/etc/letsencrypt/live/$domain/privkey.pem" "$docker_compose_cert_directory/key.pem" || exit 1 -cp -v "/etc/letsencrypt/live/$domain/fullchain.pem" "$docker_compose_cert_directory/cert.pem" || exit 1 +cp -v "/etc/letsencrypt/live/$ssl_cert_folder/privkey.pem" "$docker_compose_cert_directory/key.pem" || exit 1 +cp -v "/etc/letsencrypt/live/$ssl_cert_folder/fullchain.pem" "$docker_compose_cert_directory/cert.pem" || exit 1 # Set correct reading rights chmod a+r -v "$docker_compose_cert_directory/"* diff --git a/roles/nginx-docker-cert-deploy/templates/nginx-docker-cert-deploy.service.j2 b/roles/nginx-docker-cert-deploy/templates/nginx-docker-cert-deploy.service.j2 index 5b7d50b7..846714b8 100644 --- a/roles/nginx-docker-cert-deploy/templates/nginx-docker-cert-deploy.service.j2 +++ b/roles/nginx-docker-cert-deploy/templates/nginx-docker-cert-deploy.service.j2 @@ -4,4 +4,4 @@ OnFailure=systemd-notifier.cymais@%n.service [Service] Type=oneshot -ExecStart=/usr/bin/bash {{path_administrator_scripts}}/nginx-docker-cert-deploy.sh {{domain}} {{docker_compose.directories.instance}} +ExecStart=/usr/bin/bash {{path_administrator_scripts}}/nginx-docker-cert-deploy.sh {{ssl_cert_folder}} {{docker_compose.directories.instance}} diff --git a/roles/nginx-domains-cleanup/tasks/main.yml b/roles/nginx-domains-cleanup/tasks/main.yml index 8ca1fa83..63112b33 100644 --- a/roles/nginx-domains-cleanup/tasks/main.yml +++ b/roles/nginx-domains-cleanup/tasks/main.yml @@ -23,7 +23,12 @@ when: - mode_cleanup | bool - run_once_nginx_domains_cleanup is not defined - ignore_errors: true + register: certbot_revoke_result + failed_when: > + certbot_revoke_result.rc != 0 and + 'No certificate found with name' not in certbot_revoke_result.stderr + changed_when: > + certbot_revoke_result.rc == 0 # The deleting just works for the base domain - name: "Delete Certbot certificate for {{ item }}" @@ -36,7 +41,12 @@ when: - mode_cleanup | bool - run_once_nginx_domains_cleanup is not defined - ignore_errors: true + register: certbot_delete_result + failed_when: > + certbot_delete_result.rc != 0 and + 'No certificate found with name' not in certbot_delete_result.stderr + changed_when: > + certbot_delete_result.rc == 0 - name: run the nginx_domains_cleanup role once set_fact: diff --git a/roles/nginx-https-get-cert/tasks/flavors/dedicated.yml b/roles/nginx-https-get-cert/tasks/flavors/dedicated.yml new file mode 100644 index 00000000..af4f9ee4 --- /dev/null +++ b/roles/nginx-https-get-cert/tasks/flavors/dedicated.yml @@ -0,0 +1,23 @@ +- name: "receive certificate for {{ domain }}" + command: >- + certbot certonly + --agree-tos + --email {{ users.administrator.email }} + --non-interactive + {% if certbot_acme_challenge_method != "webroot" %} + --dns-{{ certbot_acme_challenge_method }} + --dns-{{ certbot_acme_challenge_method }}-credentials {{ certbot_credentials_file }} + --dns-{{ certbot_acme_challenge_method }}-propagation-seconds {{ certbot_dns_propagation_wait_seconds }} + {% else %} + --webroot + -w {{ certbot_webroot_path }} + {% endif %} + {% if wildcard_domain is defined and ( wildcard_domain | bool ) %} + -d {{ primary_domain }} + -d *.{{ primary_domain }} + {% else %} + -d {{ domain }} + {% endif %} + {{ '--test-cert' if mode_test | bool else '' }} + register: certbot_result + changed_when: "'Certificate not yet due for renewal' not in certbot_result.stdout" diff --git a/roles/nginx-https-get-cert/tasks/flavors/san.yml b/roles/nginx-https-get-cert/tasks/flavors/san.yml new file mode 100644 index 00000000..3f23d1f4 --- /dev/null +++ b/roles/nginx-https-get-cert/tasks/flavors/san.yml @@ -0,0 +1,27 @@ +- name: Install certbundle + include_role: + name: pkgmgr-install + vars: + package_name: certbundle + when: run_once_san_certs is not defined + +- name: Generate SAN certificate with certbundle + command: >- + certbundle + --domains "{{ all_domains | join(',') }}" + --certbot-email "{{ users.administrator.email }}" + --certbot-acme-challenge-method "{{ certbot_acme_challenge_method }}" + {% if certbot_acme_challenge_method != 'webroot' %} + --certbot-credentials-file "{{ certbot_credentials_file }}" + --certbot-dns-propagation-seconds "{{ certbot_dns_propagation_wait_seconds }}" + {% else %} + --certbot-webroot-path "{{ certbot_webroot_path }}" + {% endif %} + {{ '--mode-test' if mode_test | bool else '' }} + register: certbundle_result + when: run_once_san_certs is not defined + +- name: run the san tasks once + set_fact: + run_once_san_certs: true + when: run_once_san_certs is not defined \ No newline at end of file diff --git a/roles/nginx-https-get-cert/tasks/flavors/wildcard.yml b/roles/nginx-https-get-cert/tasks/flavors/wildcard.yml new file mode 100644 index 00000000..e7b00a39 --- /dev/null +++ b/roles/nginx-https-get-cert/tasks/flavors/wildcard.yml @@ -0,0 +1,19 @@ +- name: "Load wildcard certificate for domain" + include_tasks: "dedicated.yml" + vars: + wildcard_domain: true + when: + - domain.split('.') | length == (primary_domain.split('.') | length + 1) and domain.endswith(primary_domain) + - run_once_receive_certificate is not defined + +- name: "Load dedicated certificate for domain" + include_tasks: "dedicated.yml" + vars: + wildcard_domain: false + when: + - not (domain.split('.') | length == (primary_domain.split('.') | length + 1) and domain.endswith(primary_domain)) + +- name: run the receive_certificate tasks once + set_fact: + run_once_receive_certificate: true + when: run_once_receive_certificate is not defined \ No newline at end of file diff --git a/roles/nginx-https-get-cert/tasks/main.yml b/roles/nginx-https-get-cert/tasks/main.yml index e4ad9e6a..4eb58322 100644 --- a/roles/nginx-https-get-cert/tasks/main.yml +++ b/roles/nginx-https-get-cert/tasks/main.yml @@ -1,55 +1,5 @@ -- name: "receive dedicated certificate for {{ domain }}" - command: >- - certbot certonly - --agree-tos - --email {{ users.administrator.email }} - --non-interactive - {% if certbot_acme_challenge_method != "webroot" %} - --dns-{{ certbot_acme_challenge_method }} - --dns-{{ certbot_acme_challenge_method }}-credentials {{ certbot_credentials_file }} - --dns-{{ certbot_acme_challenge_method }}-propagation-seconds 60 - {% else %} - --webroot - -w /var/lib/letsencrypt/ - {% endif %} - -d {{ domain }} - {{ '--test-cert' if mode_test | bool else '' }} - register: certbot_result - changed_when: "'Certificate not yet due for renewal' not in certbot_result.stdout" - when: - - not enable_wildcard_certificate | bool or not (domain.split('.') | length == (primary_domain.split('.') | length + 1) and domain.endswith(primary_domain)) - # Wildcard certificate should not be used - # OR: The domain is not a first-level subdomain of the primary domain - -# The following should not work, checkout the Setup.md instructions. -# @see https://chatgpt.com/share/67efa9f0-1cdc-800f-8bce-62b00fc3e6a2 -- name: "receive wildcard certificate *{{ primary_domain }} for {{domain}}" - command: >- - certbot certonly - --agree-tos - --email {{ users.administrator.email }} - --non-interactive - {% if certbot_acme_challenge_method != "webroot" %} - --dns-{{ certbot_acme_challenge_method }} - --dns-{{ certbot_acme_challenge_method }}-credentials {{ certbot_credentials_file }} - --dns-{{ certbot_acme_challenge_method }}-propagation-seconds 60 - {% else %} - --webroot - -w /var/lib/letsencrypt/ - {% endif %} - -d {{ primary_domain }} - -d *.{{ primary_domain }} - {{ '--test-cert' if mode_test | bool else '' }} - register: certbot_result - changed_when: "'Certificate not yet due for renewal' not in certbot_result.stdout" - when: - - enable_wildcard_certificate | bool - # Wildcard certificate is enabled - - domain.split('.') | length == (primary_domain.split('.') | length + 1) and domain.endswith(primary_domain) - # AND: The domain is a direct first-level subdomain of the primary domain - - run_once_receive_certificate is not defined - # Ensure this task runs only once for the wildcard certificate - ignore_errors: true +- name: "Include flavor" + include_tasks: "{{ role_path }}/tasks/flavors/{{ certbot_flavor }}.yml" - name: "Cleanup dedicated cert for {{ domain }}" command: >- @@ -57,7 +7,7 @@ when: - mode_cleanup | bool # Cleanup mode is enabled - - enable_wildcard_certificate | bool + - certbot_flavor != 'dedicated' # Wildcard certificate is enabled - domain.split('.') | length == (primary_domain.split('.') | length + 1) and domain.endswith(primary_domain) # AND: The domain is a direct first-level subdomain of the primary domain @@ -67,7 +17,15 @@ failed_when: certbot_result.rc != 0 and ("No certificate found with name" not in certbot_result.stderr) changed_when: certbot_result.rc == 0 and ("No certificate found with name" not in certbot_result.stderr) -- name: run the receive_certificate tasks once +- name: Find SSL cert folder for domain + find_cert_folder: + domain: "{{ domain }}" + certbot_flavor: "{{ certbot_flavor }}" + cert_base_path: "{{ certbot_cert_path }}" + debug: "{{ enable_debug | default(false) }}" + register: cert_folder_result + delegate_to: "{{ inventory_hostname }}" + +- name: Set fact set_fact: - run_once_receive_certificate: true - when: run_once_receive_certificate is not defined \ No newline at end of file + ssl_cert_folder: "{{ cert_folder_result.folder }}" \ No newline at end of file diff --git a/roles/nginx-redirect-www/README.md b/roles/nginx-redirect-www/README.md index d184c2bd..71fe1843 100644 --- a/roles/nginx-redirect-www/README.md +++ b/roles/nginx-redirect-www/README.md @@ -1,22 +1,24 @@ -# nginx-redirect-www +# Nginx WWW Redirect 🌐 + +## Description +Automates the creation of Nginx server blocks that redirect all `www.` subdomains to their non-www equivalents. Simple, idempotent, and SEO-friendly! 🚀 ## Overview -The `nginx-redirect-www` role is designed to automate the process of setting up redirects from `www.domain.tld` to `domain.tld` for all domains and subdomains configured within the `{{nginx.directories.http.servers}}` directory. This role dynamically identifies configuration files following the pattern `*domain.tld.conf` and creates corresponding redirection rules. +This role will: +- **Discover** existing `*.conf` vhosts in your Nginx servers directory +- **Filter** domains with or without your `primary_domain` +- **Generate** redirect rules via the `nginx-redirect-domain` role +- **Optionally** include a wildcard redirect template (experimental) ⭐️ +- **Clean up** leftover configs when running in cleanup mode 🧹 -## Role Description -This role performs several key tasks: -1. **Find Configuration Files**: Locates all `.conf` files in the `{{nginx.directories.http.servers}}` directory that match the `*.*.conf` pattern, ensuring that only domain and subdomain configurations are selected. - -2. **Filter Domain Names**: Processes each configuration file, extracting the domain names and removing both the `.conf` extension and the `{{nginx.directories.http.servers}}` path. +All tasks are guarded by “run once” facts and `mode_cleanup` flags to avoid unintended re-runs or stale files. -3. **Prepare Redirect Domain Mappings**: Transforms the filtered domain names into a source-target mapping format, where `source` is `www.domain.tld` and `target` is `domain.tld`. +## Purpose +Ensure that any request to `www.example.com` automatically and permanently redirects to `https://example.com`, improving user experience, SEO, and certificate management. 🎯 -4. **Include nginx-redirect-domain Role**: Applies the redirection configuration using the `nginx-redirect-domain` role with the dynamically generated domain mappings. - -## Notes -- This role is designed to work in environments where domain and subdomain configurations follow the naming pattern `*domain.tld.conf`. -- It automatically excludes any configurations that begin with `www.`, preventing duplicate redirects. - ---- - -This `nginx-redirect-www` role was crafted by [Kevin Veen-Birkenbach](https://www.veen.world) with insights and guidance provided by ChatGPT, an advanced AI language model from OpenAI. The development process, including the discussions with ChatGPT that shaped this role, can be [here](https://chat.openai.com/share/a68e3574-f543-467d-aea7-0895f0e00bbb) explored in detail. \ No newline at end of file +## Features +- **Auto-Discovery**: Scans your Nginx `servers` directory for `.conf` files. 🔍 +- **Dynamic Redirects**: Builds `source: "www.domain"` → `target: "domain"` mappings on the fly. 🔧 +- **Wildcard Redirect**: Includes a templated wildcard server block for `www.*` domains (toggleable). ✨ +- **Cleanup Mode**: Removes the wildcard config file when `certbot_flavor` is set to `dedicated` and `mode_cleanup` is enabled. 🗑️ +- **Debug Output**: Optional `enable_debug` gives detailed variable dumps for troubleshooting. 🐛 diff --git a/roles/nginx-redirect-www/meta/main.yml b/roles/nginx-redirect-www/meta/main.yml index 0ecbe0af..26dffe38 100644 --- a/roles/nginx-redirect-www/meta/main.yml +++ b/roles/nginx-redirect-www/meta/main.yml @@ -1,2 +1,26 @@ +--- +galaxy_info: + author: "Kevin Veen-Birkenbach" + description: "An Ansible role to redirect www subdomains to non-www domains in Nginx" + license: "CyMaIS NonCommercial License (CNCL)" + license_url: "https://s.veen.world/cncl" + company: | + Kevin Veen-Birkenbach + Consulting & Coaching Solutions + https://www.veen.world + min_ansible_version: "2.9" + platforms: + - name: Archlinux + versions: + - rolling + galaxy_tags: + - nginx + - redirect + - www + - wildcard + - seo + repository: "https://s.veen.world/cymais" + issue_tracker_url: "https://s.veen.world/cymaisissues" + documentation: "https://s.veen.world/cymais" dependencies: - - nginx + - nginx \ No newline at end of file diff --git a/roles/nginx-redirect-www/tasks/main.yml b/roles/nginx-redirect-www/tasks/main.yml index 8e95b709..8aa6c638 100644 --- a/roles/nginx-redirect-www/tasks/main.yml +++ b/roles/nginx-redirect-www/tasks/main.yml @@ -1,87 +1,23 @@ --- -- name: Find all .conf - ansible.builtin.find: - paths: "{{nginx.directories.http.servers}}" - patterns: '*.*.conf' - register: conf_files - -# Filter all domains - -- name: Filter domain names and remove .conf extension and path +# 1. Filter all domains with the “www.” prefix +- name: Filter www-prefixed domains from all_domains set_fact: - filtered_domains: "{{ conf_files.files | map(attribute='path') | map('regex_search', domain_regex) | select('string') | map('regex_replace', path_regex, '') | map('regex_replace', '.conf$', '') | list }}" - vars: - domain_regex: "^{{nginx.directories.http.servers}}(?!www\\.)[^/]+\\.conf$" - path_regex: "^{{nginx.directories.http.servers}}" + www_domains: "{{ all_domains | select('match', '^www\\.') | list }}" -# Routine for domains with primary domain included - -- name: Set filtered_domains_with_primary_domain +# 2. Build redirect mappings (www.domain → domain) +- name: Build redirect mappings for www domains set_fact: - filtered_domains_with_primary_domain: "{{ filtered_domains | select('search', primary_domain + '$') | list }}" + domain_mappings: >- + {{ www_domains + | map('regex_replace', '^www\\.(.+)$', '{ source: \"www.\\1\", target: \"\\1\" }') + | map('from_yaml') + | list + }} -- name: Include nginx-redirect-domain role with dynamic domain mappings for domains with {{primary_domain}} included +# 3. Include the nginx-redirect-domain role to apply these mappings +- name: Include nginx-redirect-domain role for www-to-bare redirects include_role: name: nginx-redirect-domain vars: - domain_mappings: "{{ filtered_domains_with_primary_domain | map('regex_replace', '^(.*)$', '{ source: \"www.\\1\", target: \"\\1\" }') | map('from_yaml') | list }}" - when: not enable_wildcard_certificate | bool - -- name: Include wildcard www. redirect for domains with {{primary_domain}} included - vars: - domain: "{{primary_domain}}" - template: - src: www.wildcard.conf.j2 - dest: "{{nginx_www_wildcard_configuration}}" - notify: restart nginx - when: enable_wildcard_certificate | bool - -# Routine for domains without the primary domain included - -- name: Set filtered_domains_without_primary_domain - set_fact: - filtered_domains_without_primary_domain: "{{ filtered_domains | reject('search', primary_domain + '$') | list }}" - -- name: "Relevant variables for role: {{ role_path | basename }}" - debug: - msg: - filtered_domains_with_primary_domain: "{{filtered_domains_with_primary_domain}}" - filtered_domains: "{{filtered_domains}}" - filtered_domains_without_primary_domain: "{{filtered_domains_without_primary_domain}}" - when: enable_debug | bool - -- name: Include nginx-redirect-domain role with dynamic domain mappings for domains without primary domain - include_role: - name: nginx-redirect-domain - vars: - domain_mappings: "{{ filtered_domains_without_primary_domain | map('regex_replace', '^(.*)$', '{ source: \"www.\\1\", target: \"\\1\" }') | map('from_yaml') | list }}" - - -# Cleanup -# Deactivated due to complexity -#- name: Cleanup dedicated nginx configurations for www redirect configuration -# file: -# path: "{{ nginx.directories.http.servers }}{{ item.source }}.conf" -# state: absent -# # Filter: Only first-level subdomains of primary_domain -# # Exclude the primary domain itself -# # Transform for www redirection -# loop: "{{ filtered_domains_with_primary_domain -# | select('regex_search', '^[^.]+\\.' ~ primary_domain ~ '$') -# | reject('equalto', primary_domain) -# | map('regex_replace', '^(.*)$', '{ source: \"www.\\1\", target: \"\\1\" }') -# | map('from_yaml') -# | list }}" -# notify: restart nginx -# when: -# - enable_wildcard_certificate | bool # Wildcard certificate must be enabled -# - mode_cleanup | bool # Cleanup mode must be enabled - -- name: Cleanup {{nginx_www_wildcard_configuration}} - file: - path: "{{nginx_www_wildcard_configuration}}" - state: absent - notify: restart nginx - when: - - not enable_wildcard_certificate | bool - - mode_cleanup | bool + domain_mappings: "{{ domain_mappings }}" + when: certbot_flavor == 'dedicated' diff --git a/roles/nginx-redirect-www/templates/www.wildcard.conf.j2 b/roles/nginx-redirect-www/templates/www.wildcard.conf.j2 deleted file mode 100644 index 92d05e6c..00000000 --- a/roles/nginx-redirect-www/templates/www.wildcard.conf.j2 +++ /dev/null @@ -1,6 +0,0 @@ -server { - server_name ~^www\.(?.+)$; - {% include 'roles/letsencrypt/templates/ssl_header.j2' %} - - return 301 https://$domain$request_uri; -} \ No newline at end of file diff --git a/roles/nginx-redirect-www/vars/main.yml b/roles/nginx-redirect-www/vars/main.yml deleted file mode 100644 index fb54e48c..00000000 --- a/roles/nginx-redirect-www/vars/main.yml +++ /dev/null @@ -1 +0,0 @@ -nginx_www_wildcard_configuration: "{{nginx.directories.http.global}}www.wildcard.conf" \ No newline at end of file diff --git a/roles/pkgmgr-install/tasks/main.yml b/roles/pkgmgr-install/tasks/main.yml index 69d17d10..ec4855b6 100644 --- a/roles/pkgmgr-install/tasks/main.yml +++ b/roles/pkgmgr-install/tasks/main.yml @@ -9,7 +9,10 @@ source ~/.venvs/pkgmgr/bin/activate pkgmgr update {{ package_name }} --dependencies --clone-mode https notify: "{{ package_notify | default(omit) }}" - + register: pkgmgr_update_result + changed_when: "'No command defined and neither main.sh nor main.py found' not in pkgmgr_update_result.stdout" + failed_when: pkgmgr_update_result.rc != 0 and 'No command defined and neither main.sh nor main.py found' not in pkgmgr_update_result.stdout + - name: mark pkgmgr update as done set_fact: run_once_pkgmgr_update: true diff --git a/tasks/constructor.yml b/tasks/constructor.yml index 2b364554..e836d930 100644 --- a/tasks/constructor.yml +++ b/tasks/constructor.yml @@ -62,15 +62,32 @@ set_fact: service_provider: "{{ defaults_service_provider | combine(service_provider | default({}, true), recursive=True) }}" + - name: Collect all domains (domains, redirect sources + www) + set_fact: + all_domains: >- + {{ + ( + ( + domains.values() | flatten + + (redirect_domain_mappings | map(attribute='source') | list) + ) + + ( + domains.values() | flatten + + (redirect_domain_mappings | map(attribute='source') | list) + ) | map('regex_replace', '^(.*)$', 'www.\\1') | list + ) | unique | sort + }} + - name: "Merged Variables" # Add new merged variables here debug: msg: - domains: "{{domains}}" - applications: "{{applications}}" - oidc: "{{oidc}}" - service_provider: "{{service_provider}}" - users: "{{users}}" + domains: "{{ domains }}" + applications: "{{ applications }}" + oidc: "{{ oidc }}" + service_provider: "{{ service_provider }}" + users: "{{ users }}" + all_domains: "{{ all_domains }}" when: enable_debug | bool - name: init root user