Files
ansible-role-www/README.md
T
2026-10-29 14:24:47 -04:00

317 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
Role Name
=========
A brief description of the role goes here.
Requirements
# ansible-role-www
Deploy the eom.dev website and membership services to **Kubernetes**, not the
development Docker Compose stack.
## What is deployed
- Existing namespace, `data` ReadWriteMany archive PVC, Apache, HTTP/Prometheus
ClusterIP services, TLS ingress and optional Tor OnionService.
- Website source from `www_repo_url` at `www_repo_version`. Static content is
copied without deleting the persistent archive.
- Public membership ConfigMaps, the five existing live Stripe tiers
($4/$8/$16/$32/$64 USD monthly), optional SHKeeper prepaid crypto and Secret
references. LDAP/Dex and both payment providers use the same application.
- Persistent PostgreSQL 17 StatefulSet with a separate ReadWriteOnce claim, or
an external PostgreSQL server using `verify-full`.
- A release/configuration-specific migration Job, completed **before** new web
and worker Deployments are applied. Failed migrations stop the role.
- Non-root, read-only membership web pods and one reconciliation worker, resource
limits, probes, optional CA mounts and no service-account token automounts.
NetworkPolicy allows membership web ingress only from Apache and database
ingress only from membership clients. Worker ingress is denied.
The role does **not** provision LDAP accounts/groups/ACLs, Dex clients, Stripe
products/webhooks, SHKeeper wallets, image builds or database backups. Review the
application's deployment instructions before launch.
## Requirements
- Ansible Core 2.15+ on a Linux/WSL controller, the collections in
[requirements.yml](requirements.yml), Kubernetes Python client and authorized
kubeconfig. No application pod needs Kubernetes API permissions.
- Kubernetes with StatefulSets, Jobs, Ingress/NetworkPolicy, a CNI that enforces
NetworkPolicy, default persistent StorageClass, and archive RWX provisioner.
- Ingress-nginx, cert-manager (`ca-issuer` by default), and the Tor controller
with OnionService CRD when `www_onion_enabled` is true.
- A **built and pushed** image from the www repository's membership Dockerfile,
including Stripe and SHKeeper migrations. Set `www_membership_image` to a
versioned tag, ideally an immutable digest. No unpublished image is assumed
and nothing is built inside the cluster. The image contract is `/app`,
Python/manage.py, UID/GID 10001, port 8000 and the Gunicorn default command.
## Required configuration
Defaults deliberately refuse incomplete membership deployments. Configure:
| Variable | Purpose / default |
| --- | --- |
| `www_namespace` | Namespace, default `www` |
| `www_hostname` | Canonical HTTPS hostname, default **www.eom.dev** |
| `www_membership_enabled` | Deploy membership/proxy, default true |
| `www_membership_image` | Required versioned application image/digest |
| `www_membership_secret_name` | Existing Secret, default `membership` |
| `www_membership_secret_data` | Optional Vault/lookup mapping to manage Secret; default empty |
| `www_membership_environment_overrides` | Partial public overrides merged with defaults |
| `www_membership_tiers` | Tier mapping with label, price_id, price_label, usd_amount, group_dn |
| `www_membership_replicas` | Web replicas, default 2 |
| `www_membership_worker_interval` | Seconds after full sweep, default 60 |
| `www_membership_ca_secret_name` | Optional CA Secret mounted at `/etc/membership/ca` |
| `www_membership_stripe_enabled` | Require Stripe settings/secrets, default true |
| `www_membership_shkeeper_enabled` | Require SHKeeper settings/key, default false |
| `www_membership_postgres_managed` | Provision local PostgreSQL, default true |
| `www_membership_postgres_storage_size` | Default 10Gi |
| `www_membership_postgres_storage_class` | Empty uses cluster default |
| `www_repo_url` / `www_repo_version` | Public source URL and ref/commit; default existing URL / HEAD |
| `www_rollout_revision` | Optional marker to force source/config rollout |
| `www_image_pull_secrets` | List of `{name: registry-secret}` objects |
| `www_onion_enabled` | Retain static Tor mirror, default true |
Public overrides must include nonempty `LDAP_BIND_DN`, `EMAIL_HOST`, verified
`LDAP_PASSWORD_SCHEME: '{ARGON2}'`, and the correct Dex issuer/client/connector.
Stripe needs `STRIPE_PORTAL_CONFIGURATION`. SMTP STARTTLS, production PostgreSQL
and HTTPS are required; production SQLite is rejected. A complete override of
`www_membership_environment` is possible but must preserve mandatory defaults.
LDAP uses verified LDAPS/StartTLS by default. For deliberate plaintext LDAP
inside a trusted cluster, set `LDAP_URL: ldap://openldap.openldap.svc.cluster.local:389`
and `LDAP_ALLOW_INSECURE: "1"` in `www_membership_environment_overrides`, then
redeploy with a membership image containing this option. This applies to both web
and worker and sends bind/registration passwords and directory data unencrypted.
The default is `"0"`; `ldaps://` remains certificate-verified regardless of this
option. There is no automatic downgrade after a TLS failure.
Payment flags validate operator intent; **actual application availability is
controlled by its environment, secrets and tiers**. Disabling Stripe validation
alone does not remove its button. Set an empty tier mapping to disable all paid
tiers. SHKeeper needs both its flag and URL/currencies plus API key in the Secret.
It prepays one calendar month per invoice, without automatic renewal.
Defaults reuse product `prod_Sc9N5J0vNAdLCn` and the same five live Price IDs as
the application. Level 0 is the free account baseline; paid offers are levels
1–5. Provision dedicated `www-level-1` through `www-level-5`
groupOfUniqueNames groups, sentinel and ACLs separately. Override group DNs if
needed; sandbox testing requires separate test-mode prices.
### Service access groups, including free Level 0
Set `www_membership_service_groups` to a mapping of **quoted effective level**
keys to lists of pre-created LDAP group DNs. The default `{}` grants no additional
groups. For example:
```yaml
www_membership_service_groups:
"0": ["cn=community-chat,ou=membership-services,dc=example,dc=org"]
"1": ["cn=community-chat,ou=membership-services,dc=example,dc=org"]
"2":
- "cn=community-chat,ou=membership-services,dc=example,dc=org"
- "cn=game-servers,ou=membership-services,dc=example,dc=org"
```
The highest valid paid membership selects exactly that level's list. **No implicit
inheritance**: include groups again for levels 3–5 if they should also receive
them. Level 0 is selected only when no paid entitlement remains; repeat a free
service DN at every level to offer it to all members. Original tier `group_dn`
grants are unchanged and independent of these additional service groups.
The role serializes the mapping into the existing membership-tiers ConfigMap as
`service-groups.json`, sets `MEMBERSHIP_SERVICE_GROUPS_FILE`, and includes it in
the configuration revision so updates trigger workload rollout. Do not override
it using `MEMBERSHIP_SERVICE_GROUPS_JSON` in the public environment. Publish an
updated membership image before deploying this role change; no new DB migration
is needed. See [tests/example-vars.yml](tests/example-vars.yml) for all six levels.
Targets must be dedicated `groupOfUniqueNames` groups with the existing sentinel;
provision groups and narrow `uniqueMember` worker ACLs separately. Configure each
service/Dex to authorize those groups. Never map admin/staff or independently
managed groups: the worker **removes noneligible local members**, including
manually granted memberships. Keep retired groups/ACLs until revocation completes.
Historical group targets persist for safe removal after mappings change. Expiry
returns users to level 0 without waiting for Stripe/SHKeeper, but LDAP outages
and downstream cached sessions can still delay access changes. Free grants cover
local membership accounts, not untouched users elsewhere in LDAP.
### Secrets
Create an Opaque Secret using External Secrets, Sealed Secrets or your secret
manager, or provide `www_membership_secret_data` from **Ansible Vault**/an external
lookup. Role-managed values use base64 `data` for repeatable reconciliation.
Secret create/read/validation tasks use `no_log: true`, and diffs are disabled.
Base64 is not encryption: enable etcd encryption at rest and appropriate RBAC.
Never put credentials in public overrides or commit real values to git.
Only these keys are permitted:
| Secret key | Required |
| --- | --- |
| `MEMBERSHIP_SECRET_KEY` | Always; at least 32 random characters |
| `POSTGRES_PASSWORD` | Always |
| `DEX_CLIENT_SECRET` | Always |
| `LDAP_BIND_PASSWORD` | Always |
| `EMAIL_HOST_PASSWORD` | When SMTP authentication requires it |
| `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` | With Stripe enabled |
| `SHKEEPER_API_KEY` | With SHKeeper enabled |
Do not use root/admin LDAP credentials. Restrict ACLs to the website account OU
and dedicated group attributes. Use SHKeeper's invoice API key, never wallet
private keys or UI/payout credentials.
`LDAP_BIND_DN` names the membership service account; `LDAP_BIND_PASSWORD` must
be its original plaintext password, **not** its stored LDAP `userPassword` hash
or an already-base64-encoded value. Supply original values in
`www_membership_secret_data`; the role base64-encodes them once for Secret `data`.
Current membership login uses verified Dex issuer/subject claims without an LDAP
connection. These service credentials remain required for account registration
and worker group synchronization. Older images logging
`LDAP service bind rejected during Dex callback` or
`Dex callback rejected kind=LDAPInvalidCredentialsResult` still perform the
retired callback lookup; upgrade the membership image to remove that dependency.
Fix service credentials separately for registration/sync, then rerun the role to
roll out web and worker. Do not use directory administrator credentials.
`LDAP_PASSWORD_SCHEME` gates new registrations only; it does not select the hash
algorithm for existing-user login.
The Dex-only login release adds migration `0004_dex_login_identity`. Keep
`DEX_ISSUER` unchanged during this upgrade so existing subjects are pinned to
their original issuer, and coordinate replacing web/worker together. Existing
subject bindings and billing data are retained. Unbound local accounts require
proof from their recent registration session or an administrator's explicit
`bind_dex_identity` mapping (`--account`, `--uid`, `--subject`); they are never
claimed by email/username matching alone. Review any retired connector bindings
before deployment. Keep Dex's existing connector/uid mapping unchanged and do not
recycle usernames. The worker pins new users' directory UUIDs separately; LDAP
outages can still delay paid group grants/revocations even though login works.
Private trust roots belong in a separate CA Secret. Set paths such as
`LDAP_CA_CERT_FILE`, `SHKEEPER_CA_CERT_FILE`, `PGSSLROOTCERT` or
`REQUESTS_CA_BUNDLE` to `/etc/membership/ca/<secret-key>`. The read-only mount is
shared by web, worker and migrations. Verify certificate expiry, strong keys,
SHA-2 signatures and trust independently; do not disable TLS verification.
### Self-service profiles
The profile release requires a membership image with Pillow and migration
`0005_profile_email_change`, plus the updated website templates/CSS. The role's
migration Job still runs before workloads. Deploy matching reviewed versions of
the application and website; do not reuse cached image tags.
The membership page links to an authenticated editor with friendly labels for
the requested inetOrgPerson attributes, with `displayName` labelled **Gamer tag**.
Dex's normal claims do not contain the full profile; opening the editor reads
the current LDAP entry, while ordinary sign-in remains LDAP-independent. The
service identity needs read/search access to profile attributes and operational
UUID/timestamp fields. Writes use the member's current password and **user bind**,
not broader service privileges. Configure narrow self-write ACLs, server-side
mail/uid uniqueness and RFC 4528 assertion support. SSH key edits additionally
require the `ldapPublicKey` schema and pre-added auxiliary class or an ACL allowing
only that specific self-add. This role does not create schema/ACLs automatically.
Email changes require mailbox verification plus the current password and replace
all existing mail values. Password hashing is delegated to slapd's Password Modify
policy; the application does not generate MD5-Crypt or expose stored hashes.
Review legacy hashing separately. See the application's self-service profile
documentation for the full attribute mapping and directory prerequisites.
The ingress upload limit is 2 MiB to accommodate a 1 MiB JPEG and multipart
overhead. Apache retains 256 KiB for non-profile member routes. Keep access/body
logging disabled for verification URLs, passwords and other profile data.
### Example inventory and hostname
[tests/example-vars.yml](tests/example-vars.yml) contains public configuration
only. Supply credentials separately via Vault or a pre-existing Secret. Run
through your existing deployment playbook after pushing matching website and
membership releases and installing the collection requirements.
**Hostname matters:** the old role serves **www.eom.dev**, whereas the standalone
app defaults to eom.dev. This role sets `MEMBERSHIP_PUBLIC_URL` to
`https://{{ www_hostname }}`. Register these exact callbacks, including slashes:
- Dex: `https://<www_hostname>/members/oidc/callback/`.
- Stripe: `https://<www_hostname>/members/stripe/webhook/`.
- SHKeeper: `https://<www_hostname>/members/shkeeper/callback/`.
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.
## Upgrades and operations
- Pin source ref and images to the same reviewed release. Change
`www_rollout_revision` when deliberately tracking a moving branch. Git URL/ref
are passed as environment variables, not shell-interpolated. Never put deploy
tokens in the repository URL.
- Apache config/source ref changes replace its pod. Membership image, public
settings, tiers and Secret/CA **resource versions** trigger a new migration Job
and workload rollout without publishing secret content hashes. The worker
uses Recreate to avoid parallel release workers.
- Completed migration Jobs are retained for idempotent reruns. Prune superseded
Jobs manually after an upgrade; retain the current Job. Review/remove failed
Jobs or choose a new rollout revision to retry. Keep schema changes compatible
with old pods during rollout; coordinate downtime for incompatible changes.
- The archive PVC is not wiped. Repository files (including dotfiles) overlay
it; stale deleted static assets are not automatically pruned. Review archive
path collisions deliberately. Database storage remains separate.
- PostgreSQL initializes credentials only on an empty directory. Updating the
Secret **does not rotate an existing DB password**: coordinate SQL rotation,
application rollout and DB restart manually. Do not change the database
name/user or major PostgreSQL version on an existing claim without a migration
plan. Expand existing claims through your approved storage procedure rather
than editing immutable StatefulSet claim templates.
- Local PostgreSQL is network-isolated but its default connection is not TLS.
Use an encrypted private CNI/service mesh or an external database with
`POSTGRES_SSLMODE=verify-full` and a trusted CA when encrypted DB transport is
required. Use encrypted disks/backups, restrict access and test restores. The
single managed instance is not HA and has no automatic backup schedule.
- For external PostgreSQL set `www_membership_postgres_managed: false` and
override POSTGRES_HOST/PORT/DB/USER and SSLMODE. Existing managed resources and
claims are deliberately **not deleted** when switching.
- Monitor worker errors and stale `last_ldap_sync`, `last_billing_sync` and
`last_shkeeper_sync`. Process health is not proof of successful reconciliation.
Downstream apps must refresh cached Dex group/session claims promptly.
## Network and privacy
No direct membership/database ingress, NodePort or LoadBalancer is created.
Apache alone may assert the trusted headers. Do not create permissive policies
or give untrusted pods its label. NetworkPolicy enforcement is required. Egress
is left to cluster policy: allow DNS, PostgreSQL, LDAP TLS, Dex HTTPS, SMTP
STARTTLS, Stripe HTTPS and optional SHKeeper HTTPS to configured endpoints.
Private destinations are supported; the role cannot infer safe IP allowlists.
Ingress enforces TLS and disables access logging of email bearer tokens/OIDC
codes. Apache excludes member access logs; the app image disables Gunicorn
access logs. Redact URIs/bodies at upstream load balancers, WAFs, APM and error
tracing too. The default IP throttle sees the ingress peer; configure trusted
mod_remoteip ranges separately for per-client limits. Never trust arbitrary
X-Forwarded-For headers.
Tor remains a **static mirror**; membership is denied for noncanonical Host
headers. Disabling membership only disables its Apache route: existing workers,
database and history are not deleted. Keep workers alive to revoke expiries
until a reviewed shutdown. Turning off Tor likewise does not delete an existing
OnionService automatically.
## Validation
[tests/Dockerfile](tests/Dockerfile) runs real Ansible syntax checks and template
rendering without a cluster, then
[tests/validate_manifests.py](tests/validate_manifests.py) asserts Secret/config
separation, tiers, migrations, private services, network isolation, non-root
constraints, probes and archive preservation. Fixtures cover managed PostgreSQL,
external TLS DB/private CA/image pull Secrets, SHKeeper, and disabled membership/
Tor. Before production, validate in a disposable namespace, including storage
permissions, NetworkPolicy, migrations, LDAP/Dex and sandbox payment flows.
The editor's container scanner currently flags one high-severity finding in the
validation runner's upstream Python base image, even after updating/pulling it.
This runner is test-only and is not deployed by the role. Review that finding
with your authenticated image scanner and refresh approved base-image digests
before using it in CI; no scanner authentication was configured by this change.