diff --git a/stacks/traefik/main.tf b/stacks/traefik/main.tf index 0210b030..d824a1c4 100644 --- a/stacks/traefik/main.tf +++ b/stacks/traefik/main.tf @@ -13,9 +13,35 @@ data "vault_kv_secret_v2" "viktor" { 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"] diff --git a/stacks/traefik/modules/traefik/captcha.html b/stacks/traefik/modules/traefik/captcha.html new file mode 100644 index 00000000..bf7a8afd --- /dev/null +++ b/stacks/traefik/modules/traefik/captcha.html @@ -0,0 +1,338 @@ + + + + + CrowdSec Captcha + + + + + + + +
+
+
+ +

CrowdSec Captcha

+
+
+
+
+
+
+

This security check has been powered by

+ + + + + + + + + + + + + + + + + + + + + CrowdSec + +
+
+
+ + + diff --git a/stacks/traefik/modules/traefik/error-pages.tf b/stacks/traefik/modules/traefik/error-pages.tf index 7f2e614c..4913bc99 100644 --- a/stacks/traefik/modules/traefik/error-pages.tf +++ b/stacks/traefik/modules/traefik/error-pages.tf @@ -201,6 +201,7 @@ resource "kubernetes_manifest" "ingressroute_catchall" { priority = 1 middlewares = [ { name = "rate-limit", namespace = kubernetes_namespace.traefik.metadata[0].name }, + { name = "crowdsec", namespace = kubernetes_namespace.traefik.metadata[0].name }, ] services = [{ name = "error-pages" diff --git a/stacks/traefik/modules/traefik/main.tf b/stacks/traefik/modules/traefik/main.tf index 1d72be22..1de3fc41 100644 --- a/stacks/traefik/modules/traefik/main.tf +++ b/stacks/traefik/modules/traefik/main.tf @@ -1,4 +1,8 @@ variable "tier" { type = string } +variable "crowdsec_api_key" { + type = string + sensitive = true +} variable "redis_host" { type = string } variable "tls_secret_name" {} variable "auth_fallback_htpasswd" { @@ -17,6 +21,16 @@ variable "x402_notify_webhook_url" { description = "Slack-compatible incoming-webhook URL the gateway POSTs to on every successful payment. Empty = no notifications." sensitive = true } +variable "captcha_site_key" { + type = string + sensitive = true + description = "Cloudflare Turnstile site key (public) for the CrowdSec captcha remediation. Sourced from cloudflare_turnstile_widget in the stack root." +} +variable "captcha_secret_key" { + type = string + sensitive = true + description = "Cloudflare Turnstile secret key for the CrowdSec captcha remediation — validated server-side by the bouncer plugin against Cloudflare siteverify." +} resource "kubernetes_namespace" "traefik" { metadata { @@ -34,6 +48,22 @@ resource "kubernetes_namespace" "traefik" { } } +# captcha.html template served by the CrowdSec bouncer plugin for Turnstile +# challenges. The pulled Yaegi plugin does NOT expose its bundled template to +# Traefik, so we vendor it (captcha.html in this module) and mount it into the +# Traefik container at /captcha via the `volumes` Helm value below. The template +# is provider-agnostic: the plugin fills {{ .FrontendJS }}/{{ .FrontendKey }}/ +# {{ .SiteKey }} with Turnstile's JS URL + `cf-turnstile` class + the site key. +resource "kubernetes_config_map" "captcha_template" { + metadata { + name = "crowdsec-captcha-template" + namespace = kubernetes_namespace.traefik.metadata[0].name + } + data = { + "captcha.html" = file("${path.module}/captcha.html") + } +} + resource "helm_release" "traefik" { namespace = kubernetes_namespace.traefik.metadata[0].name create_namespace = false @@ -45,9 +75,9 @@ resource "helm_release" "traefik" { # chart 41.0.0 rejects this values block's `logs` key ("Additional property # logs is not allowed"). Bump deliberately (with values migration), never # implicitly. Deployed since 2026-05-30 (release rev 57). - version = "40.2.0" - atomic = true - timeout = 600 + version = "40.2.0" + atomic = true + timeout = 600 values = [yamlencode({ deployment = { @@ -70,10 +100,13 @@ resource "helm_release" "traefik" { command = ["sh", "-c", join("", [ "set -e; ", "STORAGE=/plugins-storage; ", + "mkdir -p \"$STORAGE/archives/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin\"; ", + "wget -q -T 30 -O \"$STORAGE/archives/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/v1.6.0.zip\" ", + "\"https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/archive/refs/tags/v1.6.0.zip\"; ", "mkdir -p \"$STORAGE/archives/github.com/Aetherinox/traefik-api-token-middleware\"; ", "wget -q -T 30 -O \"$STORAGE/archives/github.com/Aetherinox/traefik-api-token-middleware/v0.1.4.zip\" ", "\"https://github.com/Aetherinox/traefik-api-token-middleware/archive/refs/tags/v0.1.4.zip\"; ", - "printf '{\"github.com/Aetherinox/traefik-api-token-middleware\":\"v0.1.4\"}' ", + "printf '{\"github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin\":\"v1.6.0\",\"github.com/Aetherinox/traefik-api-token-middleware\":\"v0.1.4\"}' ", "> \"$STORAGE/archives/state.json\"; ", "echo \"Plugins pre-downloaded successfully\"", ])] @@ -92,6 +125,15 @@ resource "helm_release" "traefik" { } } + # Mount the CrowdSec captcha template into the Traefik container at + # /captcha/captcha.html (chart `volumes` creates the volume + container + # mount from one entry). Referenced by captchaHTMLFilePath in middleware.tf. + volumes = [{ + name = kubernetes_config_map.captcha_template.metadata[0].name + mountPath = "/captcha" + type = "configMap" + }] + ingressClass = { enabled = true isDefaultClass = true @@ -188,6 +230,10 @@ resource "helm_release" "traefik" { # Plugins experimental = { plugins = { + crowdsec-bouncer = { + moduleName = "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" + version = "v1.6.0" + } # Static-token bearer/header auth middleware. Used by services that # need gateway-level API-key/bearer enforcement without app-layer auth # (e.g. paperless-mcp, which has no native auth). Plugin key diff --git a/stacks/traefik/modules/traefik/middleware.tf b/stacks/traefik/modules/traefik/middleware.tf index befeb6bf..9b9709c7 100644 --- a/stacks/traefik/modules/traefik/middleware.tf +++ b/stacks/traefik/modules/traefik/middleware.tf @@ -183,6 +183,59 @@ resource "kubernetes_manifest" "middleware_security_headers" { depends_on = [helm_release.traefik] } +# CrowdSec bouncer plugin middleware +resource "kubernetes_manifest" "middleware_crowdsec" { + manifest = { + apiVersion = "traefik.io/v1alpha1" + kind = "Middleware" + metadata = { + name = "crowdsec" + namespace = kubernetes_namespace.traefik.metadata[0].name + } + spec = { + plugin = { + crowdsec-bouncer = { + crowdsecLapiKey = var.crowdsec_api_key + crowdsecLapiHost = "crowdsec-service.crowdsec.svc.cluster.local:8080" + # LIVE mode (synchronous per-request LAPI query), not stream: under + # Traefik's Yaegi interpreter the plugin's stream cache updates (it logs + # `handleStreamCache:updated`) but does NOT enforce the cached decisions + # — verified by a ban that was present in the LAPI stream AND pulled by + # the plugin yet still let the banned IP through. Live mode queries LAPI + # per request (result cached per-IP for defaultDecisionSeconds), enforces + # reliably, and picks up new decisions immediately. LAPI is 3-replica + + # in-cluster; fail-open preserved via updateMaxFailure=-1. + crowdsecMode = "live" + updateMaxFailure = -1 # fail-open if LAPI is unreachable + # Redis cache DISABLED: the plugin's redis client does not work under + # Traefik's Yaegi interpreter — it logs `cache:unreachable` even though + # redis-master is reachable+writable from the traefik ns (verified). With + # the redis cache enabled + redisCacheUnreachableBlock=false the bouncer + # therefore failed open and enforced nothing. In-memory cache (the + # default when disabled) holds the streamed decision set per-pod and + # works under Yaegi. Trade-off: captcha "already-solved" grace is + # per-pod across the 3 Traefik replicas (at worst an occasional re-solve). + redisCacheEnabled = false + clientTrustedIPs = ["10.0.20.0/24", "10.10.0.0/16"] # node + pod CIDRs bypass CrowdSec + # Captcha remediation: serve a Cloudflare Turnstile challenge for + # `captcha`-type LAPI decisions instead of falling through to a 403 + # (the pre-2026-06 behaviour — no provider configured → handleBan). + # captcha.html is mounted at /captcha in the Traefik pod + # (kubernetes_config_map.captcha_template + the helm `volumes` value); + # keys come from the Turnstile widget in the stack root (main.tf). + captchaProvider = "turnstile" + captchaSiteKey = var.captcha_site_key + captchaSecretKey = var.captcha_secret_key + captchaGracePeriodSeconds = 1800 # how long a solved challenge is honoured + captchaHTMLFilePath = "/captcha/captcha.html" + } + } + } + } + + depends_on = [helm_release.traefik, kubernetes_config_map.captcha_template] +} + # TLS option for mTLS (client certificate auth) resource "kubernetes_manifest" "tls_option_mtls" { manifest = {