x402: consolidate to a single shared forwardAuth gateway

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).
This commit is contained in:
Viktor Barzin 2026-05-10 10:54:38 +00:00
parent 786f0434cb
commit 203a71768d
12 changed files with 269 additions and 419 deletions

View file

@ -1,297 +0,0 @@
terraform {
required_providers {
kubernetes = {
source = "hashicorp/kubernetes"
}
}
}
# Per-site x402 payment gateway. Sits in FRONT of Anubis:
#
# ingress -> x402-<name> Service:8080 -> x402 pod:8923 ->
# anubis-<name> 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-<name>."
}
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-<name> 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."
}