From 203a71768d9df88132f7bbc88e31677f4702d3ff Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sun, 10 May 2026 10:54:38 +0000 Subject: [PATCH] x402: consolidate to a single shared forwardAuth gateway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- modules/kubernetes/x402_instance/main.tf | 297 ------------------- stacks/blog/main.tf | 30 +- stacks/cyberchef/main.tf | 20 +- stacks/f1-stream/main.tf | 26 +- stacks/homepage/main.tf | 22 +- stacks/jsoncrack/main.tf | 20 +- stacks/kms/main.tf | 20 +- stacks/privatebin/main.tf | 12 +- stacks/real-estate-crawler/main.tf | 20 +- stacks/traefik/modules/traefik/main.tf | 176 +++++++++++ stacks/traefik/modules/traefik/middleware.tf | 25 ++ stacks/travel_blog/main.tf | 20 +- 12 files changed, 269 insertions(+), 419 deletions(-) delete mode 100644 modules/kubernetes/x402_instance/main.tf diff --git a/modules/kubernetes/x402_instance/main.tf b/modules/kubernetes/x402_instance/main.tf deleted file mode 100644 index cf60839f..00000000 --- a/modules/kubernetes/x402_instance/main.tf +++ /dev/null @@ -1,297 +0,0 @@ -terraform { - required_providers { - kubernetes = { - source = "hashicorp/kubernetes" - } - } -} - -# Per-site x402 payment gateway. Sits in FRONT of Anubis: -# -# ingress -> x402- Service:8080 -> x402 pod:8923 -> -# anubis- Service:8080 -> anubis pod -> backend -# -# Behaviour: -# - X-PAYMENT header present → validate via Coinbase facilitator, -# forward to Anubis on success, 402 on fail. -# - User-Agent matches AI bot → return 402 with payment requirements. -# - Everything else → forward transparently to Anubis (browsers -# still solve the JS PoW gate as today). -# -# When `wallet_address` is empty, the gateway runs in DRY_RUN mode — every -# request is forwarded transparently. This lets us drop the pod into the -# request path without changing live behaviour while we wait for the -# wallet to be configured. Flip live by setting `wallet_address`. - -variable "name" { - type = string - description = "Short logical name (e.g. \"blog\"). Used to derive Service/Deployment/Secret names as x402-." -} - -variable "namespace" { - type = string - description = "Namespace to deploy into — typically the same as the Anubis instance for the same backend." -} - -variable "target_url" { - type = string - description = "Upstream URL the gateway forwards to. Usually the anubis- Service's cluster DNS." -} - -variable "wallet_address" { - type = string - default = "" - description = "EVM wallet address (0x…) that receives USDC. Empty = DRY_RUN, no 402s issued." -} - -variable "price_label" { - type = string - default = "$0.01" - description = "Human-readable price displayed in payment requirements." -} - -variable "price_usdc_micros" { - type = number - default = 10000 - description = "Price in USDC base units (6 decimals). Default 10_000 = $0.01." -} - -variable "network" { - type = string - default = "base" - description = "x402 network identifier. \"base\", \"base-sepolia\", or any custom paired with USDC_ASSET." -} - -variable "facilitator_url" { - type = string - default = "https://x402.org/facilitator" - description = "Coinbase / community facilitator endpoint that verifies and settles X-PAYMENT headers." -} - -variable "image_tag" { - type = string - default = "ce333419" - description = "forgejo.viktorbarzin.me/viktor/x402-gateway tag. Pin to a release SHA, never :latest." -} - -variable "replicas" { - type = number - default = 1 - description = "Replica count. The gateway is stateless so >1 is fine, but 1 is enough for low-traffic sites." -} - -variable "memory" { - type = string - default = "64Mi" - description = "requests==limits memory. The Go binary idles at ~10MiB." -} - -variable "cpu_request" { - type = string - default = "10m" - description = "CPU request. Per-request work is just an HTTP call to the facilitator." -} - -variable "bot_ua_regex" { - type = string - default = "" - description = "Override for the AI-bot User-Agent regex. Empty = use the gateway's default (ClaudeBot|GPTBot|…)." -} - -locals { - full_name = "x402-${var.name}" - labels = { - "app" = local.full_name - "app.kubernetes.io/name" = "x402-gateway" - "app.kubernetes.io/instance" = local.full_name - "app.kubernetes.io/component" = "payment-gateway" - "app.kubernetes.io/managed-by" = "terraform" - } -} - -resource "kubernetes_deployment" "x402" { - metadata { - name = local.full_name - namespace = var.namespace - labels = local.labels - } - - spec { - replicas = var.replicas - - selector { - match_labels = { app = local.full_name } - } - - strategy { - type = "RollingUpdate" - rolling_update { - max_surge = 1 - max_unavailable = 0 - } - } - - template { - metadata { - labels = local.labels - } - - spec { - image_pull_secrets { - name = "registry-credentials" - } - - container { - name = "x402-gateway" - image = "forgejo.viktorbarzin.me/viktor/x402-gateway:${var.image_tag}" - - port { - name = "http" - container_port = 8923 - } - port { - name = "metrics" - container_port = 9090 - } - - env { - name = "BIND" - value = ":8923" - } - env { - name = "METRICS_BIND" - value = ":9090" - } - env { - name = "TARGET" - value = var.target_url - } - env { - name = "WALLET_ADDRESS" - value = var.wallet_address - } - env { - name = "PRICE_LABEL" - value = var.price_label - } - env { - name = "PRICE_USDC_MICROS" - value = tostring(var.price_usdc_micros) - } - env { - name = "NETWORK" - value = var.network - } - env { - name = "FACILITATOR_URL" - value = var.facilitator_url - } - dynamic "env" { - for_each = var.bot_ua_regex == "" ? [] : [1] - content { - name = "BOT_UA_REGEX" - value = var.bot_ua_regex - } - } - - resources { - requests = { - cpu = var.cpu_request - memory = var.memory - } - limits = { - memory = var.memory - } - } - - liveness_probe { - http_get { - path = "/healthz" - port = "metrics" - } - initial_delay_seconds = 5 - period_seconds = 30 - failure_threshold = 3 - } - readiness_probe { - http_get { - path = "/healthz" - port = "metrics" - } - initial_delay_seconds = 1 - period_seconds = 5 - failure_threshold = 2 - } - - 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" { - metadata { - name = local.full_name - namespace = var.namespace - labels = local.labels - annotations = { - "prometheus.io/scrape" = "true" - "prometheus.io/path" = "/metrics" - "prometheus.io/port" = "9090" - } - } - - spec { - selector = { app = local.full_name } - port { - name = "http" - port = 8080 - target_port = 8923 - protocol = "TCP" - } - port { - name = "metrics" - port = 9090 - target_port = 9090 - protocol = "TCP" - } - } -} - -resource "kubernetes_pod_disruption_budget_v1" "x402" { - metadata { - name = local.full_name - namespace = var.namespace - } - spec { - min_available = "1" - selector { - match_labels = { app = local.full_name } - } - } -} - -output "service_name" { - value = kubernetes_service.x402.metadata[0].name - description = "ClusterIP service name. Pass this to ingress_factory `service_name` so Traefik routes through the gateway." -} - -output "service_port" { - value = 8080 - description = "Service port — same as the Anubis service for drop-in replacement in ingress_factory." -} diff --git a/stacks/blog/main.tf b/stacks/blog/main.tf index 4afabf3b..d074441f 100644 --- a/stacks/blog/main.tf +++ b/stacks/blog/main.tf @@ -122,20 +122,13 @@ module "anubis" { target_url = "http://${kubernetes_service.blog.metadata[0].name}.${kubernetes_namespace.website.metadata[0].name}.svc.cluster.local" } -# x402 payment gateway in front of Anubis. DRY_RUN until wallet_address is set. -module "x402" { - source = "../../modules/kubernetes/x402_instance" - name = "blog" - namespace = kubernetes_namespace.website.metadata[0].name - target_url = "http://${module.anubis.service_name}.${kubernetes_namespace.website.metadata[0].name}.svc.cluster.local:${module.anubis.service_port}" -} - module "ingress" { - source = "../../modules/kubernetes/ingress_factory" - namespace = kubernetes_namespace.website.metadata[0].name - name = "blog" - service_name = module.x402.service_name - port = module.x402.service_port + source = "../../modules/kubernetes/ingress_factory" + namespace = kubernetes_namespace.website.metadata[0].name + name = "blog" + service_name = module.anubis.service_name + port = module.anubis.service_port + extra_middlewares = ["traefik-x402@kubernetescrd"] full_host = "viktorbarzin.me" dns_type = "proxied" tls_secret_name = var.tls_secret_name @@ -151,11 +144,12 @@ module "ingress" { } module "ingress-www" { - source = "../../modules/kubernetes/ingress_factory" - namespace = kubernetes_namespace.website.metadata[0].name - name = "blog-www" - service_name = module.x402.service_name - port = module.x402.service_port + source = "../../modules/kubernetes/ingress_factory" + namespace = kubernetes_namespace.website.metadata[0].name + name = "blog-www" + service_name = module.anubis.service_name + port = module.anubis.service_port + extra_middlewares = ["traefik-x402@kubernetescrd"] full_host = "www.viktorbarzin.me" tls_secret_name = var.tls_secret_name anti_ai_scraping = false diff --git a/stacks/cyberchef/main.tf b/stacks/cyberchef/main.tf index aebc7a7d..6b4e71ae 100644 --- a/stacks/cyberchef/main.tf +++ b/stacks/cyberchef/main.tf @@ -111,20 +111,14 @@ module "anubis" { target_url = "http://${kubernetes_service.cyberchef.metadata[0].name}.${kubernetes_namespace.cyberchef.metadata[0].name}.svc.cluster.local" } -module "x402" { - source = "../../modules/kubernetes/x402_instance" - name = "cc" - namespace = kubernetes_namespace.cyberchef.metadata[0].name - target_url = "http://${module.anubis.service_name}.${kubernetes_namespace.cyberchef.metadata[0].name}.svc.cluster.local:${module.anubis.service_port}" -} - module "ingress" { - source = "../../modules/kubernetes/ingress_factory" - dns_type = "proxied" - namespace = kubernetes_namespace.cyberchef.metadata[0].name - name = "cc" - service_name = module.x402.service_name - port = module.x402.service_port + source = "../../modules/kubernetes/ingress_factory" + dns_type = "proxied" + namespace = kubernetes_namespace.cyberchef.metadata[0].name + name = "cc" + service_name = module.anubis.service_name + port = module.anubis.service_port + extra_middlewares = ["traefik-x402@kubernetescrd"] tls_secret_name = var.tls_secret_name anti_ai_scraping = false extra_annotations = { diff --git a/stacks/f1-stream/main.tf b/stacks/f1-stream/main.tf index 3e27dbef..f219bd79 100644 --- a/stacks/f1-stream/main.tf +++ b/stacks/f1-stream/main.tf @@ -268,23 +268,17 @@ module "anubis" { EOT } -module "x402" { - source = "../../modules/kubernetes/x402_instance" - name = "f1" - namespace = kubernetes_namespace.f1-stream.metadata[0].name - target_url = "http://${module.anubis.service_name}.${kubernetes_namespace.f1-stream.metadata[0].name}.svc.cluster.local:${module.anubis.service_port}" -} - module "ingress" { - source = "../../modules/kubernetes/ingress_factory" - dns_type = "non-proxied" - namespace = kubernetes_namespace.f1-stream.metadata[0].name - name = "f1" - service_name = module.x402.service_name - port = module.x402.service_port - tls_secret_name = var.tls_secret_name - exclude_crowdsec = true - anti_ai_scraping = false + source = "../../modules/kubernetes/ingress_factory" + dns_type = "non-proxied" + namespace = kubernetes_namespace.f1-stream.metadata[0].name + name = "f1" + service_name = module.anubis.service_name + port = module.anubis.service_port + tls_secret_name = var.tls_secret_name + exclude_crowdsec = true + anti_ai_scraping = false + extra_middlewares = ["traefik-x402@kubernetescrd"] extra_annotations = { "gethomepage.dev/enabled" = "true" "gethomepage.dev/name" = "F1 Stream" diff --git a/stacks/homepage/main.tf b/stacks/homepage/main.tf index 8494cbf0..d4c84c68 100644 --- a/stacks/homepage/main.tf +++ b/stacks/homepage/main.tf @@ -144,21 +144,15 @@ module "anubis" { target_url = "http://${kubernetes_service.cache_proxy.metadata[0].name}.${kubernetes_namespace.homepage.metadata[0].name}.svc.cluster.local" } -module "x402" { - source = "../../modules/kubernetes/x402_instance" - name = "homepage" - namespace = kubernetes_namespace.homepage.metadata[0].name - target_url = "http://${module.anubis.service_name}.${kubernetes_namespace.homepage.metadata[0].name}.svc.cluster.local:${module.anubis.service_port}" -} - module "ingress" { - source = "../../modules/kubernetes/ingress_factory" - namespace = kubernetes_namespace.homepage.metadata[0].name - name = "homepage" - host = "home" - dns_type = "proxied" - service_name = module.x402.service_name - port = module.x402.service_port + source = "../../modules/kubernetes/ingress_factory" + namespace = kubernetes_namespace.homepage.metadata[0].name + name = "homepage" + host = "home" + dns_type = "proxied" + service_name = module.anubis.service_name + port = module.anubis.service_port + extra_middlewares = ["traefik-x402@kubernetescrd"] tls_secret_name = var.tls_secret_name anti_ai_scraping = false extra_annotations = { diff --git a/stacks/jsoncrack/main.tf b/stacks/jsoncrack/main.tf index 6bdc7087..c53c6bbc 100644 --- a/stacks/jsoncrack/main.tf +++ b/stacks/jsoncrack/main.tf @@ -91,20 +91,14 @@ module "anubis" { target_url = "http://${kubernetes_service.jsoncrack.metadata[0].name}.${kubernetes_namespace.jsoncrack.metadata[0].name}.svc.cluster.local" } -module "x402" { - source = "../../modules/kubernetes/x402_instance" - name = "json" - namespace = kubernetes_namespace.jsoncrack.metadata[0].name - target_url = "http://${module.anubis.service_name}.${kubernetes_namespace.jsoncrack.metadata[0].name}.svc.cluster.local:${module.anubis.service_port}" -} - module "ingress" { - source = "../../modules/kubernetes/ingress_factory" - dns_type = "proxied" - namespace = kubernetes_namespace.jsoncrack.metadata[0].name - name = "json" - service_name = module.x402.service_name - port = module.x402.service_port + source = "../../modules/kubernetes/ingress_factory" + dns_type = "proxied" + namespace = kubernetes_namespace.jsoncrack.metadata[0].name + name = "json" + service_name = module.anubis.service_name + port = module.anubis.service_port + extra_middlewares = ["traefik-x402@kubernetescrd"] tls_secret_name = var.tls_secret_name anti_ai_scraping = false extra_annotations = { diff --git a/stacks/kms/main.tf b/stacks/kms/main.tf index b8302e4f..127568a2 100644 --- a/stacks/kms/main.tf +++ b/stacks/kms/main.tf @@ -110,20 +110,14 @@ module "anubis" { target_url = "http://${kubernetes_service.kms-web-page.metadata[0].name}.${kubernetes_namespace.kms.metadata[0].name}.svc.cluster.local" } -module "x402" { - source = "../../modules/kubernetes/x402_instance" - name = "kms" - namespace = kubernetes_namespace.kms.metadata[0].name - target_url = "http://${module.anubis.service_name}.${kubernetes_namespace.kms.metadata[0].name}.svc.cluster.local:${module.anubis.service_port}" -} - module "ingress" { - source = "../../modules/kubernetes/ingress_factory" - dns_type = "non-proxied" - namespace = kubernetes_namespace.kms.metadata[0].name - name = "kms" - service_name = module.x402.service_name - port = module.x402.service_port + source = "../../modules/kubernetes/ingress_factory" + dns_type = "non-proxied" + namespace = kubernetes_namespace.kms.metadata[0].name + name = "kms" + service_name = module.anubis.service_name + port = module.anubis.service_port + extra_middlewares = ["traefik-x402@kubernetescrd"] tls_secret_name = var.tls_secret_name anti_ai_scraping = false extra_annotations = { diff --git a/stacks/privatebin/main.tf b/stacks/privatebin/main.tf index 9145693e..cd8f7130 100644 --- a/stacks/privatebin/main.tf +++ b/stacks/privatebin/main.tf @@ -138,21 +138,15 @@ module "anubis" { target_url = "http://${kubernetes_service.privatebin.metadata[0].name}.${kubernetes_namespace.privatebin.metadata[0].name}.svc.cluster.local" } -module "x402" { - source = "../../modules/kubernetes/x402_instance" - name = "privatebin" - namespace = kubernetes_namespace.privatebin.metadata[0].name - target_url = "http://${module.anubis.service_name}.${kubernetes_namespace.privatebin.metadata[0].name}.svc.cluster.local:${module.anubis.service_port}" -} - module "ingress" { source = "../../modules/kubernetes/ingress_factory" namespace = kubernetes_namespace.privatebin.metadata[0].name name = "privatebin" host = "pb" dns_type = "proxied" - service_name = module.x402.service_name - port = module.x402.service_port + service_name = module.anubis.service_name + port = module.anubis.service_port + extra_middlewares = ["traefik-x402@kubernetescrd"] anti_ai_scraping = false tls_secret_name = var.tls_secret_name custom_content_security_policy = "script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'" diff --git a/stacks/real-estate-crawler/main.tf b/stacks/real-estate-crawler/main.tf index 678fc44c..b5e0b79a 100644 --- a/stacks/real-estate-crawler/main.tf +++ b/stacks/real-estate-crawler/main.tf @@ -339,20 +339,14 @@ module "anubis" { target_url = "http://realestate-crawler-ui.${kubernetes_namespace.realestate-crawler.metadata[0].name}.svc.cluster.local" } -module "x402" { - source = "../../modules/kubernetes/x402_instance" - name = "wrongmove" - namespace = kubernetes_namespace.realestate-crawler.metadata[0].name - target_url = "http://${module.anubis.service_name}.${kubernetes_namespace.realestate-crawler.metadata[0].name}.svc.cluster.local:${module.anubis.service_port}" -} - module "ingress" { - source = "../../modules/kubernetes/ingress_factory" - dns_type = "proxied" - namespace = kubernetes_namespace.realestate-crawler.metadata[0].name - name = "wrongmove" - service_name = module.x402.service_name - port = module.x402.service_port + source = "../../modules/kubernetes/ingress_factory" + dns_type = "proxied" + namespace = kubernetes_namespace.realestate-crawler.metadata[0].name + name = "wrongmove" + service_name = module.anubis.service_name + port = module.anubis.service_port + extra_middlewares = ["traefik-x402@kubernetescrd"] anti_ai_scraping = false tls_secret_name = var.tls_secret_name extra_annotations = { diff --git a/stacks/traefik/modules/traefik/main.tf b/stacks/traefik/modules/traefik/main.tf index 4edea94b..8ee65407 100644 --- a/stacks/traefik/modules/traefik/main.tf +++ b/stacks/traefik/modules/traefik/main.tf @@ -10,6 +10,11 @@ variable "auth_fallback_htpasswd" { 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." +} resource "kubernetes_namespace" "traefik" { metadata { @@ -459,6 +464,177 @@ resource "kubernetes_service" "bot_block_proxy" { } } +# 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:f4804d62" + 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" + } + 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" { diff --git a/stacks/traefik/modules/traefik/middleware.tf b/stacks/traefik/modules/traefik/middleware.tf index 2c8ae8c4..632b3639 100644 --- a/stacks/traefik/modules/traefik/middleware.tf +++ b/stacks/traefik/modules/traefik/middleware.tf @@ -322,6 +322,31 @@ resource "kubernetes_manifest" "middleware_ai_bot_block" { 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 = { diff --git a/stacks/travel_blog/main.tf b/stacks/travel_blog/main.tf index a66d5c15..01fbbb15 100644 --- a/stacks/travel_blog/main.tf +++ b/stacks/travel_blog/main.tf @@ -109,20 +109,14 @@ module "anubis" { target_url = "http://${kubernetes_service.travel-blog.metadata[0].name}.${kubernetes_namespace.travel-blog.metadata[0].name}.svc.cluster.local" } -module "x402" { - source = "../../modules/kubernetes/x402_instance" - name = "travel" - namespace = kubernetes_namespace.travel-blog.metadata[0].name - target_url = "http://${module.anubis.service_name}.${kubernetes_namespace.travel-blog.metadata[0].name}.svc.cluster.local:${module.anubis.service_port}" -} - module "ingress" { - source = "../../modules/kubernetes/ingress_factory" - namespace = kubernetes_namespace.travel-blog.metadata[0].name - name = "travel" - tls_secret_name = var.tls_secret_name - service_name = module.x402.service_name - port = module.x402.service_port + source = "../../modules/kubernetes/ingress_factory" + namespace = kubernetes_namespace.travel-blog.metadata[0].name + name = "travel" + tls_secret_name = var.tls_secret_name + service_name = module.anubis.service_name + port = module.anubis.service_port + extra_middlewares = ["traefik-x402@kubernetescrd"] anti_ai_scraping = false extra_annotations = { "gethomepage.dev/enabled" = "true"