forgejo: open native self-signups, gated by Turnstile + email confirmation
All checks were successful
ci/woodpecker/push/default Pipeline was successful
All checks were successful
ci/woodpecker/push/default Pipeline was successful
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 <noreply@anthropic.com>
This commit is contained in:
parent
21dbd79ae4
commit
963e4fcdde
5 changed files with 292 additions and 3 deletions
|
|
@ -96,7 +96,7 @@
|
||||||
| n8n | Workflow automation | n8n |
|
| n8n | Workflow automation | n8n |
|
||||||
| real-estate-crawler | Property crawler | real-estate-crawler |
|
| real-estate-crawler | Property crawler | real-estate-crawler |
|
||||||
| tor-proxy | Tor proxy | tor-proxy |
|
| 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 |
|
| freshrss | RSS reader | freshrss |
|
||||||
| navidrome | Music streaming | navidrome |
|
| navidrome | Music streaming | navidrome |
|
||||||
| networking-toolbox | Network tools | networking-toolbox |
|
| networking-toolbox | Network tools | networking-toolbox |
|
||||||
|
|
|
||||||
128
docs/runbooks/forgejo-open-signups.md
Normal file
128
docs/runbooks/forgejo-open-signups.md
Normal file
|
|
@ -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__<section>__<KEY>`
|
||||||
|
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 <name>` 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.
|
||||||
40
stacks/forgejo/email-secret.tf
Normal file
40
stacks/forgejo/email-secret.tf
Normal file
|
|
@ -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" }
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -92,8 +92,16 @@ resource "kubernetes_deployment" "forgejo" {
|
||||||
# from 11.0.14 → 1.18 on 2026-05-24 (same bug as memory id=1933).
|
# 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.
|
# TF owns the tag now; bump it manually here when upgrading.
|
||||||
"keel.sh/policy" = "never"
|
"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 {
|
spec {
|
||||||
replicas = 1
|
replicas = 1
|
||||||
strategy {
|
strategy {
|
||||||
|
|
@ -142,14 +150,21 @@ resource "kubernetes_deployment" "forgejo" {
|
||||||
name = "FORGEJO__server__ROOT_URL"
|
name = "FORGEJO__server__ROOT_URL"
|
||||||
value = "https://forgejo.viktorbarzin.me"
|
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 {
|
env {
|
||||||
name = "FORGEJO__service__DISABLE_REGISTRATION"
|
name = "FORGEJO__service__DISABLE_REGISTRATION"
|
||||||
value = "false"
|
value = "false"
|
||||||
}
|
}
|
||||||
env {
|
env {
|
||||||
name = "FORGEJO__service__ALLOW_ONLY_EXTERNAL_REGISTRATION"
|
name = "FORGEJO__service__ALLOW_ONLY_EXTERNAL_REGISTRATION"
|
||||||
value = "true"
|
value = "false"
|
||||||
}
|
}
|
||||||
env {
|
env {
|
||||||
name = "FORGEJO__openid__ENABLE_OPENID_SIGNIN"
|
name = "FORGEJO__openid__ENABLE_OPENID_SIGNIN"
|
||||||
|
|
@ -190,6 +205,81 @@ resource "kubernetes_deployment" "forgejo" {
|
||||||
name = "FORGEJO__repository__DISABLE_DOWNLOAD_SOURCE_ARCHIVES"
|
name = "FORGEJO__repository__DISABLE_DOWNLOAD_SOURCE_ARCHIVES"
|
||||||
value = "true"
|
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 <noreply@viktorbarzin.me>"
|
||||||
|
}
|
||||||
|
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 {
|
volume_mount {
|
||||||
name = "data"
|
name = "data"
|
||||||
mount_path = "/data"
|
mount_path = "/data"
|
||||||
|
|
|
||||||
31
stacks/forgejo/turnstile.tf
Normal file
31
stacks/forgejo/turnstile.tf
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue