diff --git a/roles/web-opt-rdr-www/Todo.md b/roles/web-opt-rdr-www/Todo.md new file mode 100644 index 00000000..fa23d02d --- /dev/null +++ b/roles/web-opt-rdr-www/Todo.md @@ -0,0 +1,2 @@ +# To-dos +- Test flavor 'edge' \ No newline at end of file diff --git a/roles/web-opt-rdr-www/config/main.yml b/roles/web-opt-rdr-www/config/main.yml new file mode 100644 index 00000000..e7ec54b8 --- /dev/null +++ b/roles/web-opt-rdr-www/config/main.yml @@ -0,0 +1,5 @@ +# The following defines the redirect flavor +# Possible options +# - 'edge' for configuration via cloudflare +# - 'origin' via native nginx +prefered_flavor: "origin" diff --git a/roles/web-opt-rdr-www/meta/main.yml b/roles/web-opt-rdr-www/meta/main.yml index 444b0640..6b3ce31c 100644 --- a/roles/web-opt-rdr-www/meta/main.yml +++ b/roles/web-opt-rdr-www/meta/main.yml @@ -1,6 +1,6 @@ galaxy_info: author: "Kevin Veen-Birkenbach" - description: "An Ansible role to redirect www subdomains to non-www domains in Nginx" + description: "An Ansible role to redirect www subdomains to bare domains (apex). Supports Cloudflare edge redirects or local Nginx redirects." license: "Infinito.Nexus NonCommercial License" license_url: "https://s.infinito.nexus/license" company: | @@ -9,15 +9,15 @@ galaxy_info: https://www.veen.world min_ansible_version: "2.9" platforms: - - name: Archlinux - versions: - - rolling + - name: "Archlinux" + versions: + - "rolling" galaxy_tags: - - nginx - - redirect - - www - - wildcard - - seo + - nginx + - redirect + - www + - cloudflare + - seo repository: "https://s.infinito.nexus/code" issue_tracker_url: "https://s.infinito.nexus/issues" documentation: "https://docs.infinito.nexus" diff --git a/roles/web-opt-rdr-www/tasks/cloudflare_redirect_rule.yml b/roles/web-opt-rdr-www/tasks/cloudflare_redirect_rule.yml new file mode 100644 index 00000000..9714e3af --- /dev/null +++ b/roles/web-opt-rdr-www/tasks/cloudflare_redirect_rule.yml @@ -0,0 +1,134 @@ +# This task file ensures that a Cloudflare "Dynamic Redirect Rule" exists +# which redirects www.example.com → https://example.com{uri} with 301. + +# Required vars to pass in: +# www_fqdn: "www.example.com" +# apex_url: "https://example.com" +# CLOUDFLARE_API_TOKEN + +- name: "Derive zone and apex from www_fqdn" + set_fact: + cf_zone_name: "{{ www_fqdn | to_zone }}" # e.g. "cymais.cloud" + domain_apex: "{{ www_fqdn | regex_replace('^www\\.', '') }}" # e.g. "academy.cymais.cloud" + +- name: "Cloudflare: Lookup zone id for {{ cf_zone_name }}" + ansible.builtin.uri: + url: "https://api.cloudflare.com/client/v4/zones?name={{ cf_zone_name }}" + method: GET + headers: + Authorization: "Bearer {{ CLOUDFLARE_API_TOKEN }}" + Content-Type: "application/json" + return_content: true + register: cf_zone_lookup + failed_when: > + (cf_zone_lookup.status != 200) or + (cf_zone_lookup.json.result | length) == 0 + +- name: "Set fact: zone_id" + set_fact: + cf_zone_id: "{{ (cf_zone_lookup.json.result | first).id }}" + +- name: "Remove conflicting A/AAAA record for {{ www_fqdn }}" + community.general.cloudflare_dns: + api_token: "{{ CLOUDFLARE_API_TOKEN }}" + zone: "{{ cf_zone_name }}" + type: "A" + name: "{{ www_fqdn }}" + state: absent + ignore_errors: true + +- name: "Cloudflare DNS: ensure {{ www_fqdn }} is proxied (CNAME)" + community.general.cloudflare_dns: + api_token: "{{ CLOUDFLARE_API_TOKEN }}" + zone: "{{ cf_zone_name }}" + type: "CNAME" + name: "{{ www_fqdn }}" + value: "{{ domain_apex }}" + proxied: true + ttl: 1 + state: present + +# 1) Fetch existing redirect rulesets +- name: "Cloudflare Rulesets: list dynamic redirect rulesets" + ansible.builtin.uri: + url: "https://api.cloudflare.com/client/v4/zones/{{ cf_zone_id }}/rulesets?phase=http_request_dynamic_redirect" + method: GET + headers: + Authorization: "Bearer {{ CLOUDFLARE_API_TOKEN }}" + Content-Type: "application/json" + return_content: true + register: cf_rulesets + +- name: "Pick existing custom ruleset (if any)" + set_fact: + cf_redirect_ruleset: >- + {{ + (cf_rulesets.json.result | default([])) + | selectattr('kind','equalto','zone') + | list + | first + }} + cf_redirect_ruleset_id: "{{ (cf_redirect_ruleset.id | default('')) }}" + +# Desired redirect rule object +- name: "Build desired redirect rule object" + set_fact: + desired_redirect_rule: + ref: "redirect_www_to_apex" + description: "Redirect www → apex (301)" + expression: "http.host eq \"{{ www_fqdn }}\"" + action: "redirect" + action_parameters: + from_value: + target_url: + expression: "\"{{ apex_url }}\" + http.request.uri.path + (len(http.request.uri.query) > 0 ? (\"?\" + http.request.uri.query) : \"\")" + status_code: 301 + preserve_query_string: true + +# 2) Update existing ruleset (PUT) +- name: "Update existing dynamic redirect ruleset (PUT)" + when: cf_redirect_ruleset_id | length > 0 + vars: + existing_rules: "{{ cf_redirect_ruleset.rules | default([]) }}" + merged_rules: >- + {{ + ( + (existing_rules | rejectattr('ref','equalto', desired_redirect_rule.ref) | list) + + [ desired_redirect_rule ] + ) + }} + ansible.builtin.uri: + url: "https://api.cloudflare.com/client/v4/zones/{{ cf_zone_id }}/rulesets/{{ cf_redirect_ruleset_id }}" + method: PUT + headers: + Authorization: "Bearer {{ CLOUDFLARE_API_TOKEN }}" + Content-Type: "application/json" + status_code: 200 + body_format: json + body: + name: "{{ cf_redirect_ruleset.name | default('www-to-apex') }}" + kind: "zone" + phase: "http_request_dynamic_redirect" + rules: "{{ merged_rules }}" + register: cf_ruleset_update + changed_when: cf_ruleset_update.status in [200] + +# 3) Create new ruleset (POST) +- name: "Create dynamic redirect ruleset (POST)" + when: cf_redirect_ruleset_id | length == 0 + ansible.builtin.uri: + url: "https://api.cloudflare.com/client/v4/zones/{{ cf_zone_id }}/rulesets" + method: POST + headers: + Authorization: "Bearer {{ CLOUDFLARE_API_TOKEN }}" + Content-Type: "application/json" + status_code: 200 + body_format: json + body: + name: "www-to-apex" + kind: "zone" + phase: "http_request_dynamic_redirect" + rules: + - "{{ desired_redirect_rule }}" + register: cf_ruleset_create + changed_when: cf_ruleset_create.status in [200] diff --git a/roles/web-opt-rdr-www/tasks/main.yml b/roles/web-opt-rdr-www/tasks/main.yml index 248e14d7..7dcd5552 100644 --- a/roles/web-opt-rdr-www/tasks/main.yml +++ b/roles/web-opt-rdr-www/tasks/main.yml @@ -6,22 +6,19 @@ - include_tasks: utils/run_once.yml when: run_once_web_opt_rdr_www is not defined -- name: Filter www-prefixed domains from current_play_domains_all - set_fact: - www_domains: "{{ current_play_domains_all | select('match', '^www\\.') | list }}" - - name: Include web-opt-rdr-domains role for www-to-bare redirects include_role: name: web-opt-rdr-domains vars: - domain_mappings: "{{ www_domains | map('regex_replace', '^www\\.(.+)$', '{ source: \"www.\\1\", target: \"\\1\" }') | map('from_yaml') | list }}" + domain_mappings: "{{ REDIRECT_WWW_DOMAINS | map('regex_replace', '^www\\.(.+)$', '{ source: \"www.\\1\", target: \"\\1\" }') | map('from_yaml') | list }}" + when: REDIRECT_WWW_FLAVOR == 'origin' - name: Include DNS role to set redirects include_role: name: sys-dns-cloudflare-records vars: cloudflare_records: | - {%- set bare = www_domains | map('regex_replace', '^www\\.(.+)$', '\\1') | list -%} + {%- set bare = REDIRECT_WWW_DOMAINS | map('regex_replace', '^www\\.(.+)$', '\\1') | list -%} [ {%- for d in bare -%} { @@ -29,10 +26,22 @@ "zone": "{{ d | to_zone }}", "name": "{{ d }}", "content": "{{ networks.internet.ip4 }}", - "proxied": false, + "proxied": {{ REDIRECT_WWW_FLAVOR == 'edge' }}, "ttl": 1 }{{ "," if not loop.last else "" }} {%- endfor -%} ] - when: DNS_PROVIDER == 'cloudflare' + when: + - DNS_PROVIDER == 'cloudflare' + - REDIRECT_WWW_FLAVOR == 'origin' +- name: Include Cloudflare redirect rule to enforce www → apex + include_tasks: cloudflare_redirect_rule.yml + vars: + domain: "{{ item | regex_replace('^www\\.', '') }}" + www_fqdn: "{{ item }}" + apex_url: "{{ WEB_PROTOCOL }}://{{ item | regex_replace('^www\\.', '') }}" + loop: "{{ REDIRECT_WWW_DOMAINS }}" + when: REDIRECT_WWW_FLAVOR == 'edge' + + diff --git a/roles/web-opt-rdr-www/vars/main.yml b/roles/web-opt-rdr-www/vars/main.yml index e1b3686e..8fe08189 100644 --- a/roles/web-opt-rdr-www/vars/main.yml +++ b/roles/web-opt-rdr-www/vars/main.yml @@ -1,2 +1,6 @@ +# General application_id: "web-opt-rdr-www" -REDIRECT_WWW_FLAVOR: "edge" \ No newline at end of file + +# Redirect WWW +REDIRECT_WWW_FLAVOR: "{{ applications | get_app_conf(application_id, 'prefered_flavor') if DNS_PROVIDER == 'cloudflare' else 'origin' }}" +REDIRECT_WWW_DOMAINS: "{{ current_play_domains_all | select('match', '^www\\.') | list }}" \ No newline at end of file diff --git a/tests/integration/test_domains_canonical.py b/tests/integration/test_domains_canonical.py deleted file mode 100644 index 69ac02aa..00000000 --- a/tests/integration/test_domains_canonical.py +++ /dev/null @@ -1,36 +0,0 @@ -import unittest -import yaml -import glob -import os - -class TestWebRolesDomains(unittest.TestCase): - def test_canonical_domains_present_and_not_empty(self): - """ - Check all roles/web-*/config/main.yml files: - - must have domains.canonical defined - - domains.canonical must not be empty dict, empty list, or empty string - """ - role_config_paths = glob.glob("roles/web-*/config/main.yml") - self.assertTrue(role_config_paths, "No roles/web-*/config/main.yml files found.") - - for path in role_config_paths: - with self.subTest(role_config=path): - with open(path, "r") as f: - data = yaml.safe_load(f) - - self.assertIsInstance(data, dict, f"YAML root is not a dict in {path}") - - domains = data.get('server',{}).get('domains',{}) - self.assertIsNotNone(domains, f"'domains' section missing in {path}") - self.assertIsInstance(domains, dict, f"'domains' must be a dict in {path}") - - canonical = domains.get("canonical") - self.assertIsNotNone(canonical, f"'server.domains.canonical' missing in {path}") - - # Check for emptiness - empty_values = [{}, [], ""] - self.assertNotIn(canonical, empty_values, - f"'server.domains.canonical' in {path} must not be empty dict, list, or empty string") - -if __name__ == "__main__": - unittest.main()