infra/stacks/traefik/modules/traefik/middleware.tf
Viktor Barzin 203a71768d x402: consolidate to a single shared forwardAuth gateway
The per-site `x402_instance` module created one Deployment + Service +
PDB per protected host (9 in total, 9×64Mi). Every pod was running the
exact same logic with the same config — the only thing that varied
was the upstream URL, which we don't even need since the gateway can
return 200 to "allow" and Traefik handles the upstream itself.

Refactor to the same pattern as `ai-bot-block`:
 * single deployment + service in `traefik` namespace, 2 replicas, HA
 * Traefik `Middleware` CRD `x402` (forwardAuth → x402-gateway:8080/auth)
 * each consumer ingress just appends `traefik-x402@kubernetescrd` to
   its middleware chain via `extra_middlewares`

x402-gateway gains a `MODE=forwardauth` env var that returns 200 (allow)
or 402 (with x402 PaymentRequiredResponse body) instead of reverse-
proxying. Image: ghcr ... f4804d62.

Pod count: 9 → 2 (78% memory saved). All 9 sites verified still
serving the Anubis challenge to plain curl with identical TTFB.
DRY_RUN until `var.x402_wallet_address` is set on the traefik stack.

Removes `modules/kubernetes/x402_instance/` (dead code now).
2026-05-10 10:54:38 +00:00

389 lines
10 KiB
HCL

# Shared Traefik Middleware CRDs
# These are referenced by ingress resources via annotations like:
# "traefik.ingress.kubernetes.io/router.middlewares" = "traefik-rate-limit@kubernetescrd"
# Rate limiting middleware
resource "kubernetes_manifest" "middleware_rate_limit" {
manifest = {
apiVersion = "traefik.io/v1alpha1"
kind = "Middleware"
metadata = {
name = "rate-limit"
namespace = kubernetes_namespace.traefik.metadata[0].name
}
spec = {
rateLimit = {
average = 10
burst = 50
}
}
}
field_manager {
force_conflicts = true
}
depends_on = [helm_release.traefik]
}
# Authentik forward auth middleware
resource "kubernetes_manifest" "middleware_authentik_forward_auth" {
manifest = {
apiVersion = "traefik.io/v1alpha1"
kind = "Middleware"
metadata = {
name = "authentik-forward-auth"
namespace = kubernetes_namespace.traefik.metadata[0].name
}
spec = {
forwardAuth = {
address = "http://auth-proxy.traefik.svc.cluster.local:9000/outpost.goauthentik.io/auth/traefik"
trustForwardHeader = true
authResponseHeaders = [
"X-authentik-username",
"X-authentik-uid",
"X-authentik-email",
"X-authentik-name",
"X-authentik-groups",
"Set-Cookie",
]
}
}
}
depends_on = [helm_release.traefik]
}
# IP allowlist for local-only access
resource "kubernetes_manifest" "middleware_local_only" {
manifest = {
apiVersion = "traefik.io/v1alpha1"
kind = "Middleware"
metadata = {
name = "local-only"
namespace = kubernetes_namespace.traefik.metadata[0].name
}
spec = {
ipAllowList = {
sourceRange = [
"192.168.1.0/24",
"10.0.0.0/8",
"fc00::/7",
"fe80::/10",
]
}
}
}
depends_on = [helm_release.traefik]
}
# HTTPS redirect middleware
resource "kubernetes_manifest" "middleware_redirect_https" {
manifest = {
apiVersion = "traefik.io/v1alpha1"
kind = "Middleware"
metadata = {
name = "redirect-https"
namespace = kubernetes_namespace.traefik.metadata[0].name
}
spec = {
redirectScheme = {
scheme = "https"
permanent = true
}
}
}
depends_on = [helm_release.traefik]
}
# CSP headers middleware (default)
resource "kubernetes_manifest" "middleware_csp_headers" {
manifest = {
apiVersion = "traefik.io/v1alpha1"
kind = "Middleware"
metadata = {
name = "csp-headers"
namespace = kubernetes_namespace.traefik.metadata[0].name
}
spec = {
headers = {
contentSecurityPolicy = "frame-ancestors 'self' *.viktorbarzin.me viktorbarzin.me"
}
}
}
depends_on = [helm_release.traefik]
}
# Security headers middleware (HSTS, X-Frame-Options, etc.)
resource "kubernetes_manifest" "middleware_security_headers" {
manifest = {
apiVersion = "traefik.io/v1alpha1"
kind = "Middleware"
metadata = {
name = "security-headers"
namespace = kubernetes_namespace.traefik.metadata[0].name
}
spec = {
headers = {
stsSeconds = 31536000
stsIncludeSubdomains = true
frameDeny = true
contentTypeNosniff = true
browserXssFilter = true
referrerPolicy = "strict-origin-when-cross-origin"
permissionsPolicy = "camera=(), microphone=(), geolocation=()"
}
}
}
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"
crowdsecMode = "stream"
updateMaxFailure = -1 # fail-open: serve from cache when LAPI is unreachable
redisCacheEnabled = true
redisCacheHost = var.redis_host
redisCacheUnreachableBlock = false # don't block traffic if Redis is also unreachable
clientTrustedIPs = ["10.0.20.0/24", "10.10.0.0/16"] # node + pod CIDRs bypass CrowdSec
}
}
}
}
depends_on = [helm_release.traefik]
}
# TLS option for mTLS (client certificate auth)
resource "kubernetes_manifest" "tls_option_mtls" {
manifest = {
apiVersion = "traefik.io/v1alpha1"
kind = "TLSOption"
metadata = {
name = "mtls"
namespace = kubernetes_namespace.traefik.metadata[0].name
}
spec = {
clientAuth = {
secretNames = ["ca-secret"]
clientAuthType = "RequireAndVerifyClientCert"
}
}
}
depends_on = [helm_release.traefik]
}
# ServersTransport for backends with self-signed certificates
resource "kubernetes_manifest" "servers_transport_insecure" {
manifest = {
apiVersion = "traefik.io/v1alpha1"
kind = "ServersTransport"
metadata = {
name = "insecure-skip-verify"
namespace = kubernetes_namespace.traefik.metadata[0].name
}
spec = {
insecureSkipVerify = true
}
}
depends_on = [helm_release.traefik]
}
# Strip Authentik auth headers/cookies before forwarding to backend
# Useful for backends (iDRAC, TP-Link) that break when receiving extra headers
resource "kubernetes_manifest" "middleware_strip_auth_headers" {
manifest = {
apiVersion = "traefik.io/v1alpha1"
kind = "Middleware"
metadata = {
name = "strip-auth-headers"
namespace = kubernetes_namespace.traefik.metadata[0].name
}
spec = {
headers = {
customRequestHeaders = {
"X-authentik-username" = ""
"X-authentik-uid" = ""
"X-authentik-email" = ""
"X-authentik-name" = ""
"X-authentik-groups" = ""
}
}
}
}
depends_on = [helm_release.traefik]
}
# Immich-specific rate limit (higher limits for photo uploads)
resource "kubernetes_manifest" "middleware_immich_rate_limit" {
manifest = {
apiVersion = "traefik.io/v1alpha1"
kind = "Middleware"
metadata = {
name = "immich-rate-limit"
namespace = kubernetes_namespace.traefik.metadata[0].name
}
spec = {
rateLimit = {
average = 1000
burst = 20000
}
}
}
depends_on = [helm_release.traefik]
}
# Compress responses to clients at the entrypoint level (outermost).
# Applied at websecure entrypoint so all responses get compressed.
# Uses includedContentTypes (whitelist) instead of excludedContentTypes:
# - Only compresses text-based types that benefit from compression
# - Binary types (images, video, zip) are never compressed (no wasted CPU)
# - SSE (text/event-stream) is not listed = not compressed (safe for streaming)
# - WebSocket is safe regardless (Hijacker interface bypasses compress)
# - gRPC is hardcoded excluded in Traefik source (always safe)
resource "kubernetes_manifest" "middleware_compress" {
manifest = {
apiVersion = "traefik.io/v1alpha1"
kind = "Middleware"
metadata = {
name = "compress"
namespace = kubernetes_namespace.traefik.metadata[0].name
}
spec = {
compress = {
minResponseBodyBytes = 1024
includedContentTypes = [
"text/html",
"text/css",
"text/plain",
"text/xml",
"text/javascript",
"application/javascript",
"application/json",
"application/xml",
"application/xhtml+xml",
"application/rss+xml",
"application/atom+xml",
"image/svg+xml",
"application/wasm",
"font/woff2",
"font/woff",
"font/ttf",
"application/manifest+json",
]
}
}
}
field_manager {
force_conflicts = true
}
depends_on = [helm_release.traefik]
}
# ForwardAuth middleware to block known AI bot User-Agents
resource "kubernetes_manifest" "middleware_ai_bot_block" {
manifest = {
apiVersion = "traefik.io/v1alpha1"
kind = "Middleware"
metadata = {
name = "ai-bot-block"
namespace = kubernetes_namespace.traefik.metadata[0].name
}
spec = {
forwardAuth = {
address = "http://bot-block-proxy.traefik.svc.cluster.local:8080/auth"
trustForwardHeader = true
}
}
}
depends_on = [helm_release.traefik]
}
# x402 payment-required middleware. Traefik calls the shared x402-gateway
# in this namespace; the gateway returns 200 (allow) to browsers and curl,
# 402 with x402 PaymentRequiredResponse to declared AI-bot UAs (or to any
# request whose X-PAYMENT header fails facilitator validation).
# DRY_RUN until WALLET_ADDRESS is set on the gateway, in which case the
# gateway always returns 200.
resource "kubernetes_manifest" "middleware_x402" {
manifest = {
apiVersion = "traefik.io/v1alpha1"
kind = "Middleware"
metadata = {
name = "x402"
namespace = kubernetes_namespace.traefik.metadata[0].name
}
spec = {
forwardAuth = {
address = "http://x402-gateway.traefik.svc.cluster.local:8080/auth"
trustForwardHeader = true
}
}
}
depends_on = [helm_release.traefik, kubernetes_service.x402_gateway]
}
# X-Robots-Tag header to discourage compliant AI crawlers
resource "kubernetes_manifest" "middleware_anti_ai_headers" {
manifest = {
apiVersion = "traefik.io/v1alpha1"
kind = "Middleware"
metadata = {
name = "anti-ai-headers"
namespace = kubernetes_namespace.traefik.metadata[0].name
}
spec = {
headers = {
customResponseHeaders = {
"X-Robots-Tag" = "noai, noimageai"
}
}
}
}
depends_on = [helm_release.traefik]
}
# Retry middleware for transient backend failures (502/503 during restarts)
resource "kubernetes_manifest" "middleware_retry" {
manifest = {
apiVersion = "traefik.io/v1alpha1"
kind = "Middleware"
metadata = {
name = "retry"
namespace = kubernetes_namespace.traefik.metadata[0].name
}
spec = {
retry = {
attempts = 2
initialInterval = "100ms"
}
}
}
depends_on = [helm_release.traefik]
}