From ce4a75d79a6673ee91e183f2cd472fd6e366bb5d Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sun, 10 May 2026 02:25:57 +0000 Subject: [PATCH] x402: deploy payment gateway in front of Anubis on all 9 public sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds modules/kubernetes/x402_instance/ — a small Go reverse proxy (forgejo.viktorbarzin.me/viktor/x402-gateway:ce333419) that selectively issues HTTP 402 Payment Required to declared AI-bot User-Agents and validates X-PAYMENT headers against a Coinbase x402 facilitator. Browsers are forwarded transparently to Anubis (which then handles the JS PoW gate as before). Wired into all nine Anubis-fronted sites: ingress -> x402-X -> anubis-X -> backend While `wallet_address` is empty the gateway runs in DRY_RUN — every request is transparent-proxied, no 402s issued. This lets the pod sit in the request path with zero behavioural impact today; flipping the wallet variable in the per-stack module call activates payment-required mode for AI-bot UAs. Default config: Base mainnet USDC, $0.01/req, x402.org/facilitator, catch-all UA list (ClaudeBot|GPTBot|Bytespider|meta-externalagent| PerplexityBot|GoogleOther|cohere-ai|Diffbot|Amazonbot| Applebot-Extended|FacebookBot|ImagesiftBot|YouBot|anthropic-ai| Claude-Web|petalbot|spawning-ai|scrapy|python-requests). Verified post-apply: 9/9 pods Running, all 9 sites still serve the Anubis challenge to plain curl with identical TTFB, x402 logs confirm "dry_run":true on every instance. --- modules/kubernetes/x402_instance/main.tf | 297 +++++++++++++++++++++++ stacks/blog/main.tf | 16 +- stacks/cyberchef/main.tf | 11 +- stacks/f1-stream/main.tf | 11 +- stacks/homepage/main.tf | 11 +- stacks/jsoncrack/main.tf | 11 +- stacks/kms/main.tf | 11 +- stacks/privatebin/main.tf | 11 +- stacks/real-estate-crawler/main.tf | 11 +- stacks/travel_blog/main.tf | 11 +- 10 files changed, 381 insertions(+), 20 deletions(-) create mode 100644 modules/kubernetes/x402_instance/main.tf diff --git a/modules/kubernetes/x402_instance/main.tf b/modules/kubernetes/x402_instance/main.tf new file mode 100644 index 00000000..cf60839f --- /dev/null +++ b/modules/kubernetes/x402_instance/main.tf @@ -0,0 +1,297 @@ +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 44bfc78f..4afabf3b 100644 --- a/stacks/blog/main.tf +++ b/stacks/blog/main.tf @@ -122,12 +122,20 @@ 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.anubis.service_name - port = module.anubis.service_port + service_name = module.x402.service_name + port = module.x402.service_port full_host = "viktorbarzin.me" dns_type = "proxied" tls_secret_name = var.tls_secret_name @@ -146,8 +154,8 @@ module "ingress-www" { 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 + service_name = module.x402.service_name + port = module.x402.service_port 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 7cdd49ad..aebc7a7d 100644 --- a/stacks/cyberchef/main.tf +++ b/stacks/cyberchef/main.tf @@ -111,13 +111,20 @@ 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.anubis.service_name - port = module.anubis.service_port + service_name = module.x402.service_name + port = module.x402.service_port 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 072a8bab..3e27dbef 100644 --- a/stacks/f1-stream/main.tf +++ b/stacks/f1-stream/main.tf @@ -268,13 +268,20 @@ 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.anubis.service_name - port = module.anubis.service_port + 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 diff --git a/stacks/homepage/main.tf b/stacks/homepage/main.tf index 8a6bf959..8494cbf0 100644 --- a/stacks/homepage/main.tf +++ b/stacks/homepage/main.tf @@ -144,14 +144,21 @@ 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.anubis.service_name - port = module.anubis.service_port + service_name = module.x402.service_name + port = module.x402.service_port 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 d3c26b91..6bdc7087 100644 --- a/stacks/jsoncrack/main.tf +++ b/stacks/jsoncrack/main.tf @@ -91,13 +91,20 @@ 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.anubis.service_name - port = module.anubis.service_port + service_name = module.x402.service_name + port = module.x402.service_port 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 088bf441..b8302e4f 100644 --- a/stacks/kms/main.tf +++ b/stacks/kms/main.tf @@ -110,13 +110,20 @@ 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.anubis.service_name - port = module.anubis.service_port + service_name = module.x402.service_name + port = module.x402.service_port 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 d7358964..9145693e 100644 --- a/stacks/privatebin/main.tf +++ b/stacks/privatebin/main.tf @@ -138,14 +138,21 @@ 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.anubis.service_name - port = module.anubis.service_port + service_name = module.x402.service_name + port = module.x402.service_port 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 2dc037c7..678fc44c 100644 --- a/stacks/real-estate-crawler/main.tf +++ b/stacks/real-estate-crawler/main.tf @@ -339,13 +339,20 @@ 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.anubis.service_name - port = module.anubis.service_port + service_name = module.x402.service_name + port = module.x402.service_port anti_ai_scraping = false tls_secret_name = var.tls_secret_name extra_annotations = { diff --git a/stacks/travel_blog/main.tf b/stacks/travel_blog/main.tf index 3b53b0ee..a66d5c15 100644 --- a/stacks/travel_blog/main.tf +++ b/stacks/travel_blog/main.tf @@ -109,13 +109,20 @@ 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.anubis.service_name - port = module.anubis.service_port + service_name = module.x402.service_name + port = module.x402.service_port anti_ai_scraping = false extra_annotations = { "gethomepage.dev/enabled" = "true"