infra/stacks/traefik/modules/traefik/middleware.tf
Viktor Barzin 0e837b57b8 authentik: add public guest auto-login flow + dedicated outpost + traefik public middleware
Phase 1+2 of default-deny ingress plan. Adds the infrastructure for an `auth = "public"`
ingress tier that auto-binds anonymous requests to a `guest` Authentik user (no UI
prompt), so public sites are still recorded as authenticated by Authentik for audit
purposes — but as `guest`, not by leaking the standard catchall flow.

- guest user in `Public Guests` group (NOT `Allow Login Users`).
- `public-auto-login` flow: stage_binding policy sets `pending_user = guest`,
  `evaluate_on_plan = false` + `re_evaluate_policies = true` so flow_plan is
  populated when the policy mutates it; `authentication = none` lets anonymous
  requests enter.
- `Provider for Public` proxy provider (forward_domain, cookie_domain
  viktorbarzin.me) with `authentication_flow = public-auto-login`.
- Dedicated `public` outpost: only the public provider bound, deployed as
  `ak-outpost-public` Deployment+Service in the `authentik` namespace by
  Authentik's K8s controller.
- `public-auth.viktorbarzin.me` ingress exposes the public outpost's
  `/outpost.goauthentik.io/*` so OAuth callbacks land on it (the embedded
  outpost doesn't know about the public provider, so `authentik.viktorbarzin.me`
  callbacks would fail).
- `authentik-forward-auth-public` traefik middleware points at the public
  outpost service (not via the auth-proxy nginx fallback). The plan's
  `?app=public` dispatch idea was tested and rejected — the embedded outpost
  dispatches purely by Host header, so a dedicated outpost was the only way
  to isolate the public flow without conflicts.

No ingresses use the new middleware yet — Phase 3+4 (the ingress_factory
`auth` variable refactor + audit pass) wires it up. This commit is additive
and behaviour-neutral.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 18:26:16 +00:00

430 lines
12 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 (default — login required).
# Used by ingress_factory `auth = "required"`.
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]
}
# Authentik forward auth — public tier. Calls the dedicated public outpost
# (`ak-outpost-public.authentik.svc`) where the `Public` proxy provider is the
# only bound provider, so every request runs the `public-auto-login` flow and
# auto-binds anonymous users to the `guest` user. Users with an existing
# Authentik session keep their real identity in `X-authentik-username`.
# Used by ingress_factory `auth = "public"`.
#
# This is intentionally a different upstream from the standard middleware
# (which targets the embedded outpost via the auth-proxy nginx fallback). The
# `?app=` query param is NOT a working dispatch knob in current Authentik —
# the embedded outpost dispatches by Host header alone, and the catchall's
# forward_domain mode already claims viktorbarzin.me, so the only way to
# isolate the public flow is via a dedicated outpost.
resource "kubernetes_manifest" "middleware_authentik_forward_auth_public" {
manifest = {
apiVersion = "traefik.io/v1alpha1"
kind = "Middleware"
metadata = {
name = "authentik-forward-auth-public"
namespace = kubernetes_namespace.traefik.metadata[0].name
}
spec = {
forwardAuth = {
address = "http://ak-outpost-public.authentik.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]
}