diff --git a/hosts/mail/configuration.nix b/hosts/mail/configuration.nix index dff1253..cb1fc81 100644 --- a/hosts/mail/configuration.nix +++ b/hosts/mail/configuration.nix @@ -10,6 +10,7 @@ ./modules/openldap.nix ./modules/dovecot.nix ./modules/postfix.nix + ./modules/dkim-fueltide.nix ./utils/modules/borgbackup.nix ./utils/modules/promtail diff --git a/hosts/mail/modules/dkim-fueltide.nix b/hosts/mail/modules/dkim-fueltide.nix new file mode 100644 index 0000000..2a0af27 --- /dev/null +++ b/hosts/mail/modules/dkim-fueltide.nix @@ -0,0 +1,28 @@ +{ config, pkgs, ... }: + +{ + sops.secrets.rspamd-dkim-fueltide-io-key = { + owner = "rspamd"; + group = "rspamd"; + mode = "0400"; + }; + + # rspamd's dkim_signing module in rspamd.nix picks up per-domain keys from + # /var/lib/rspamd/dkim/$domain.$selector.key. This one-shot drops the + # fueltide.io key into place before rspamd starts. + systemd.services.rspamd-dkim-fueltide-setup = { + description = "Install fueltide.io DKIM key into rspamd"; + wantedBy = [ "multi-user.target" ]; + before = [ "rspamd.service" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + script = '' + install -d -o rspamd -g rspamd -m 0750 /var/lib/rspamd/dkim + install -o rspamd -g rspamd -m 0400 \ + ${config.sops.secrets.rspamd-dkim-fueltide-io-key.path} \ + /var/lib/rspamd/dkim/fueltide.io.default.key + ''; + }; +} diff --git a/hosts/web-arm/modules/supabase/FUELTIDE_AUTH_SETUP.md b/hosts/web-arm/modules/supabase/FUELTIDE_AUTH_SETUP.md new file mode 100644 index 0000000..16f3dc9 --- /dev/null +++ b/hosts/web-arm/modules/supabase/FUELTIDE_AUTH_SETUP.md @@ -0,0 +1,246 @@ +# Supabase auth setup: Google + Apple OAuth, fueltide.io email + +This doc lists the **user-side steps** required to make the code changes in +this branch functional. Nothing here is performed by Nix — these are manual +actions on external services, LDAP, SOPS, and DNS. + +The Nix changes in this branch cover: + +- `hosts/web-arm/modules/supabase/default.nix` — GoTrue env for Google + Apple + OAuth, SMTP pointed at `mail.cloonar.com:587`, `MAILER_AUTOCONFIRM=false`, + `SITE_URL` + `URI_ALLOW_LIST` for fueltide.io, python+cryptography in the + env-generate path (for Apple JWT signing). +- `hosts/web-arm/modules/supabase/env-generate.sh` — new `auth.env` block that + pulls SMTP + OAuth creds from SOPS and signs the Apple client-secret JWT + fresh on every activation. +- `hosts/mail/modules/dkim-fueltide.nix` — installs a per-domain DKIM key for + fueltide.io into rspamd so outbound mail from `noreply@fueltide.io` is + signed. + +Complete the seven steps below **before** merging to master. Merging without +them will deploy a broken GoTrue (missing OAuth/SMTP creds → auth emails fail, +OAuth flows 500). + +--- + +## 1. LDAP service account + fueltide alias on `mail.cloonar.com` + +Mirrors the `gitea@cloonar.com` / `authelia@cloonar.com` pattern. The alias +on `noreply@fueltide.io` is what `smtpd_sender_login_maps` uses to let the +`supabase` SASL user send as that address without tripping +`reject_authenticated_sender_login_mismatch`. + +```bash +# on mail.cloonar.com +SMTP_PASS=$(openssl rand -base64 30 | tr -d '/+=' | head -c 32) +echo "SMTP_PASS (store this in SOPS, step 3): $SMTP_PASS" +CRYPT=$(mkpasswd -m sha-512 "$SMTP_PASS") + +cat > /tmp/supabase.ldif < goes into SOPS (step 3) +# public key: printed to stdout -> goes into DNS (step 4) +``` + +Wipe the temp dir once both are copied out. + +## 3. SOPS edits (two files) + +### `hosts/mail/secrets.yaml` + +```bash +nix-shell -p sops --run 'sops hosts/mail/secrets.yaml' +``` + +Add: + +```yaml +rspamd-dkim-fueltide-io-key: | + -----BEGIN PRIVATE KEY----- + + -----END PRIVATE KEY----- +``` + +### `hosts/web-arm/secrets.yaml` + +```bash +nix-shell -p sops --run 'sops hosts/web-arm/secrets.yaml' +``` + +Inside the existing `supabase-env` multiline value, append eight new lines +(these are sourced as shell variables by `env-generate.sh`): + +``` +SMTP_USER=supabase@cloonar.com +SMTP_PASS= +GOOGLE_CLIENT_ID=<from step 5> +GOOGLE_SECRET=<from step 5> +APPLE_TEAM_ID=XWJ4DC7TBH +APPLE_KEY_ID=<from step 6> +APPLE_SERVICES_ID=com.cloonar.supabase.fueltide +APPLE_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\n<.p8 body>\n-----END PRIVATE KEY----- +``` + +Note on `APPLE_PRIVATE_KEY`: it must be **one line** with literal backslash-n +separating the PEM lines (no real newlines inside the value). The python +signer in `env-generate.sh` un-escapes those via `decode("unicode_escape")` +before loading the PEM. To format an existing `AuthKey_XXX.p8` as that single +line: + +```bash +awk '{printf "%s\\n", $0}' AuthKey_XXXXXXXXXX.p8 +``` + +## 4. DNS records for `fueltide.io` + +Add on whichever DNS provider hosts fueltide.io: + +``` +TXT @ v=spf1 mx a:mail.cloonar.com ~all +TXT default._domainkey v=DKIM1; k=rsa; p=<public key from step 2> +TXT _dmarc v=DMARC1; p=quarantine; rua=mailto:postmaster@cloonar.com; fo=1 +``` + +PTR for mail.cloonar.com is already set (it's been sending for cloonar.com). +If fueltide.io has no MX record, outbound is fine but bounces from remote MTAs +won't route — acceptable for one-way transactional mail. Add an MX pointing at +`mail.cloonar.com.` if you want bounces to be received. + +## 5. Google Cloud OAuth client (≈ 5 min) + +1. console.cloud.google.com → **APIs & Services → OAuth consent screen**. + External user type. App name `Fueltide`, user support email, developer + contact. Scopes: `openid`, `email`, `profile`. Submit (or keep in testing + if only internal users). +2. **Credentials → Create Credentials → OAuth client ID → Web application**. + Name `Supabase`. Authorised redirect URI: + `https://supabase.cloonar.com/auth/v1/callback`. +3. Copy Client ID + Client Secret → into SOPS as `GOOGLE_CLIENT_ID` and + `GOOGLE_SECRET`. + +## 6. Apple Developer Sign in with Apple (≈ 15 min, paid account required) + +1. developer.apple.com → **Certificates, IDs & Profiles → Identifiers → + + → Services IDs**. Description `Fueltide Supabase Auth`. Identifier + `com.cloonar.supabase.fueltide`. Check **Sign in with Apple → Configure**. +2. Primary App ID: existing `io.fueltide.workout` (Team `XWJ4DC7TBH`, see + `hosts/web-arm/sites/fueltide.io.nix`). Domains and Subdomains: + `supabase.cloonar.com`. Return URLs: + `https://supabase.cloonar.com/auth/v1/callback`. Save. +3. **Keys → +** → name `Fueltide Supabase Auth` → check **Sign in with Apple + → Configure** → primary App ID `io.fueltide.workout`. Register. +4. **Download the `.p8` file now** — Apple only offers it once. +5. Note the Key ID (10 chars) displayed on the key page. +6. Team ID is `XWJ4DC7TBH` (already known). +7. Into SOPS on web-arm: + - `APPLE_TEAM_ID=XWJ4DC7TBH` + - `APPLE_KEY_ID=<from step 5>` + - `APPLE_SERVICES_ID=com.cloonar.supabase.fueltide` + - `APPLE_PRIVATE_KEY=<single-line .p8 as described in step 3>` + +### iOS native flow (optional) + +If the fueltide iOS app will use `supabase.auth.signInWithIdToken({ provider: +'apple', token: identityToken })` (native `AuthenticationServices` SDK, no web +browser), the iOS bundle ID must also appear in `GOTRUE_EXTERNAL_APPLE_CLIENT_ID`. +Change the line in `env-generate.sh` that currently reads: + +```sh +GOTRUE_EXTERNAL_APPLE_CLIENT_ID=${APPLE_SERVICES_ID:-} +``` + +to something like: + +```sh +GOTRUE_EXTERNAL_APPLE_CLIENT_ID=${APPLE_SERVICES_ID:-},io.fueltide.workout +``` + +(GoTrue accepts a comma-separated audiences list here and validates incoming +id_tokens against any of them.) + +## 7. Merge and deploy + +Once steps 1–6 are done: + +```bash +./scripts/test-configuration web-arm +./scripts/test-configuration mail +git checkout master +git merge --no-ff <this-branch> +git push +``` + +Bento rolls out both hosts. On `web-arm.cloonar.com`: + +```bash +sudo systemctl restart supabase-env-generate +sudo cat /run/supabase/auth.env # expect 8 new vars populated +sudo podman exec supabase-auth nc -vz mail.cloonar.com 587 +sudo podman restart supabase-auth +``` + +### Verification checklist + +- [ ] `/run/supabase/auth.env` contains `GOTRUE_EXTERNAL_APPLE_SECRET=<long-JWT>`. +- [ ] Second `systemctl restart supabase-env-generate` produces a different + Apple JWT (freshness — signed with new `iat`). +- [ ] `curl -X POST -H 'apikey: <anon>' -H 'Content-Type: application/json' \ + https://supabase.cloonar.com/auth/v1/signup \ + -d '{"email":"<real inbox>","password":"correct horse battery staple"}'` + delivers a mail with `From: noreply@fueltide.io` within ~30 s. +- [ ] Mail headers show `dkim=pass`, `spf=pass`, `dmarc=pass` + (`Authentication-Results` header). +- [ ] `POST /auth/v1/recover` triggers a reset mail. +- [ ] Browser visit to + `https://supabase.cloonar.com/auth/v1/authorize?provider=google` + completes and lands on `/auth/v1/callback`. Row in `auth.identities` + with `provider='google'`. +- [ ] Same with `?provider=apple` from a page Apple's Return URL accepts. +- [ ] Send a signup to [mail-tester.com](https://www.mail-tester.com/) — target + ≥ 9/10 spam score. + +## Rotation notes + +- **Apple client-secret JWT**: auto-regenerated on every activation + (`supabase-env-generate.service`). No manual rotation. +- **Apple `.p8` key**: no expiry, but revoking it in the Apple console + immediately breaks auth. If ever rotated, update `APPLE_KEY_ID` and + `APPLE_PRIVATE_KEY` in SOPS together. +- **Google client secret**: no expiry; rotate via Google Cloud console if + leaked and update `GOOGLE_SECRET` in SOPS. +- **DKIM key**: no expiry, but best practice is to rotate yearly. Rotation + = regenerate keypair (step 2), replace the SOPS value (step 3), update DNS + (step 4), deploy. Keep both old+new DNS records live for 24h during + cutover. +- **SMTP LDAP password**: no expiry. To rotate, run `mkpasswd` again and + update both the LDAP userPassword attribute and SOPS `SMTP_PASS`. diff --git a/hosts/web-arm/modules/supabase/default.nix b/hosts/web-arm/modules/supabase/default.nix index 4519e7e..5613edf 100644 --- a/hosts/web-arm/modules/supabase/default.nix +++ b/hosts/web-arm/modules/supabase/default.nix @@ -19,11 +19,14 @@ in sops.secrets.supabase-env = { }; # --- Persistent data directories --- + # Postgres data lives in a named podman volume (supabase-db-data) so podman + # owns the permissions on the container's postgres UID; logical dumps go to + # /var/backups/supabase where borg picks them up from /var. systemd.tmpfiles.rules = [ - "d /var/lib/supabase/db/data 0700 root root -" "d /var/lib/supabase/storage 0755 root root -" "d /var/lib/supabase/functions 0755 root root -" "d /var/lib/supabase/snippets 0755 root root -" + "d /var/backups/supabase 0700 root root -" ]; @@ -67,7 +70,12 @@ in supabase-env-generate = { description = "Generate Supabase per-container env files from SOPS secrets"; wantedBy = [ "multi-user.target" ]; - path = [ pkgs.jq ]; + # python+cryptography is used to sign the Apple OAuth client-secret JWT + # (ES256) inside env-generate.sh. + path = [ + pkgs.jq + (pkgs.python3.withPackages (ps: [ ps.cryptography ])) + ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; @@ -95,9 +103,38 @@ in after = [ "supabase-functions-seed.service" ]; requires = [ "supabase-functions-seed.service" ]; }; + # Logical daily dump of the containerised Postgres cluster. Writes to + # /var/backups/supabase which is covered by the borg path /var; + # /var/lib/containers (the named-volume storage) is excluded from borg, + # so the dump is the only copy borg ships off-host. + supabase-db-backup = { + description = "pg_dumpall of the Supabase Postgres cluster"; + after = [ "podman-supabase-db.service" ]; + requires = [ "podman-supabase-db.service" ]; + serviceConfig = { + Type = "oneshot"; + }; + script = '' + set -euo pipefail + tmp=/var/backups/supabase/supabase-all.sql.tmp + out=/var/backups/supabase/supabase-all.sql + ${pkgs.podman}/bin/podman exec -u postgres supabase-db \ + pg_dumpall -U postgres --clean --if-exists > "$tmp" + mv "$tmp" "$out" + ''; + }; } ]); + systemd.timers.supabase-db-backup = { + description = "Daily Supabase Postgres dump"; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnCalendar = "*-*-* 02:30:00"; + Persistent = true; + }; + }; + # --- Containers --- virtualisation.oci-containers.containers = { @@ -114,7 +151,7 @@ in }; environmentFiles = [ "/run/supabase/db.env" ]; volumes = [ - "/var/lib/supabase/db/data:/var/lib/postgresql/data" + "supabase-db-data:/var/lib/postgresql/data" "${./sql/_supabase.sql}:/docker-entrypoint-initdb.d/migrations/97-_supabase.sql:ro" "${./sql/realtime.sql}:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:ro" "${./sql/logs.sql}:/docker-entrypoint-initdb.d/migrations/99-logs.sql:ro" @@ -166,8 +203,8 @@ in GOTRUE_API_PORT = "9999"; API_EXTERNAL_URL = "https://supabase.cloonar.com"; GOTRUE_DB_DRIVER = "postgres"; - GOTRUE_SITE_URL = "https://supabase.cloonar.com"; - GOTRUE_URI_ALLOW_LIST = ""; + GOTRUE_SITE_URL = "https://app.fueltide.io"; + GOTRUE_URI_ALLOW_LIST = "https://app.fueltide.io,https://app.fueltide.io/**,https://app.stage.fueltide.io,https://app.stage.fueltide.io/**,io.fueltide.workout://"; GOTRUE_DISABLE_SIGNUP = "false"; GOTRUE_JWT_ADMIN_ROLES = "service_role"; GOTRUE_JWT_AUD = "authenticated"; @@ -175,19 +212,21 @@ in GOTRUE_JWT_EXP = "3600"; GOTRUE_EXTERNAL_EMAIL_ENABLED = "true"; GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED = "false"; - GOTRUE_MAILER_AUTOCONFIRM = "true"; - GOTRUE_SMTP_ADMIN_EMAIL = "admin@cloonar.com"; - GOTRUE_SMTP_HOST = "supabase-mail"; - GOTRUE_SMTP_PORT = "2500"; - GOTRUE_SMTP_USER = ""; - GOTRUE_SMTP_PASS = ""; - GOTRUE_SMTP_SENDER_NAME = "Supabase"; + GOTRUE_MAILER_AUTOCONFIRM = "false"; + GOTRUE_SMTP_ADMIN_EMAIL = "noreply@fueltide.io"; + GOTRUE_SMTP_HOST = "mail.cloonar.com"; + GOTRUE_SMTP_PORT = "587"; + GOTRUE_SMTP_SENDER_NAME = "Fueltide"; GOTRUE_MAILER_URLPATHS_INVITE = "/auth/v1/verify"; GOTRUE_MAILER_URLPATHS_CONFIRMATION = "/auth/v1/verify"; GOTRUE_MAILER_URLPATHS_RECOVERY = "/auth/v1/verify"; GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE = "/auth/v1/verify"; GOTRUE_EXTERNAL_PHONE_ENABLED = "false"; GOTRUE_SMS_AUTOCONFIRM = "false"; + GOTRUE_EXTERNAL_GOOGLE_ENABLED = "true"; + GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI = "https://supabase.cloonar.com/auth/v1/callback"; + GOTRUE_EXTERNAL_APPLE_ENABLED = "true"; + GOTRUE_EXTERNAL_APPLE_REDIRECT_URI = "https://supabase.cloonar.com/auth/v1/callback"; }; environmentFiles = [ "/run/supabase/auth.env" ]; extraOptions = supabaseNet ++ [ diff --git a/hosts/web-arm/modules/supabase/env-generate.sh b/hosts/web-arm/modules/supabase/env-generate.sh index ecf4f1b..ba8278c 100644 --- a/hosts/web-arm/modules/supabase/env-generate.sh +++ b/hosts/web-arm/modules/supabase/env-generate.sh @@ -22,9 +22,49 @@ LOGFLARE_PRIVATE_ACCESS_TOKEN=$LOGFLARE_PRIVATE_ACCESS_TOKEN POSTGRES_BACKEND_URL=postgresql://supabase_admin:$PG_PASS_ENCODED@db:5432/_supabase EOF +# Apple client-secret is a short-lived JWT signed with the .p8 key downloaded +# from Apple Developer. Re-sign on every activation (lifetime 180 days, Apple's +# cap) so there is no manual rotation ritual. The SOPS-sourced APPLE_PRIVATE_KEY +# is stored as a single line with literal \n separators; python un-escapes it. +APPLE_SECRET="" +if [ -n "${APPLE_TEAM_ID:-}" ] && [ -n "${APPLE_KEY_ID:-}" ] \ + && [ -n "${APPLE_SERVICES_ID:-}" ] && [ -n "${APPLE_PRIVATE_KEY:-}" ]; then + APPLE_SECRET=$( + APPLE_TEAM_ID="$APPLE_TEAM_ID" \ + APPLE_KEY_ID="$APPLE_KEY_ID" \ + APPLE_SERVICES_ID="$APPLE_SERVICES_ID" \ + APPLE_PRIVATE_KEY="$APPLE_PRIVATE_KEY" \ + python3 - <<'PY' +import base64, json, os, time +from cryptography.hazmat.primitives import serialization, hashes +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature +def b64u(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode() +now = int(time.time()) +header = {"alg": "ES256", "kid": os.environ["APPLE_KEY_ID"], "typ": "JWT"} +payload = {"iss": os.environ["APPLE_TEAM_ID"], "iat": now, "exp": now + 86400 * 180, + "aud": "https://appleid.apple.com", "sub": os.environ["APPLE_SERVICES_ID"]} +parts = (b64u(json.dumps(header, separators=(",", ":")).encode()) + + "." + b64u(json.dumps(payload, separators=(",", ":")).encode())).encode() +pem = os.environ["APPLE_PRIVATE_KEY"].encode().decode("unicode_escape").encode() +key = serialization.load_pem_private_key(pem, password=None) +der = key.sign(parts, ec.ECDSA(hashes.SHA256())) +r, s = decode_dss_signature(der) +raw = r.to_bytes(32, "big") + s.to_bytes(32, "big") +print(parts.decode() + "." + b64u(raw)) +PY + ) +fi + cat > /run/supabase/auth.env <<EOF GOTRUE_JWT_SECRET=$JWT_SECRET GOTRUE_DB_DATABASE_URL=postgres://supabase_auth_admin:$PG_PASS_ENCODED@db:5432/postgres +GOTRUE_SMTP_USER=${SMTP_USER:-} +GOTRUE_SMTP_PASS=${SMTP_PASS:-} +GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} +GOTRUE_EXTERNAL_GOOGLE_SECRET=${GOOGLE_SECRET:-} +GOTRUE_EXTERNAL_APPLE_CLIENT_ID=${APPLE_SERVICES_ID:-} +GOTRUE_EXTERNAL_APPLE_SECRET=$APPLE_SECRET EOF cat > /run/supabase/rest.env <<EOF