Mobile timeline scrubs prefetch ~100 thumbs in <1s, which exhausted the immich-rate-limit (avg=500, burst=5000) and produced a cascade of HTTP 429s. CrowdSec's local http-429-abuse scenario then fired captcha:1 on the source IP (alert #291, 2026-04-25 — owner's Hyperoptic IPv6). Two changes: - crowdsec: add a second whitelist doc (viktor/immich-asset-paths-whitelist) filtering events by Immich asset paths so they never feed leaky buckets. Auth endpoints intentionally excluded — brute-force protection unchanged. - traefik: raise immich-rate-limit avg=500->1000, burst=5000->20000 so legitimate mobile scrubs don't produce 429s in the first place.
364 lines
9.6 KiB
HCL
364 lines
9.6 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]
|
|
}
|
|
|
|
# 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]
|
|
}
|