infra/stacks/traefik/main.tf
Viktor Barzin fd0c7493c3 traefik/crowdsec: serve Cloudflare Turnstile for captcha remediation
CrowdSec LAPI already issues `captcha`-type decisions for lower-severity abuse
(http-429-abuse, http-403-abuse, http-crawl-non_statics, http-sensitive-files),
but the Traefik bouncer plugin had no captcha provider configured — so those
decisions silently fell through to a 403 ban (traced in the plugin's bouncer.go
@ v1.4.2: captchaClient.Valid==false => handleBanServeHTTP). Flagged users had
no way to self-unblock, contradicting the profile's stated intent.

Wire Cloudflare Turnstile as the bouncer's captcha provider so a captcha
decision now renders a solvable challenge instead of a hard block:

- New cloudflare_turnstile_widget.crowdsec_captcha (managed mode), scoped to
  viktorbarzin.me so one widget covers every subdomain the bouncer fronts.
  Mirrors the existing Forgejo-signup Turnstile pattern; sitekey + secret are
  passed into the traefik module.
- middleware.tf: captchaProvider=turnstile + site/secret keys + grace 1800s +
  captchaHTMLFilePath=/captcha/captcha.html.
- Vendor the plugin's captcha.html and mount it into the Traefik container at
  /captcha via the chart `volumes` value — the pulled Yaegi plugin does not
  expose its bundled template to Traefik.
- docs/architecture/security.md: document the ban-vs-captcha remediation split.
- Remove the dead crowdsec-ingress-bouncer.yaml (unused nginx bouncer with
  placeholder reCAPTCHA keys; referenced by zero .tf).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 16:38:38 +00:00

52 lines
2.5 KiB
HCL

variable "tls_secret_name" { type = string }
variable "redis_host" { type = string }
data "vault_kv_secret_v2" "secrets" {
mount = "secret"
name = "platform"
}
# x402 wallet lives under secret/viktor (Viktor's personal config) — not
# secret/platform — and is the only field this stack needs from there.
data "vault_kv_secret_v2" "viktor" {
mount = "secret"
name = "viktor"
}
# Cloudflare Turnstile widget backing the CrowdSec captcha remediation. When
# LAPI issues a `captcha` decision (rate-limit / 403 / crawl / sensitive-file
# abuse — the captcha_remediation profile in stacks/crowdsec .../values.yaml),
# the Traefik bouncer plugin serves this widget so flagged users can
# self-unblock instead of getting a hard 403 (which is what happened before:
# the plugin had no captcha provider, so captcha decisions fell through to ban).
# Scoped to the registrable domain — a Turnstile hostname covers its subdomains,
# so one widget works on every *.viktorbarzin.me app the bouncer fronts.
# Same IaC pattern as stacks/forgejo/turnstile.tf; the CF Global API Key
# (cloudflare_provider.tf) has account-wide Turnstile access. The widget secret
# is sensitive and lands in TF state (Tier-1 PG, encrypted) — same trust level
# as the CrowdSec LAPI key already passed into the bouncer middleware.
data "cloudflare_accounts" "main" {}
resource "cloudflare_turnstile_widget" "crowdsec_captcha" {
account_id = data.cloudflare_accounts.main.accounts[0].id
name = "crowdsec-captcha"
domains = ["viktorbarzin.me"]
# "managed" = Cloudflare adaptively decides whether to show an interactive
# challenge; lowest friction for real users, strong against bots.
mode = "managed"
}
module "traefik" {
source = "./modules/traefik"
tier = local.tiers.core
crowdsec_api_key = data.vault_kv_secret_v2.secrets.data["ingress_crowdsec_api_key"]
captcha_site_key = cloudflare_turnstile_widget.crowdsec_captcha.id
captcha_secret_key = cloudflare_turnstile_widget.crowdsec_captcha.secret
redis_host = var.redis_host
tls_secret_name = var.tls_secret_name
auth_fallback_htpasswd = data.vault_kv_secret_v2.secrets.data["auth_fallback_htpasswd"]
x402_wallet_address = lookup(data.vault_kv_secret_v2.viktor.data, "x402_wallet_address", "")
# Reuses the existing Alertmanager Slack incoming webhook — same channel as
# other infra alerts. Payment events arrive as a normal Slack message.
x402_notify_webhook_url = lookup(data.vault_kv_secret_v2.viktor.data, "alertmanager_slack_api_url", "")
}