Compare commits

...
3 Commits
Author SHA1 Message Date
claudeandClaude Opus 5.5 f59d2b72fb www: redirect alias hostnames (e.g. apex) to canonical origin
Adds www_redirect_hostnames; renders a www-redirect ingress that 301s
to https://<www_hostname>$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 <noreply@anthropic.com>
2026-11-07 13:04:11 -05:00
claudeandClaude Opus 5.5 eea6389a5a www: pass listmonk newsletter settings to membership
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 20:27:43 -05:00
ClaudeandClaude Opus 5.5 d694ae5650 www: redirect /members to /members/, ensure membership LDAP groups
Service sign-up buttons link to /members, which 404'd because only
/members/ is proxied. Use an absolute https target since TLS ends at
the ingress.

Add an opt-in task that creates any missing tier/service group from
the role's config (with the placeholder member) in the OpenLDAP pod,
leaving existing groups untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 18:33:25 -05:00
7 changed files with 89 additions and 2 deletions
+5 -1
View File
@@ -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
+13
View File
@@ -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
@@ -131,6 +133,11 @@ www_membership_environment:
SHKEEPER_URL: ""
SHKEEPER_CURRENCIES: ""
SHKEEPER_CA_CERT_FILE: ""
# Newsletter opt-in at signup and on the profile. Enabled only when the
# LISTMONK_API_TOKEN Secret key is also present.
LISTMONK_URL: ""
LISTMONK_API_USER: ""
LISTMONK_LIST_NAME: eom.dev
# Existing LIVE Stripe prices. Level 0 is free and is not configured as a paid tier.
# Provision the dedicated paid-group DNs separately.
@@ -204,3 +211,9 @@ www_membership_tiers:
- Exclusive polls on the direction of the platform
- Optional Diamond credit on the supporters page
- Your biggest boost for independent public projects
# Create any missing tier/service groups (with the placeholder member) in the
# OpenLDAP pod. Existing groups and their members are left untouched.
www_membership_ldap_ensure_groups: false
www_membership_ldap_namespace: openldap
www_membership_ldap_pod_selector: app=openldap
+28 -1
View File
@@ -34,6 +34,7 @@
- not www_membership_shkeeper_enabled or (www_membership_environment.SHKEEPER_URL is match('^https://[^/@]+') and www_membership_environment.SHKEEPER_CURRENCIES | length > 0)
- www_membership_shkeeper_enabled or (www_membership_environment.SHKEEPER_URL | length == 0 and www_membership_environment.SHKEEPER_CURRENCIES | length == 0)
- not www_membership_stripe_enabled or www_membership_environment.STRIPE_PORTAL_CONFIGURATION | length > 0
- (www_membership_environment.LISTMONK_URL | default('')) | length == 0 or www_membership_environment.LISTMONK_URL is match('^https://[^/@]+$')
- not www_membership_postgres_managed or www_membership_environment.POSTGRES_HOST == 'membership-db'
- www_membership_postgres_managed or www_membership_environment.POSTGRES_SSLMODE == 'verify-full'
- not www_membership_postgres_managed or www_membership_environment.POSTGRES_PORT | string == '5432'
@@ -61,7 +62,7 @@
- name: Define secret-only settings
tags: [membership_validate]
ansible.builtin.set_fact:
_www_secret_keys: [MEMBERSHIP_SECRET_KEY, POSTGRES_PASSWORD, DEX_CLIENT_SECRET, LDAP_BIND_PASSWORD, EMAIL_HOST_PASSWORD, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, SHKEEPER_API_KEY]
_www_secret_keys: [MEMBERSHIP_SECRET_KEY, POSTGRES_PASSWORD, DEX_CLIENT_SECRET, LDAP_BIND_PASSWORD, EMAIL_HOST_PASSWORD, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, SHKEEPER_API_KEY, LISTMONK_API_TOKEN]
_www_required_secret_keys: "{{ ['MEMBERSHIP_SECRET_KEY', 'POSTGRES_PASSWORD', 'DEX_CLIENT_SECRET', 'LDAP_BIND_PASSWORD'] + (['STRIPE_SECRET_KEY', 'STRIPE_WEBHOOK_SECRET'] if www_membership_stripe_enabled else []) + (['SHKEEPER_API_KEY'] if www_membership_shkeeper_enabled else []) }}"
- name: Prevent secrets in public ConfigMaps
@@ -207,3 +208,29 @@
status: "True"
loop: [membership, membership-worker]
when: not ansible_check_mode
- name: Find the OpenLDAP pod
kubernetes.core.k8s_info:
kind: Pod
namespace: "{{ www_membership_ldap_namespace }}"
label_selectors: ["{{ www_membership_ldap_pod_selector }}"]
field_selectors: [status.phase=Running]
register: www_ldap_pods
when: www_membership_ldap_ensure_groups | bool
- name: Ensure membership tier and service groups exist in LDAP
kubernetes.core.k8s_exec:
namespace: "{{ www_membership_ldap_namespace }}"
pod: "{{ www_ldap_pods.resources[0].metadata.name }}"
command: >-
sh -c 'printf "%s\n" "dn: {{ item }}" "objectClass: groupOfUniqueNames"
"cn: {{ (item | split(","))[0] | regex_replace("^cn=", "") }}"
"uniqueMember: {{ (www_membership_environment | combine(www_membership_environment_overrides)).LDAP_GROUP_PLACEHOLDER_DN }}"
| ldapadd -x -H ldap://localhost -D "cn=admin,dc=eom,dc=dev" -w "$LDAP_ADMIN_PASSWORD"; rc=$?;
if [ $rc -eq 0 ]; then echo GROUP_CREATED; elif [ $rc -ne 68 ]; then exit $rc; fi'
loop: >-
{{ ((www_membership_tiers.values() | map(attribute='group_dn') | list)
+ (www_membership_service_groups.values() | flatten)) | unique }}
register: www_ldap_groups
changed_when: "'GROUP_CREATED' in (www_ldap_groups.stdout | default(''))"
when: www_membership_ldap_ensure_groups | bool
+3
View File
@@ -1,6 +1,9 @@
{% if www_membership_enabled %}
ProxyRequests Off
ProxyPreserveHost On
# Service sign-up buttons link to /members; the app lives under /members/.
# Absolute target: TLS ends at ingress, so a relative one becomes http://...:443
RedirectMatch 301 "^/members$" "https://{{ www_hostname }}/members/"
ProxyPass "/members/" "http://membership:8000/members/" connectiontimeout=5 timeout=120
ProxyPassReverse "/members/" "http://membership:8000/members/"
<Location "/members/">
+36
View File
@@ -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 %}
+1
View File
@@ -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"]
+3
View File
@@ -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)