Implemented SAN via Letsencrypt and Certbot

This commit is contained in:
Kevin Veen-Birkenbach 2025-04-28 16:47:51 +02:00
parent 0fc9c3e495
commit 04deeef385
No known key found for this signature in database
GPG Key ID: 44D8F11FD62F878E
28 changed files with 411 additions and 224 deletions

View File

@ -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
# 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

View File

@ -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: []

157
library/find_cert_folder.py Normal file
View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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}}"

View File

@ -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

View File

@ -0,0 +1,24 @@
# Lets Encrypt SSL for Nginx 🔐
## Description
Automates obtaining, configuring, and renewing Lets 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
Its idempotent: configuration and certificate tasks only run when needed. ✅
## Purpose
Ensure all your Nginx-hosted sites use free, trusted SSL certificates from Lets Encrypt—all managed automatically via Ansible. 🎯
## Features
- **Automatic Certificate Issuance**: Uses Certbots webroot plugin to request and install certificates. 📜
- **Nginx Redirect**: Creates a temporary HTTP → HTTPS redirect block. ↪️
- **ACMEChallenge Handling**: Configures `/.well-known/acme-challenge/` for Certbot validation. 🔍
- **Secure SSL Defaults**: Includes modern cipher suites, HSTS, OCSP stapling, and session settings. 🔒
- **AutoRenewal**: Leverages system scheduling (cron or systemd timer) to renew certs before expiration. 🔄
- **OneTime Setup**: Tasks guarded by a “run once” fact to avoid re-applying unchanged templates. 🏃‍♂️

View File

@ -1,2 +1,26 @@
---
galaxy_info:
author: "Kevin Veen-Birkenbach"
description: "An Ansible role to automate Lets 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

View File

@ -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;
}

View File

@ -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;
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;

View File

@ -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)**.

View File

@ -2,21 +2,21 @@
# Check if the necessary parameters are provided
if [ "$#" -ne 2 ]; then
echo "Usage: $0 <domain> <docker_compose_instance_directory>"
echo "Usage: $0 <ssl_cert_folder> <docker_compose_instance_directory>"
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/"*

View File

@ -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}}

View File

@ -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:

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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
ssl_cert_folder: "{{ cert_folder_result.folder }}"

View File

@ -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.
## 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. 🐛

View File

@ -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

View File

@ -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'

View File

@ -1,6 +0,0 @@
server {
server_name ~^www\.(?<domain>.+)$;
{% include 'roles/letsencrypt/templates/ssl_header.j2' %}
return 301 https://$domain$request_uri;
}

View File

@ -1 +0,0 @@
nginx_www_wildcard_configuration: "{{nginx.directories.http.global}}www.wildcard.conf"

View File

@ -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

View File

@ -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