From f59d2b72fb1e28c238bc6e0d8626d74da991f05c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 13:04:11 -0500 Subject: [PATCH] www: redirect alias hostnames (e.g. apex) to canonical origin Adds www_redirect_hostnames; renders a www-redirect ingress that 301s to https://$request_uri while leaving more specific paths on the same host (e.g. /.well-known/matrix/*) to their own ingresses. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++++- defaults/main.yml | 2 ++ templates/website.yml.j2 | 36 ++++++++++++++++++++++++++++++++++++ tests/render.yml | 1 + tests/validate_manifests.py | 3 +++ 5 files changed, 47 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1b70d2f..b39f58f 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Defaults deliberately refuse incomplete membership deployments. Configure: | --- | --- | | `www_namespace` | Namespace, default `www` | | `www_hostname` | Canonical HTTPS hostname, default **www.eom.dev** | +| `www_redirect_hostnames` | Hostnames 301-redirected to `www_hostname`, default `[]` | | `www_membership_enabled` | Deploy membership/proxy, default true | | `www_membership_image` | Required versioned application image/digest | | `www_membership_secret_name` | Existing Secret, default `membership` | @@ -239,7 +240,10 @@ app defaults to eom.dev. This role sets `MEMBERSHIP_PUBLIC_URL` to To serve the apex instead, set `www_hostname: eom.dev` and align DNS, certificates, callbacks and redirects. Membership requires one canonical HTTPS -origin for sessions, CSRF, email verification and OIDC. +origin for sessions, CSRF, email verification and OIDC. To send apex visitors +to the canonical origin without serving it there, set +`www_redirect_hostnames: [eom.dev]`; other ingresses' more specific apex paths +(such as `/.well-known/matrix/*`) keep working. ## Upgrades and operations diff --git a/defaults/main.yml b/defaults/main.yml index 7f75eab..eebd5aa 100644 --- a/defaults/main.yml +++ b/defaults/main.yml @@ -15,6 +15,8 @@ www_repo_url: https://git.example.com/organization/www.git www_namespace: www www_hostname: www.example.com www_ingress_class: nginx +# Extra hostnames 301-redirected to https://{{ www_hostname }} (e.g. [eom.dev]). +www_redirect_hostnames: [] www_cluster_issuer: letsencrypt-prod # Set to your published website image, including any required metrics exporter. www_httpd_image: registry.example.com/organization/www:release diff --git a/templates/website.yml.j2 b/templates/website.yml.j2 index 83bd64b..e1a382b 100755 --- a/templates/website.yml.j2 +++ b/templates/website.yml.j2 @@ -171,3 +171,39 @@ spec: tls: - hosts: [{{ www_hostname }}] secretName: www +{% if www_redirect_hostnames | length > 0 %} +--- +# Send browsers on alias hostnames (e.g. the apex, whose /.well-known/matrix/* +# paths are owned by another ingress) to the canonical origin. Exact/longer +# paths on the same host in other ingresses still take precedence over "/". +# A snippet is used because the controller's annotation validation rejects +# "$request_uri" in permanent-redirect. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: www-redirect + namespace: {{ www_namespace }} + annotations: + cert-manager.io/cluster-issuer: {{ www_cluster_issuer | to_json }} + nginx.ingress.kubernetes.io/ssl-redirect: "true" + nginx.ingress.kubernetes.io/enable-access-log: "false" + nginx.ingress.kubernetes.io/configuration-snippet: {{ ("return 301 https://" ~ www_hostname ~ "$request_uri;") | to_json }} +spec: + ingressClassName: {{ www_ingress_class }} + rules: +{% for host in www_redirect_hostnames %} + - host: {{ host }} + http: + paths: + - pathType: Prefix + path: / + backend: + service: + name: httpd + port: + number: 80 +{% endfor %} + tls: + - hosts: {{ www_redirect_hostnames | to_json }} + secretName: www-redirect +{% endif %} diff --git a/tests/render.yml b/tests/render.yml index f448e10..3ca28ab 100755 --- a/tests/render.yml +++ b/tests/render.yml @@ -11,6 +11,7 @@ - name: Supply nonsecret fixture settings ansible.builtin.set_fact: www_membership_image: registry.example.org/www-membership:release-test + www_redirect_hostnames: [apex.example.com] www_membership_service_groups: "0": ["cn=free-service,ou=groups,dc=example,dc=org"] "2": ["cn=paid-service,ou=groups,dc=example,dc=org", "cn=second-service,ou=groups,dc=example,dc=org"] diff --git a/tests/validate_manifests.py b/tests/validate_manifests.py index cba3cad..0579293 100755 --- a/tests/validate_manifests.py +++ b/tests/validate_manifests.py @@ -92,6 +92,9 @@ ingress = find(all_objects, "Ingress", "www") assert ingress["metadata"]["annotations"]["nginx.ingress.kubernetes.io/enable-access-log"] == "false" assert ingress["metadata"]["annotations"]["nginx.ingress.kubernetes.io/proxy-body-size"] == "2m" assert ingress["spec"]["rules"][0]["http"]["paths"][0]["backend"]["service"]["name"] == "httpd" +redirect = find(all_objects, "Ingress", "www-redirect") +assert redirect["spec"]["rules"][0]["host"] == "apex.example.com" +assert redirect["metadata"]["annotations"]["nginx.ingress.kubernetes.io/configuration-snippet"] == "return 301 " + config["MEMBERSHIP_PUBLIC_URL"] + "$request_uri;" for obj in docs("external-membership-workloads"): if obj["kind"] == "Deployment": pod_checks(obj["spec"]["template"]["spec"], external=True)