From 963e4fcdde1fb69bb1f8dc04b9da47e353671258 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Fri, 19 Jun 2026 16:05:07 +0000 Subject: [PATCH] forgejo: open native self-signups, gated by Turnstile + email confirmation Viktor wants Forgejo open for anyone to sign up, but without bot/spam account floods. Flip the deployment from OAuth-only registration (ALLOW_ONLY_EXTERNAL_REGISTRATION=true) to allowing native local sign-up, and add two bot gates on the registration form: - Cloudflare Turnstile captcha (CAPTCHA_TYPE=cfturnstile). The widget is managed in Terraform (turnstile.tf) via the CF Global API key, so the sitekey/secret are IaC, not a dashboard artifact. - Mandatory email confirmation (REGISTER_EMAIL_CONFIRM=true). Wire the Forgejo mailer to the cluster mailserver as noreply@viktorbarzin.me (mail.viktorbarzin.me:587 STARTTLS), reusing the same Vault-sourced credential Authentik uses (email-secret.tf ESO -> secret/authentik smtp_password). Existing Authentik OAuth2 login is unchanged (additive). Deployment env appended (not inserted) so the diff stays purely additive; a reloader annotation rolls the pod on secret rotation. Verified live: signup page renders the Turnstile widget, mailer delivers a test message end-to-end, Forgejo healthy, plan-to-zero after apply. Runbook: docs/runbooks/forgejo-open-signups.md Co-Authored-By: Claude Opus 4.8 --- .claude/reference/service-catalog.md | 2 +- docs/runbooks/forgejo-open-signups.md | 128 ++++++++++++++++++++++++++ stacks/forgejo/email-secret.tf | 40 ++++++++ stacks/forgejo/main.tf | 94 ++++++++++++++++++- stacks/forgejo/turnstile.tf | 31 +++++++ 5 files changed, 292 insertions(+), 3 deletions(-) create mode 100644 docs/runbooks/forgejo-open-signups.md create mode 100644 stacks/forgejo/email-secret.tf create mode 100644 stacks/forgejo/turnstile.tf diff --git a/.claude/reference/service-catalog.md b/.claude/reference/service-catalog.md index d8f10633..881ba35a 100644 --- a/.claude/reference/service-catalog.md +++ b/.claude/reference/service-catalog.md @@ -96,7 +96,7 @@ | n8n | Workflow automation | n8n | | real-estate-crawler | Property crawler | real-estate-crawler | | tor-proxy | Tor proxy | tor-proxy | -| forgejo | Git forge | forgejo | +| forgejo | Git forge. Open native self-signup (Turnstile captcha + email confirm) alongside Authentik OAuth; see `docs/runbooks/forgejo-open-signups.md` | forgejo | | freshrss | RSS reader | freshrss | | navidrome | Music streaming | navidrome | | networking-toolbox | Network tools | networking-toolbox | diff --git a/docs/runbooks/forgejo-open-signups.md b/docs/runbooks/forgejo-open-signups.md new file mode 100644 index 00000000..3b206277 --- /dev/null +++ b/docs/runbooks/forgejo-open-signups.md @@ -0,0 +1,128 @@ +# Runbook: Forgejo open self-service signups + +Last updated: 2026-06-19 + +`forgejo.viktorbarzin.me` allows **open native self-registration** (anyone can +create a local Forgejo account from the web form), gated against bots by two +layers: + +1. **Cloudflare Turnstile** captcha on the registration form. +2. **Mandatory email confirmation** — a new account stays inactive until the + user clicks an activation link emailed to the address they registered with. + +The pre-existing **Authentik OAuth2 login** ("Sign in with …") is unchanged and +still works alongside local accounts. This is additive — opening local signups +did not touch SSO. + +Everything is Terraform-managed in `stacks/forgejo/`. There is no dashboard or +manual cluster state. + +## What is configured (and where) + +All on the `kubernetes_deployment.forgejo` container env in +`stacks/forgejo/main.tf` (Forgejo reads `app.ini` keys from `FORGEJO__
__` +env vars): + +| Setting | Value | Effect | +|---|---|---| +| `service.DISABLE_REGISTRATION` | `false` | Registration is enabled | +| `service.ALLOW_ONLY_EXTERNAL_REGISTRATION` | `false` | Native local sign-up allowed (was `true` = OAuth-only) | +| `service.ENABLE_CAPTCHA` | `true` | Captcha required on the signup form | +| `service.CAPTCHA_TYPE` | `cfturnstile` | Cloudflare Turnstile | +| `service.CF_TURNSTILE_SITEKEY` | widget id | Public; rendered in the page | +| `service.CF_TURNSTILE_SECRET` | from `forgejo-turnstile` Secret | Server-side verification | +| `service.REGISTER_EMAIL_CONFIRM` | `true` | Account inactive until email is confirmed | +| `mailer.*` | see below | Sends the activation email | + +Captcha guards **registration only** — `REQUIRE_CAPTCHA_FOR_LOGIN` is left at the +default `false`, so existing users are not captcha'd on every login. + +## Cloudflare Turnstile widget — `turnstile.tf` + +- The widget is a Terraform resource: `cloudflare_turnstile_widget.forgejo_signup` + (mode `managed`, domain `forgejo.viktorbarzin.me`), created with the CF Global + API Key already wired in `cloudflare_provider.tf`. The account id is resolved + via `data.cloudflare_accounts`. +- `.id` is the **public sitekey** (passed as a plain env value). `.secret` is the + **secret key**, stored in the `forgejo-turnstile` K8s Secret and injected via + `secret_key_ref`. The secret also lives in TF state (Tier-1 PG, encrypted at + rest) — same trust level as the CF API key already in state. +- Forgejo is **non-proxied** (direct A record to Traefik), but Turnstile is a + client-side JS widget served from `challenges.cloudflare.com`, so proxy status + is irrelevant — the widget works regardless. + +**Rotate the widget secret** (e.g. if it leaks): +``` +cd stacks/forgejo && vault login -method=oidc +../../scripts/tg apply --non-interactive -replace=cloudflare_turnstile_widget.forgejo_signup +``` +This mints a new sitekey+secret, updates the `forgejo-turnstile` Secret, and (via +the Reloader annotation) rolls the Forgejo pod. Verify the new sitekey appears in +the `/user/sign_up` HTML afterwards. + +## Mailer — `email-secret.tf` + `[mailer]` env + +- Forgejo sends as **`noreply@viktorbarzin.me`** via **`mail.viktorbarzin.me:587`** + with `PROTOCOL=smtp+starttls`. This reuses the same mailserver SASL account + Authentik uses (`stacks/authentik/email-secret.tf`) — one credential, one + rotation point. +- **The host MUST be `mail.viktorbarzin.me`, not `mailserver.mailserver.svc`**: + the mailserver serves the `*.viktorbarzin.me` wildcard cert, which does not + cover the `.svc` DNS name, so STARTTLS cert verification would fail. + `mail.viktorbarzin.me` resolves in-cluster (→ `10.0.20.1`) and matches the cert. +- The password is synced from Vault `secret/authentik` → `smtp_password` by the + `forgejo-email` ExternalSecret (ESO `ClusterSecretStore vault-kv`) into the + `forgejo-email` K8s Secret (key `PASSWD`), referenced by `FORGEJO__mailer__PASSWD`. +- The deployment carries `reloader.stakater.com/auto: "true"`, so a rotation of + either secret rolls the pod automatically. + +## Re-closing / tightening signups + +Edit `stacks/forgejo/main.tf` and `scripts/tg apply` (or commit + push — CI +applies): + +- **OAuth-only again** (revert this change): set + `FORGEJO__service__ALLOW_ONLY_EXTERNAL_REGISTRATION` back to `"true"`. +- **No new accounts at all** (admins create them): set + `FORGEJO__service__DISABLE_REGISTRATION` to `"true"`. +- **Require admin approval per signup** (strongest, instead of email confirm): + set `REGISTER_MANUAL_CONFIRM=true` **and** `REGISTER_EMAIL_CONFIRM=false` + (Forgejo makes the two mutually exclusive). New accounts then queue under Site + Administration → Identity & Access → Accounts until an admin activates them. + +## Handling spam / abuse accounts + +A signup that clears Turnstile + email confirmation is still a real, low-privilege +Forgejo user. To deal with abuse: +- **Ban/delete** via Site Administration → Identity & Access → Accounts, or + `forgejo admin user delete --username ` inside the pod + (`kubectl -n forgejo exec deploy/forgejo -- forgejo admin user ...`). +- New users get Forgejo defaults (they can create repos/orgs). If abuse warrants, + tighten with `[service].DEFAULT_ALLOW_CREATE_ORGANIZATION=false` and/or + `[repository].MAX_CREATION_LIMIT` (add as env vars; out of scope for the initial + open-signups change). + +## Operational notes + +- The Forgejo deployment is **single-replica with `Recreate` strategy**, so any + config apply briefly restarts the pod (git remote + OCI registry unavailable for + a few seconds). Expected, not an incident. +- The signup page is **not** behind Cloudflare's bot-fight (Forgejo is + non-proxied) — Turnstile + email confirmation are the bot gate. CrowdSec + + Traefik rate limiting still front the host. + +## Verify it's working + +``` +POD=$(kubectl -n forgejo get pod -l app=forgejo -o jsonpath='{.items[0].metadata.name}') +# Env present: +kubectl -n forgejo exec "$POD" -- env | grep -E 'ALLOW_ONLY_EXTERNAL|ENABLE_CAPTCHA|CAPTCHA_TYPE|CF_TURNSTILE_SITEKEY|REGISTER_EMAIL_CONFIRM|mailer__ENABLED' +# Turnstile widget rendered on the form: +kubectl -n forgejo exec "$POD" -- wget -qO- http://localhost:3000/user/sign_up | grep -oE 'cf-turnstile|data-sitekey="[^"]*"' +# Secrets healthy: +kubectl -n forgejo get externalsecret forgejo-email +kubectl -n forgejo get secret forgejo-email forgejo-turnstile +``` +A full real-world check is to register a throwaway account and confirm the +activation email arrives. The mailer transport (server/port/cert/cred) is shared +with Authentik, which is already in production for external user enrollment. diff --git a/stacks/forgejo/email-secret.tf b/stacks/forgejo/email-secret.tf new file mode 100644 index 00000000..1793caf9 --- /dev/null +++ b/stacks/forgejo/email-secret.tf @@ -0,0 +1,40 @@ +# SMTP password for Forgejo's open-signup email-confirmation + notification +# mail (main.tf: FORGEJO__service__REGISTER_EMAIL_CONFIRM + [mailer]). Synced +# from Vault secret/authentik -> smtp_password into the forgejo namespace as the +# `forgejo-email` Secret (key PASSWD), referenced by FORGEJO__mailer__PASSWD. +# Reuses the same noreply@viktorbarzin.me mailserver SASL account Authentik uses +# (stacks/authentik/email-secret.tf) — one credential, one rotation point. The +# reloader annotation rolls the Forgejo pod if the password is ever rotated. +resource "kubernetes_manifest" "forgejo_email_secret" { + manifest = { + apiVersion = "external-secrets.io/v1beta1" + kind = "ExternalSecret" + metadata = { + name = "forgejo-email" + namespace = "forgejo" + } + spec = { + refreshInterval = "1h" + secretStoreRef = { + name = "vault-kv" + kind = "ClusterSecretStore" + } + target = { + name = "forgejo-email" + template = { + metadata = { + annotations = { + "reloader.stakater.com/match" = "true" + } + } + } + } + data = [ + { + secretKey = "PASSWD" + remoteRef = { key = "authentik", property = "smtp_password" } + }, + ] + } + } +} diff --git a/stacks/forgejo/main.tf b/stacks/forgejo/main.tf index d271ffa0..0abb1847 100644 --- a/stacks/forgejo/main.tf +++ b/stacks/forgejo/main.tf @@ -92,8 +92,16 @@ resource "kubernetes_deployment" "forgejo" { # from 11.0.14 → 1.18 on 2026-05-24 (same bug as memory id=1933). # TF owns the tag now; bump it manually here when upgrading. "keel.sh/policy" = "never" + # Roll the pod when the signup secrets (forgejo-email password from Vault, + # forgejo-turnstile secret) change — env vars are read at boot, not + # hot-reloaded. Stakater Reloader watches all referenced secrets/CMs. + "reloader.stakater.com/auto" = "true" } } + # The forgejo-email Secret is materialised by the External Secrets operator + # from the forgejo-email ExternalSecret (email-secret.tf); ensure the CR + # exists before this deployment references it on a from-scratch apply. + depends_on = [kubernetes_manifest.forgejo_email_secret] spec { replicas = 1 strategy { @@ -142,14 +150,21 @@ resource "kubernetes_deployment" "forgejo" { name = "FORGEJO__server__ROOT_URL" value = "https://forgejo.viktorbarzin.me" } - # Disable local registration — only allow OAuth2 (Authentik) + # Open self-service registration. Native local sign-up is allowed + # (ALLOW_ONLY_EXTERNAL_REGISTRATION=false) alongside the existing + # Authentik OAuth2 login. Bot abuse is gated by Cloudflare Turnstile + # (ENABLE_CAPTCHA below) + mandatory email confirmation + # (REGISTER_EMAIL_CONFIRM below). To re-close signups: set + # DISABLE_REGISTRATION=true, or flip ALLOW_ONLY_EXTERNAL_REGISTRATION + # back to "true" for OAuth-only. Runbook: + # docs/runbooks/forgejo-open-signups.md env { name = "FORGEJO__service__DISABLE_REGISTRATION" value = "false" } env { name = "FORGEJO__service__ALLOW_ONLY_EXTERNAL_REGISTRATION" - value = "true" + value = "false" } env { name = "FORGEJO__openid__ENABLE_OPENID_SIGNIN" @@ -190,6 +205,81 @@ resource "kubernetes_deployment" "forgejo" { name = "FORGEJO__repository__DISABLE_DOWNLOAD_SOURCE_ARCHIVES" value = "true" } + # --- Open-signup bot prevention + mailer (appended so the diff vs the + # pre-signup deployment stays purely additive). --- + # Cloudflare Turnstile captcha on the registration form (widget + # managed in turnstile.tf). Sitekey is public (rendered in the page); + # the secret is injected from the forgejo-turnstile Secret. Guards + # registration only — not every login (REQUIRE_CAPTCHA_FOR_LOGIN left + # at the default false). + env { + name = "FORGEJO__service__ENABLE_CAPTCHA" + value = "true" + } + env { + name = "FORGEJO__service__CAPTCHA_TYPE" + value = "cfturnstile" + } + env { + name = "FORGEJO__service__CF_TURNSTILE_SITEKEY" + value = cloudflare_turnstile_widget.forgejo_signup.id + } + env { + name = "FORGEJO__service__CF_TURNSTILE_SECRET" + value_from { + secret_key_ref { + name = kubernetes_secret.forgejo_turnstile.metadata[0].name + key = "cf-turnstile-secret" + } + } + } + # Mandatory email confirmation: new accounts stay inactive until the + # user clicks an emailed activation link (kills throwaway-email bots). + env { + name = "FORGEJO__service__REGISTER_EMAIL_CONFIRM" + value = "true" + } + # Mailer: reuse the noreply@viktorbarzin.me mailserver SASL account + # (same as Authentik). MUST use the public host mail.viktorbarzin.me, + # NOT mailserver.mailserver.svc — the mailserver serves the + # *.viktorbarzin.me wildcard cert which does not cover the svc DNS + # name, so STARTTLS verification would fail. mail.viktorbarzin.me + # resolves in-cluster (10.0.20.1) and matches the cert. Password from + # the forgejo-email ESO Secret (Vault secret/authentik -> + # smtp_password; see email-secret.tf). + env { + name = "FORGEJO__mailer__ENABLED" + value = "true" + } + env { + name = "FORGEJO__mailer__PROTOCOL" + value = "smtp+starttls" + } + env { + name = "FORGEJO__mailer__SMTP_ADDR" + value = "mail.viktorbarzin.me" + } + env { + name = "FORGEJO__mailer__SMTP_PORT" + value = "587" + } + env { + name = "FORGEJO__mailer__FROM" + value = "Forgejo " + } + env { + name = "FORGEJO__mailer__USER" + value = "noreply@viktorbarzin.me" + } + env { + name = "FORGEJO__mailer__PASSWD" + value_from { + secret_key_ref { + name = "forgejo-email" + key = "PASSWD" + } + } + } volume_mount { name = "data" mount_path = "/data" diff --git a/stacks/forgejo/turnstile.tf b/stacks/forgejo/turnstile.tf new file mode 100644 index 00000000..de98833f --- /dev/null +++ b/stacks/forgejo/turnstile.tf @@ -0,0 +1,31 @@ +# Cloudflare Turnstile widget guarding the open Forgejo signup form +# (main.tf: FORGEJO__service__ENABLE_CAPTCHA + CAPTCHA_TYPE=cfturnstile). +# Managed here so the sitekey/secret are IaC rather than a dashboard artifact. +# The CF Global API Key (cloudflare_provider.tf) has account-wide access, so it +# can manage Turnstile. The widget secret is sensitive and lands in TF state +# (Tier-1 PG, encrypted at rest) — same trust level as the API key already in +# state. Forgejo is non-proxied, but Turnstile is a client-side JS widget served +# from challenges.cloudflare.com, so proxy status is irrelevant. +data "cloudflare_accounts" "main" {} + +resource "cloudflare_turnstile_widget" "forgejo_signup" { + account_id = data.cloudflare_accounts.main.accounts[0].id + name = "forgejo-signup" + domains = ["forgejo.viktorbarzin.me"] + # "managed" = Cloudflare adaptively decides whether to show an interactive + # challenge; lowest friction for real users, strong against bots. + mode = "managed" +} + +# Turnstile secret -> K8s Secret consumed by the Forgejo deployment via +# secret_key_ref (FORGEJO__service__CF_TURNSTILE_SECRET). The sitekey is public +# and passed as a plain env value in main.tf. +resource "kubernetes_secret" "forgejo_turnstile" { + metadata { + name = "forgejo-turnstile" + namespace = kubernetes_namespace.forgejo.metadata[0].name + } + data = { + "cf-turnstile-secret" = cloudflare_turnstile_widget.forgejo_signup.secret + } +}