fix: restore tree dropped by 6d224861; land stem95su gdrive-sync (10m) [ci skip]
6d224861 came from a --no-checkout worktree whose empty index made the
commit drop every file except two. This restores 05b50d2b's full tree and
correctly adds stacks/stem95su/gdrive-sync.tf + the service-catalog stem95su
entry. Forward-only (parent=6d224861, no force-push); [ci skip] since the
live infra was never applied from the broken commit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
6d224861c4
commit
fd0f4a0365
1166 changed files with 358546 additions and 0 deletions
193
stacks/traefik/modules/traefik/error-pages.tf
Normal file
193
stacks/traefik/modules/traefik/error-pages.tf
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
# Custom error pages using tarampampam/error-pages
|
||||
# Serves themed error pages for 5xx errors and catch-all 404 for unknown hosts
|
||||
|
||||
resource "kubernetes_deployment" "error_pages" {
|
||||
metadata {
|
||||
name = "error-pages"
|
||||
namespace = kubernetes_namespace.traefik.metadata[0].name
|
||||
labels = {
|
||||
app = "error-pages"
|
||||
}
|
||||
}
|
||||
|
||||
spec {
|
||||
replicas = 2
|
||||
strategy {
|
||||
type = "RollingUpdate"
|
||||
rolling_update {
|
||||
max_unavailable = 0
|
||||
max_surge = 1
|
||||
}
|
||||
}
|
||||
selector {
|
||||
match_labels = {
|
||||
app = "error-pages"
|
||||
}
|
||||
}
|
||||
template {
|
||||
metadata {
|
||||
labels = {
|
||||
app = "error-pages"
|
||||
}
|
||||
annotations = {
|
||||
"diun.enable" = "true"
|
||||
}
|
||||
}
|
||||
spec {
|
||||
topology_spread_constraint {
|
||||
max_skew = 1
|
||||
topology_key = "kubernetes.io/hostname"
|
||||
when_unsatisfiable = "DoNotSchedule"
|
||||
label_selector {
|
||||
match_labels = {
|
||||
app = "error-pages"
|
||||
}
|
||||
}
|
||||
}
|
||||
container {
|
||||
name = "error-pages"
|
||||
image = "ghcr.io/tarampampam/error-pages:3"
|
||||
|
||||
port {
|
||||
container_port = 8080
|
||||
}
|
||||
|
||||
env {
|
||||
name = "TEMPLATE_NAME"
|
||||
value = "shuffle"
|
||||
}
|
||||
|
||||
liveness_probe {
|
||||
http_get {
|
||||
path = "/healthz"
|
||||
port = 8080
|
||||
}
|
||||
initial_delay_seconds = 3
|
||||
period_seconds = 10
|
||||
}
|
||||
readiness_probe {
|
||||
http_get {
|
||||
path = "/healthz"
|
||||
port = 8080
|
||||
}
|
||||
initial_delay_seconds = 2
|
||||
period_seconds = 5
|
||||
}
|
||||
|
||||
resources {
|
||||
requests = {
|
||||
cpu = "5m"
|
||||
memory = "32Mi"
|
||||
}
|
||||
limits = {
|
||||
memory = "32Mi"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [spec[0].template[0].spec[0].dns_config] # KYVERNO_LIFECYCLE_V1
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_service" "error_pages" {
|
||||
metadata {
|
||||
name = "error-pages"
|
||||
namespace = kubernetes_namespace.traefik.metadata[0].name
|
||||
labels = {
|
||||
app = "error-pages"
|
||||
}
|
||||
}
|
||||
|
||||
spec {
|
||||
selector = {
|
||||
app = "error-pages"
|
||||
}
|
||||
port {
|
||||
name = "http"
|
||||
port = 8080
|
||||
target_port = 8080
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Errors middleware — intercepts 5xx from backends and serves themed error pages
|
||||
resource "kubernetes_manifest" "middleware_error_pages" {
|
||||
manifest = {
|
||||
apiVersion = "traefik.io/v1alpha1"
|
||||
kind = "Middleware"
|
||||
metadata = {
|
||||
name = "error-pages"
|
||||
namespace = kubernetes_namespace.traefik.metadata[0].name
|
||||
}
|
||||
spec = {
|
||||
errors = {
|
||||
status = ["500-504"]
|
||||
service = {
|
||||
name = "error-pages"
|
||||
namespace = kubernetes_namespace.traefik.metadata[0].name
|
||||
port = 8080
|
||||
}
|
||||
query = "/{status}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
depends_on = [helm_release.traefik, kubernetes_service.error_pages]
|
||||
}
|
||||
|
||||
# Default TLSStore — serves wildcard cert for unknown hosts instead of self-signed fallback
|
||||
resource "kubernetes_manifest" "tlsstore_default" {
|
||||
manifest = {
|
||||
apiVersion = "traefik.io/v1alpha1"
|
||||
kind = "TLSStore"
|
||||
metadata = {
|
||||
name = "default"
|
||||
namespace = kubernetes_namespace.traefik.metadata[0].name
|
||||
}
|
||||
spec = {
|
||||
defaultCertificate = {
|
||||
secretName = var.tls_secret_name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
depends_on = [helm_release.traefik, module.tls_secret]
|
||||
}
|
||||
|
||||
# Catch-all IngressRoute — serves 404 for unmatched *.viktorbarzin.me hosts (lowest priority)
|
||||
# Only matches *.viktorbarzin.me — non-viktorbarzin.me domains get TLS rejection (no matching router)
|
||||
# This prevents leaking the wildcard cert to attackers who point arbitrary domains at our IP
|
||||
resource "kubernetes_manifest" "ingressroute_catchall" {
|
||||
manifest = {
|
||||
apiVersion = "traefik.io/v1alpha1"
|
||||
kind = "IngressRoute"
|
||||
metadata = {
|
||||
name = "catchall-error-pages"
|
||||
namespace = kubernetes_namespace.traefik.metadata[0].name
|
||||
}
|
||||
spec = {
|
||||
entryPoints = ["websecure"]
|
||||
routes = [{
|
||||
match = "HostRegexp(`^(.+\\.)?viktorbarzin\\.me$`)"
|
||||
kind = "Rule"
|
||||
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"
|
||||
namespace = kubernetes_namespace.traefik.metadata[0].name
|
||||
port = 8080
|
||||
}]
|
||||
}]
|
||||
tls = {}
|
||||
}
|
||||
}
|
||||
|
||||
depends_on = [helm_release.traefik, kubernetes_service.error_pages]
|
||||
}
|
||||
934
stacks/traefik/modules/traefik/main.tf
Normal file
934
stacks/traefik/modules/traefik/main.tf
Normal file
|
|
@ -0,0 +1,934 @@
|
|||
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" {
|
||||
type = string
|
||||
description = "htpasswd-format string for emergency basicAuth fallback when Authentik is down"
|
||||
sensitive = true
|
||||
}
|
||||
variable "x402_wallet_address" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "EVM wallet (Base mainnet, 0x…) that receives USDC from x402 payments. Empty = DRY_RUN, gateway always returns 200 to forwardAuth so traffic is unaffected."
|
||||
}
|
||||
variable "x402_notify_webhook_url" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "Slack-compatible incoming-webhook URL the gateway POSTs to on every successful payment. Empty = no notifications."
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
resource "kubernetes_namespace" "traefik" {
|
||||
metadata {
|
||||
name = "traefik"
|
||||
labels = {
|
||||
"app.kubernetes.io/name" = "traefik"
|
||||
"app.kubernetes.io/instance" = "traefik"
|
||||
tier = var.tier
|
||||
"keel.sh/enrolled" = "true"
|
||||
}
|
||||
}
|
||||
lifecycle {
|
||||
# KYVERNO_LIFECYCLE_V1: goldilocks-vpa-auto-mode ClusterPolicy stamps this label on every namespace
|
||||
ignore_changes = [metadata[0].labels["goldilocks.fairwinds.com/vpa-update-mode"]]
|
||||
}
|
||||
}
|
||||
|
||||
resource "helm_release" "traefik" {
|
||||
namespace = kubernetes_namespace.traefik.metadata[0].name
|
||||
create_namespace = false
|
||||
name = "traefik"
|
||||
repository = "https://traefik.github.io/charts"
|
||||
chart = "traefik"
|
||||
atomic = true
|
||||
timeout = 600
|
||||
|
||||
values = [yamlencode({
|
||||
deployment = {
|
||||
replicas = 3
|
||||
terminationGracePeriodSeconds = 60
|
||||
lifecycle = {
|
||||
preStop = {
|
||||
exec = {
|
||||
command = ["/bin/sh", "-c", "sleep 15"]
|
||||
}
|
||||
}
|
||||
}
|
||||
podAnnotations = {
|
||||
"diun.enable" = "true"
|
||||
"diun.include_tags" = "^v\\d+(?:\\.\\d+)?(?:\\.\\d+)?.*$"
|
||||
}
|
||||
initContainers = [{
|
||||
name = "download-plugins"
|
||||
image = "alpine:3"
|
||||
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.4.2.zip\" ",
|
||||
"\"https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/archive/refs/tags/v1.4.2.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/maxlerebourg/crowdsec-bouncer-traefik-plugin\":\"v1.4.2\",\"github.com/Aetherinox/traefik-api-token-middleware\":\"v0.1.4\"}' ",
|
||||
"> \"$STORAGE/archives/state.json\"; ",
|
||||
"echo \"Plugins pre-downloaded successfully\"",
|
||||
])]
|
||||
volumeMounts = [{
|
||||
name = "plugins"
|
||||
mountPath = "/plugins-storage"
|
||||
}]
|
||||
}]
|
||||
}
|
||||
|
||||
updateStrategy = {
|
||||
type = "RollingUpdate"
|
||||
rollingUpdate = {
|
||||
maxUnavailable = 0
|
||||
maxSurge = 1
|
||||
}
|
||||
}
|
||||
|
||||
ingressClass = {
|
||||
enabled = true
|
||||
isDefaultClass = true
|
||||
}
|
||||
|
||||
providers = {
|
||||
kubernetesIngress = {
|
||||
enabled = true
|
||||
allowExternalNameServices = true
|
||||
publishedService = { enabled = true }
|
||||
}
|
||||
kubernetesCRD = {
|
||||
enabled = true
|
||||
allowExternalNameServices = true
|
||||
allowCrossNamespace = true
|
||||
}
|
||||
}
|
||||
|
||||
# Enable dashboard API (accessible on port 8080 internally)
|
||||
api = {
|
||||
insecure = false
|
||||
}
|
||||
|
||||
# Entrypoints
|
||||
ports = {
|
||||
web = {
|
||||
port = 8000
|
||||
exposedPort = 80
|
||||
protocol = "TCP"
|
||||
http = {
|
||||
redirections = {
|
||||
entryPoint = {
|
||||
to = "websecure"
|
||||
scheme = "https"
|
||||
}
|
||||
}
|
||||
}
|
||||
proxyProtocol = {
|
||||
trustedIPs = ["10.0.20.1"]
|
||||
}
|
||||
}
|
||||
websecure = {
|
||||
port = 8443
|
||||
exposedPort = 443
|
||||
protocol = "TCP"
|
||||
http = {
|
||||
tls = {
|
||||
enabled = true
|
||||
}
|
||||
middlewares = [
|
||||
"traefik-compress@kubernetescrd",
|
||||
]
|
||||
}
|
||||
http3 = {
|
||||
enabled = true
|
||||
advertisedPort = 443
|
||||
}
|
||||
# Accept PROXY-v2 ONLY from the pfSense HAProxy IPv6 bridge (10.0.20.1)
|
||||
# so IPv6 clients (forwarded [2001:470:6e:43d::2] -> here) get their real
|
||||
# IP for CrowdSec. Real IPv4 clients arrive with their own source IP
|
||||
# (ETP=Local, not 10.0.20.1) and are unaffected.
|
||||
proxyProtocol = {
|
||||
trustedIPs = ["10.0.20.1"]
|
||||
}
|
||||
}
|
||||
whisper-tcp = {
|
||||
port = 10300
|
||||
exposedPort = 10300
|
||||
protocol = "TCP"
|
||||
expose = { default = true }
|
||||
}
|
||||
piper-tcp = {
|
||||
port = 10200
|
||||
exposedPort = 10200
|
||||
protocol = "TCP"
|
||||
expose = { default = true }
|
||||
}
|
||||
}
|
||||
|
||||
service = {
|
||||
type = "LoadBalancer"
|
||||
annotations = {
|
||||
# Dedicated IP + ETP=Local so direct-app clients keep their real source
|
||||
# IP (CrowdSec) and QUIC handshakes pin to one pod. Proxied apps are
|
||||
# unaffected — cloudflared targets the in-cluster Traefik Service
|
||||
# (traefik.traefik.svc), not this LB IP, so the LB IP can move freely.
|
||||
"metallb.io/loadBalancerIPs" = "10.0.20.203"
|
||||
}
|
||||
spec = {
|
||||
externalTrafficPolicy = "Local"
|
||||
}
|
||||
}
|
||||
|
||||
# Plugins
|
||||
experimental = {
|
||||
plugins = {
|
||||
crowdsec-bouncer = {
|
||||
moduleName = "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
version = "v1.4.2"
|
||||
}
|
||||
# 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
|
||||
# `api-token-middleware` is the name to use as the inner key in
|
||||
# `Middleware.spec.plugin.<key>` on consuming Middleware CRDs.
|
||||
api-token-middleware = {
|
||||
moduleName = "github.com/Aetherinox/traefik-api-token-middleware"
|
||||
version = "v0.1.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Prometheus metrics
|
||||
metrics = {
|
||||
prometheus = {
|
||||
entryPoint = "metrics"
|
||||
addEntryPointsLabels = true
|
||||
addServicesLabels = true
|
||||
addRoutersLabels = true
|
||||
buckets = "0.01,0.05,0.1,0.2,0.5,1.0,2.0,5.0,10.0,30.0"
|
||||
}
|
||||
}
|
||||
|
||||
# Access logs
|
||||
logs = {
|
||||
access = {
|
||||
enabled = true
|
||||
}
|
||||
}
|
||||
|
||||
additionalArguments = [
|
||||
"--global.checknewversion=false",
|
||||
"--global.sendanonymoususage=false",
|
||||
# Skip TLS verification for self-signed backend certs (proxmox, idrac, etc.)
|
||||
"--serversTransport.insecureSkipVerify=true",
|
||||
# Increase timeouts for services like Immich
|
||||
"--serversTransport.forwardingTimeouts.dialTimeout=60s",
|
||||
"--serversTransport.forwardingTimeouts.responseHeaderTimeout=30s",
|
||||
"--serversTransport.forwardingTimeouts.idleConnTimeout=90s",
|
||||
# Increase backend connection pool (default maxIdleConnsPerHost=2 is too low)
|
||||
"--serversTransport.maxIdleConnsPerHost=100",
|
||||
# Entrypoint transport timeouts. NOTE: Traefik respondingTimeouts are HARD caps on
|
||||
# total request/response duration (unlike nginx proxy_*_timeout, which reset per read).
|
||||
# A finite writeTimeout therefore caps total *download* time regardless of progress —
|
||||
# a prior writeTimeout=60s silently truncated large downloads at 60s (HTTP/2 reset).
|
||||
# writeTimeout=0 -> unlimited download size/duration (Traefik's own default; Immich's
|
||||
# reverse-proxy guidance assumes it — it never sets writeTimeout).
|
||||
# readTimeout=3600s -> one upload may take up to 1h. NOT 0: an unbounded request read
|
||||
# is the slow-loris vector (hence Traefik's 60s default). Immich has
|
||||
# no resumable upload, so the window must exceed real upload times.
|
||||
"--entryPoints.websecure.transport.respondingTimeouts.readTimeout=3600s",
|
||||
"--entryPoints.websecure.transport.respondingTimeouts.writeTimeout=0s",
|
||||
"--entryPoints.websecure.transport.respondingTimeouts.idleTimeout=600s",
|
||||
# Use forwarded headers from trusted proxies
|
||||
"--entryPoints.websecure.forwardedHeaders.insecure=false",
|
||||
"--entryPoints.web.forwardedHeaders.insecure=false",
|
||||
"--entryPoints.websecure.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,104.24.0.0/14,172.64.0.0/13,131.0.72.0/22,10.0.0.0/8,192.168.0.0/16",
|
||||
"--entryPoints.web.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,104.24.0.0/14,172.64.0.0/13,131.0.72.0/22,10.0.0.0/8,192.168.0.0/16",
|
||||
]
|
||||
|
||||
resources = {
|
||||
requests = {
|
||||
cpu = "100m"
|
||||
memory = "768Mi"
|
||||
}
|
||||
limits = {
|
||||
memory = "768Mi"
|
||||
}
|
||||
}
|
||||
|
||||
nodeSelector = {
|
||||
"kubernetes.io/os" = "linux"
|
||||
}
|
||||
|
||||
tolerations = []
|
||||
|
||||
topologySpreadConstraints = [{
|
||||
maxSkew = 1
|
||||
topologyKey = "kubernetes.io/hostname"
|
||||
whenUnsatisfiable = "DoNotSchedule"
|
||||
labelSelector = {
|
||||
matchLabels = {
|
||||
"app.kubernetes.io/name" = "traefik"
|
||||
}
|
||||
}
|
||||
}]
|
||||
|
||||
podDisruptionBudget = {
|
||||
enabled = true
|
||||
minAvailable = 2
|
||||
}
|
||||
})]
|
||||
}
|
||||
|
||||
# Dashboard resources
|
||||
module "tls_secret" {
|
||||
source = "../../../../modules/kubernetes/setup_tls_secret"
|
||||
namespace = kubernetes_namespace.traefik.metadata[0].name
|
||||
tls_secret_name = var.tls_secret_name
|
||||
}
|
||||
|
||||
resource "kubernetes_service" "traefik_dashboard" {
|
||||
metadata {
|
||||
name = "traefik-dashboard"
|
||||
namespace = kubernetes_namespace.traefik.metadata[0].name
|
||||
labels = {
|
||||
"app" = "traefik-dashboard"
|
||||
}
|
||||
}
|
||||
|
||||
spec {
|
||||
selector = {
|
||||
"app.kubernetes.io/name" = "traefik"
|
||||
}
|
||||
port {
|
||||
name = "http"
|
||||
port = 8080
|
||||
target_port = 8080
|
||||
protocol = "TCP"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module "ingress" {
|
||||
source = "../../../../modules/kubernetes/ingress_factory"
|
||||
dns_type = "non-proxied"
|
||||
namespace = kubernetes_namespace.traefik.metadata[0].name
|
||||
name = "traefik"
|
||||
service_name = "traefik-dashboard"
|
||||
host = "traefik"
|
||||
port = 8080
|
||||
tls_secret_name = var.tls_secret_name
|
||||
auth = "required"
|
||||
extra_annotations = {
|
||||
"gethomepage.dev/enabled" = "true"
|
||||
"gethomepage.dev/name" = "Traefik"
|
||||
"gethomepage.dev/description" = "Reverse proxy & ingress"
|
||||
"gethomepage.dev/icon" = "traefik.png"
|
||||
"gethomepage.dev/group" = "Core Platform"
|
||||
"gethomepage.dev/pod-selector" = ""
|
||||
}
|
||||
}
|
||||
|
||||
# Bot-block resilience proxy: nginx reverse proxy in front of Poison Fountain
|
||||
# Forward-auth target for the ai-bot-block middleware. The poison-fountain bot
|
||||
# trap is intentionally scaled to 0 (stacks/poison-fountain), so /auth is a
|
||||
# clean no-op returning 200 (allow-all) rather than proxying to an absent
|
||||
# upstream. Reloader (annotation on the Deployment below) rolls the pods when
|
||||
# this ConfigMap changes — openresty does not reload on its own.
|
||||
resource "kubernetes_config_map" "bot_block_proxy_config" {
|
||||
metadata {
|
||||
name = "bot-block-proxy-config"
|
||||
namespace = kubernetes_namespace.traefik.metadata[0].name
|
||||
}
|
||||
|
||||
data = {
|
||||
"default.conf" = <<-EOT
|
||||
server {
|
||||
listen 8080;
|
||||
|
||||
# Browsers accumulate one authentik_proxy_<random> cookie per Authentik
|
||||
# Proxy Provider on the parent domain. With 30+ services under
|
||||
# viktorbarzin.me the combined Cookie header exceeds nginx's default
|
||||
# 4 x 8k large_client_header_buffers and the ai-bot-block forward-auth
|
||||
# rejects it with 400 (and error-pages then shows "Too big request
|
||||
# header" 431). NOTE the *binding* limit for browsers is Traefik's
|
||||
# HTTP/2 header cap (~64KB, Go maxHeaderListSize, not configurable) —
|
||||
# bigger piles are rejected upstream of here regardless. This 256k
|
||||
# only keeps bot-block from being a *tighter* bottleneck (and covers
|
||||
# HTTP/1.1 clients). poison-fountain (the bot check) ignores cookies.
|
||||
# Real fix for >64KB piles = reduce authentik_proxy_* accumulation.
|
||||
client_header_buffer_size 8k;
|
||||
large_client_header_buffers 8 256k;
|
||||
|
||||
location /auth {
|
||||
access_by_lua_block {
|
||||
ngx.req.clear_header("If-Match")
|
||||
ngx.req.clear_header("If-None-Match")
|
||||
ngx.req.clear_header("If-Modified-Since")
|
||||
ngx.req.clear_header("If-Unmodified-Since")
|
||||
}
|
||||
# poison-fountain (the bot trap) is intentionally scaled to 0
|
||||
# (stacks/poison-fountain, replicas=0). With no upstream to
|
||||
# consult we short-circuit to allow-all here -- the SAME effective
|
||||
# behaviour as the prior proxy_pass + error_page-5xx-to-200
|
||||
# fail-open (poison-fountain down => 200 allowed), minus the
|
||||
# per-request connect attempt that logged ~51k errors/hr once pod
|
||||
# logs shipped to Loki (2026-06-05) and cost up to 100ms/req. To
|
||||
# re-enable the trap: restore the upstream + proxy_pass (git
|
||||
# history) and scale poison-fountain up.
|
||||
return 200 "allowed";
|
||||
}
|
||||
location /healthz {
|
||||
access_log off;
|
||||
return 200 "ok";
|
||||
}
|
||||
}
|
||||
EOT
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_deployment" "bot_block_proxy" {
|
||||
metadata {
|
||||
name = "bot-block-proxy"
|
||||
namespace = kubernetes_namespace.traefik.metadata[0].name
|
||||
labels = {
|
||||
app = "bot-block-proxy"
|
||||
}
|
||||
annotations = {
|
||||
# openresty does not hot-reload its ConfigMap-mounted default.conf, so a
|
||||
# config change needs a pod roll. Reloader watches the named ConfigMap and
|
||||
# rolls this Deployment on change (the missing piece that let stale config
|
||||
# run for days before 2026-06-05).
|
||||
"configmap.reloader.stakater.com/reload" = "bot-block-proxy-config"
|
||||
}
|
||||
}
|
||||
|
||||
spec {
|
||||
replicas = 2
|
||||
strategy {
|
||||
type = "RollingUpdate"
|
||||
rolling_update {
|
||||
max_unavailable = 0
|
||||
max_surge = 1
|
||||
}
|
||||
}
|
||||
selector {
|
||||
match_labels = {
|
||||
app = "bot-block-proxy"
|
||||
}
|
||||
}
|
||||
template {
|
||||
metadata {
|
||||
labels = {
|
||||
app = "bot-block-proxy"
|
||||
}
|
||||
}
|
||||
spec {
|
||||
topology_spread_constraint {
|
||||
max_skew = 1
|
||||
topology_key = "kubernetes.io/hostname"
|
||||
when_unsatisfiable = "DoNotSchedule"
|
||||
label_selector {
|
||||
match_labels = {
|
||||
app = "bot-block-proxy"
|
||||
}
|
||||
}
|
||||
}
|
||||
container {
|
||||
name = "nginx"
|
||||
image = "openresty/openresty:alpine"
|
||||
|
||||
port {
|
||||
container_port = 8080
|
||||
}
|
||||
|
||||
volume_mount {
|
||||
name = "config"
|
||||
mount_path = "/etc/nginx/conf.d"
|
||||
read_only = true
|
||||
}
|
||||
|
||||
liveness_probe {
|
||||
http_get {
|
||||
path = "/healthz"
|
||||
port = 8080
|
||||
}
|
||||
initial_delay_seconds = 3
|
||||
period_seconds = 10
|
||||
}
|
||||
readiness_probe {
|
||||
http_get {
|
||||
path = "/healthz"
|
||||
port = 8080
|
||||
}
|
||||
initial_delay_seconds = 2
|
||||
period_seconds = 5
|
||||
}
|
||||
|
||||
resources {
|
||||
requests = {
|
||||
cpu = "5m"
|
||||
memory = "64Mi"
|
||||
}
|
||||
limits = {
|
||||
memory = "64Mi"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
volume {
|
||||
name = "config"
|
||||
config_map {
|
||||
name = kubernetes_config_map.bot_block_proxy_config.metadata[0].name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycle {
|
||||
# KYVERNO_LIFECYCLE_V1: Kyverno admission webhook mutates dns_config with ndots=2
|
||||
ignore_changes = [spec[0].template[0].spec[0].dns_config]
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_service" "bot_block_proxy" {
|
||||
metadata {
|
||||
name = "bot-block-proxy"
|
||||
namespace = kubernetes_namespace.traefik.metadata[0].name
|
||||
labels = {
|
||||
app = "bot-block-proxy"
|
||||
}
|
||||
}
|
||||
|
||||
spec {
|
||||
selector = {
|
||||
app = "bot-block-proxy"
|
||||
}
|
||||
port {
|
||||
name = "http"
|
||||
port = 8080
|
||||
target_port = 8080
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# x402 payment gateway — shared forwardAuth target for every ingress that
|
||||
# wants to issue HTTP 402 to declared AI-bot UAs / accept X-PAYMENT for paid
|
||||
# access. One deployment serves all hosts; each consumer ingress just adds
|
||||
# `traefik-x402@kubernetescrd` to its middleware chain.
|
||||
#
|
||||
# DRY_RUN until `var.x402_wallet_address` is set. While dry-run, every
|
||||
# auth call returns 200 (allow) so traffic is unaffected.
|
||||
resource "kubernetes_deployment" "x402_gateway" {
|
||||
metadata {
|
||||
name = "x402-gateway"
|
||||
namespace = kubernetes_namespace.traefik.metadata[0].name
|
||||
labels = { app = "x402-gateway" }
|
||||
}
|
||||
|
||||
spec {
|
||||
replicas = 2 # Stateless; HA across two pods is cheap.
|
||||
selector {
|
||||
match_labels = { app = "x402-gateway" }
|
||||
}
|
||||
strategy {
|
||||
type = "RollingUpdate"
|
||||
rolling_update {
|
||||
max_surge = 1
|
||||
max_unavailable = 0
|
||||
}
|
||||
}
|
||||
template {
|
||||
metadata {
|
||||
labels = { app = "x402-gateway" }
|
||||
}
|
||||
spec {
|
||||
image_pull_secrets {
|
||||
name = "registry-credentials"
|
||||
}
|
||||
topology_spread_constraint {
|
||||
max_skew = 1
|
||||
topology_key = "kubernetes.io/hostname"
|
||||
when_unsatisfiable = "ScheduleAnyway"
|
||||
label_selector {
|
||||
match_labels = { app = "x402-gateway" }
|
||||
}
|
||||
}
|
||||
container {
|
||||
name = "x402-gateway"
|
||||
image = "forgejo.viktorbarzin.me/viktor/x402-gateway:d9b83125"
|
||||
port {
|
||||
name = "http"
|
||||
container_port = 8923
|
||||
}
|
||||
port {
|
||||
name = "metrics"
|
||||
container_port = 9090
|
||||
}
|
||||
env {
|
||||
name = "MODE"
|
||||
value = "forwardauth"
|
||||
}
|
||||
env {
|
||||
name = "BIND"
|
||||
value = ":8923"
|
||||
}
|
||||
env {
|
||||
name = "METRICS_BIND"
|
||||
value = ":9090"
|
||||
}
|
||||
env {
|
||||
name = "WALLET_ADDRESS"
|
||||
value = var.x402_wallet_address
|
||||
}
|
||||
env {
|
||||
name = "PRICE_LABEL"
|
||||
value = "$0.01"
|
||||
}
|
||||
env {
|
||||
name = "PRICE_USDC_MICROS"
|
||||
value = "10000"
|
||||
}
|
||||
env {
|
||||
name = "NETWORK"
|
||||
value = "base"
|
||||
}
|
||||
env {
|
||||
name = "FACILITATOR_URL"
|
||||
value = "https://x402.org/facilitator"
|
||||
}
|
||||
# Slack incoming-webhook for real-time payment notifications.
|
||||
# Reuses the existing Alertmanager channel — payment events appear
|
||||
# alongside infra alerts. Reads from secret/viktor.alertmanager_slack_api_url.
|
||||
env {
|
||||
name = "NOTIFY_WEBHOOK_URL"
|
||||
value = var.x402_notify_webhook_url
|
||||
}
|
||||
resources {
|
||||
requests = {
|
||||
cpu = "10m"
|
||||
memory = "64Mi"
|
||||
}
|
||||
limits = {
|
||||
memory = "128Mi"
|
||||
}
|
||||
}
|
||||
liveness_probe {
|
||||
http_get {
|
||||
path = "/healthz"
|
||||
port = "metrics"
|
||||
}
|
||||
initial_delay_seconds = 5
|
||||
period_seconds = 30
|
||||
}
|
||||
readiness_probe {
|
||||
http_get {
|
||||
path = "/healthz"
|
||||
port = "metrics"
|
||||
}
|
||||
initial_delay_seconds = 1
|
||||
period_seconds = 5
|
||||
}
|
||||
security_context {
|
||||
run_as_non_root = true
|
||||
run_as_user = 65532
|
||||
run_as_group = 65532
|
||||
allow_privilege_escalation = false
|
||||
read_only_root_filesystem = true
|
||||
capabilities {
|
||||
drop = ["ALL"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lifecycle {
|
||||
# KYVERNO_LIFECYCLE_V1: Kyverno admission webhook mutates dns_config with ndots=2
|
||||
ignore_changes = [spec[0].template[0].spec[0].dns_config]
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_service" "x402_gateway" {
|
||||
metadata {
|
||||
name = "x402-gateway"
|
||||
namespace = kubernetes_namespace.traefik.metadata[0].name
|
||||
labels = { app = "x402-gateway" }
|
||||
annotations = {
|
||||
"prometheus.io/scrape" = "true"
|
||||
"prometheus.io/path" = "/metrics"
|
||||
"prometheus.io/port" = "9090"
|
||||
}
|
||||
}
|
||||
|
||||
spec {
|
||||
selector = { app = "x402-gateway" }
|
||||
port {
|
||||
name = "http"
|
||||
port = 8080
|
||||
target_port = 8923
|
||||
}
|
||||
port {
|
||||
name = "metrics"
|
||||
port = 9090
|
||||
target_port = 9090
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_pod_disruption_budget_v1" "x402_gateway" {
|
||||
metadata {
|
||||
name = "x402-gateway"
|
||||
namespace = kubernetes_namespace.traefik.metadata[0].name
|
||||
}
|
||||
spec {
|
||||
min_available = "1"
|
||||
selector {
|
||||
match_labels = { app = "x402-gateway" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Resilience proxy for Authentik ForwardAuth
|
||||
# Falls back to basicAuth when Authentik is unreachable
|
||||
resource "kubernetes_secret" "auth_proxy_htpasswd" {
|
||||
metadata {
|
||||
name = "auth-proxy-htpasswd"
|
||||
namespace = kubernetes_namespace.traefik.metadata[0].name
|
||||
}
|
||||
|
||||
data = {
|
||||
"htpasswd" = var.auth_fallback_htpasswd
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_config_map" "auth_proxy_config" {
|
||||
metadata {
|
||||
name = "auth-proxy-config"
|
||||
namespace = kubernetes_namespace.traefik.metadata[0].name
|
||||
}
|
||||
|
||||
data = {
|
||||
"default.conf" = <<-EOT
|
||||
upstream authentik {
|
||||
server ak-outpost-authentik-embedded-outpost.authentik.svc.cluster.local:9000;
|
||||
}
|
||||
server {
|
||||
listen 9000;
|
||||
|
||||
# Browsers accumulate one authentik_proxy_<random> cookie per Authentik
|
||||
# Proxy Provider on the parent domain. With 30+ services under
|
||||
# viktorbarzin.me the combined Cookie header exceeds nginx's default
|
||||
# 4 x 8k large_client_header_buffers and trips "Too big request header"
|
||||
# (431). Bump to 8 x 64k so the auth check accepts the pile.
|
||||
client_header_buffer_size 8k;
|
||||
large_client_header_buffers 8 64k;
|
||||
|
||||
location /outpost.goauthentik.io/auth/traefik {
|
||||
proxy_pass http://authentik;
|
||||
proxy_connect_timeout 3s;
|
||||
proxy_read_timeout 5s;
|
||||
proxy_send_timeout 5s;
|
||||
proxy_intercept_errors on;
|
||||
error_page 502 503 504 = @fallback_auth;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
|
||||
}
|
||||
|
||||
location @fallback_auth {
|
||||
auth_basic "Emergency Access";
|
||||
auth_basic_user_file /etc/nginx/htpasswd;
|
||||
# Set ALL X-authentik-* headers to prevent client-supplied header spoofing.
|
||||
# Without this, a client could inject fake X-authentik-groups and backends
|
||||
# that trust these headers would grant elevated access.
|
||||
add_header X-authentik-username $remote_user always;
|
||||
add_header X-authentik-uid "" always;
|
||||
add_header X-authentik-email "" always;
|
||||
add_header X-authentik-name "" always;
|
||||
add_header X-authentik-groups "" always;
|
||||
add_header X-Auth-Fallback "true" always;
|
||||
root /usr/share/nginx/fallback;
|
||||
try_files /ok =403;
|
||||
}
|
||||
|
||||
location /outpost.goauthentik.io/ {
|
||||
proxy_pass http://authentik;
|
||||
proxy_connect_timeout 3s;
|
||||
proxy_read_timeout 10s;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /healthz {
|
||||
access_log off;
|
||||
return 200 "ok";
|
||||
}
|
||||
}
|
||||
EOT
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_config_map" "auth_proxy_fallback" {
|
||||
metadata {
|
||||
name = "auth-proxy-fallback"
|
||||
namespace = kubernetes_namespace.traefik.metadata[0].name
|
||||
}
|
||||
|
||||
data = {
|
||||
"ok" = "authenticated"
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_deployment" "auth_proxy" {
|
||||
metadata {
|
||||
name = "auth-proxy"
|
||||
namespace = kubernetes_namespace.traefik.metadata[0].name
|
||||
labels = {
|
||||
app = "auth-proxy"
|
||||
}
|
||||
}
|
||||
|
||||
spec {
|
||||
replicas = 2
|
||||
strategy {
|
||||
type = "RollingUpdate"
|
||||
rolling_update {
|
||||
max_unavailable = 0
|
||||
max_surge = 1
|
||||
}
|
||||
}
|
||||
selector {
|
||||
match_labels = {
|
||||
app = "auth-proxy"
|
||||
}
|
||||
}
|
||||
template {
|
||||
metadata {
|
||||
labels = {
|
||||
app = "auth-proxy"
|
||||
}
|
||||
}
|
||||
spec {
|
||||
topology_spread_constraint {
|
||||
max_skew = 1
|
||||
topology_key = "kubernetes.io/hostname"
|
||||
when_unsatisfiable = "DoNotSchedule"
|
||||
label_selector {
|
||||
match_labels = {
|
||||
app = "auth-proxy"
|
||||
}
|
||||
}
|
||||
}
|
||||
container {
|
||||
name = "nginx"
|
||||
image = "nginx:1-alpine"
|
||||
|
||||
port {
|
||||
container_port = 9000
|
||||
}
|
||||
|
||||
volume_mount {
|
||||
name = "config"
|
||||
mount_path = "/etc/nginx/conf.d"
|
||||
read_only = true
|
||||
}
|
||||
volume_mount {
|
||||
name = "htpasswd"
|
||||
mount_path = "/etc/nginx/htpasswd"
|
||||
sub_path = "htpasswd"
|
||||
read_only = true
|
||||
}
|
||||
volume_mount {
|
||||
name = "fallback"
|
||||
mount_path = "/usr/share/nginx/fallback"
|
||||
read_only = true
|
||||
}
|
||||
|
||||
liveness_probe {
|
||||
http_get {
|
||||
path = "/healthz"
|
||||
port = 9000
|
||||
}
|
||||
initial_delay_seconds = 3
|
||||
period_seconds = 10
|
||||
}
|
||||
readiness_probe {
|
||||
http_get {
|
||||
path = "/healthz"
|
||||
port = 9000
|
||||
}
|
||||
initial_delay_seconds = 2
|
||||
period_seconds = 5
|
||||
}
|
||||
|
||||
resources {
|
||||
requests = {
|
||||
cpu = "5m"
|
||||
memory = "64Mi"
|
||||
}
|
||||
limits = {
|
||||
memory = "64Mi"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
volume {
|
||||
name = "config"
|
||||
config_map {
|
||||
name = kubernetes_config_map.auth_proxy_config.metadata[0].name
|
||||
}
|
||||
}
|
||||
volume {
|
||||
name = "htpasswd"
|
||||
secret {
|
||||
secret_name = kubernetes_secret.auth_proxy_htpasswd.metadata[0].name
|
||||
}
|
||||
}
|
||||
volume {
|
||||
name = "fallback"
|
||||
config_map {
|
||||
name = kubernetes_config_map.auth_proxy_fallback.metadata[0].name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycle {
|
||||
# KYVERNO_LIFECYCLE_V1: Kyverno admission webhook mutates dns_config with ndots=2
|
||||
ignore_changes = [spec[0].template[0].spec[0].dns_config]
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_service" "auth_proxy" {
|
||||
metadata {
|
||||
name = "auth-proxy"
|
||||
namespace = kubernetes_namespace.traefik.metadata[0].name
|
||||
labels = {
|
||||
app = "auth-proxy"
|
||||
}
|
||||
}
|
||||
|
||||
spec {
|
||||
selector = {
|
||||
app = "auth-proxy"
|
||||
}
|
||||
port {
|
||||
name = "http"
|
||||
port = 9000
|
||||
target_port = 9000
|
||||
}
|
||||
}
|
||||
}
|
||||
430
stacks/traefik/modules/traefik/middleware.tf
Normal file
430
stacks/traefik/modules/traefik/middleware.tf
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
# 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]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue