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>
430 lines
12 KiB
HCL
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]
|
|
}
|