mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2025-08-18 17:55:09 +02:00
feat(web-opt-rdr-www): split flavors into edge (Cloudflare redirect rule) and origin (Nginx redirect) with dynamic selection via prefered_flavor
This commit is contained in:
parent
bd2dde3af6
commit
2a1a956739
2
roles/web-opt-rdr-www/Todo.md
Normal file
2
roles/web-opt-rdr-www/Todo.md
Normal file
@ -0,0 +1,2 @@
|
||||
# To-dos
|
||||
- Test flavor 'edge'
|
5
roles/web-opt-rdr-www/config/main.yml
Normal file
5
roles/web-opt-rdr-www/config/main.yml
Normal file
@ -0,0 +1,5 @@
|
||||
# The following defines the redirect flavor
|
||||
# Possible options
|
||||
# - 'edge' for configuration via cloudflare
|
||||
# - 'origin' via native nginx
|
||||
prefered_flavor: "origin"
|
@ -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"
|
||||
|
134
roles/web-opt-rdr-www/tasks/cloudflare_redirect_rule.yml
Normal file
134
roles/web-opt-rdr-www/tasks/cloudflare_redirect_rule.yml
Normal file
@ -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]
|
@ -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'
|
||||
|
||||
|
||||
|
@ -1,2 +1,6 @@
|
||||
# General
|
||||
application_id: "web-opt-rdr-www"
|
||||
REDIRECT_WWW_FLAVOR: "edge"
|
||||
|
||||
# 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 }}"
|
@ -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()
|
Loading…
x
Reference in New Issue
Block a user