diff --git a/.claude/reference/service-catalog.md b/.claude/reference/service-catalog.md index d94c86d8..4200575a 100644 --- a/.claude/reference/service-catalog.md +++ b/.claude/reference/service-catalog.md @@ -53,6 +53,7 @@ | insta2spotify | Instagram reel song ID to Spotify playlist | insta2spotify | | trading-bot | Event-driven trading with sentiment analysis | trading-bot | | claude-memory | Persistent memory MCP server | claude-memory | +| paperless-mcp | Paperless-ngx document search MCP (barryw/PaperlessMCP). Traefik bearer auth via Aetherinox api-token-middleware. `auth=none` at ingress; gateway-level bearer enforced by `paperless-mcp/bearer-auth` Middleware CRD. Tokens + paperless API token in Vault `secret/paperless-mcp`. | paperless-mcp | | council-complaints | Islington civic reporting pilot | council-complaints | ## Optional diff --git a/stacks/paperless-mcp/main.tf b/stacks/paperless-mcp/main.tf new file mode 100644 index 00000000..145dbb09 --- /dev/null +++ b/stacks/paperless-mcp/main.tf @@ -0,0 +1,225 @@ +variable "tls_secret_name" { + type = string + sensitive = true +} + +# Vault read: bearer_tokens (JSON array, used directly in the Middleware CRD) +# and paperless_api_token (synced to a K8s Secret by ESO and consumed by the +# pod as an env var). +data "vault_kv_secret_v2" "secrets" { + mount = "secret" + name = "paperless-mcp" +} + +resource "kubernetes_namespace" "paperless-mcp" { + metadata { + name = "paperless-mcp" + labels = { + tier = local.tiers.aux + "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"]] + } +} + +# Paperless API token (MCP -> paperless). Synced from Vault to a K8s Secret +# by ESO; the pod reads it via secret_key_ref. +resource "kubernetes_manifest" "external_secret" { + manifest = { + apiVersion = "external-secrets.io/v1beta1" + kind = "ExternalSecret" + metadata = { + name = "paperless-mcp-secrets" + namespace = "paperless-mcp" + } + spec = { + refreshInterval = "15m" + secretStoreRef = { + name = "vault-kv" + kind = "ClusterSecretStore" + } + target = { + name = "paperless-mcp-secrets" + } + data = [{ + secretKey = "paperless_api_token" + remoteRef = { + key = "paperless-mcp" + property = "paperless_api_token" + } + }] + } + } + depends_on = [kubernetes_namespace.paperless-mcp] +} + +module "tls_secret" { + source = "../../modules/kubernetes/setup_tls_secret" + namespace = kubernetes_namespace.paperless-mcp.metadata[0].name + tls_secret_name = var.tls_secret_name +} + +# Gateway-level bearer auth. barryw/PaperlessMCP has no native auth; this +# Middleware enforces Authorization: Bearer at Traefik before any +# request reaches the pod. Token list lives in Vault as a JSON array string; +# rotation = update Vault then re-apply this stack. +resource "kubernetes_manifest" "bearer_middleware" { + manifest = { + apiVersion = "traefik.io/v1alpha1" + kind = "Middleware" + metadata = { + name = "bearer-auth" + namespace = kubernetes_namespace.paperless-mcp.metadata[0].name + } + spec = { + plugin = { + # Inner key must match the static-config key in Traefik + # experimental.plugins.api-token-middleware. + api-token-middleware = { + authenticationHeader = false + bearerHeader = true + bearerHeaderName = "Authorization" + tokens = jsondecode(data.vault_kv_secret_v2.secrets.data["bearer_tokens"]) + removeHeadersOnSuccess = true + authenticationErrorMsg = "Access Denied" + } + } + } + } +} + +resource "kubernetes_deployment" "paperless-mcp" { + metadata { + name = "paperless-mcp" + namespace = kubernetes_namespace.paperless-mcp.metadata[0].name + labels = { + app = "paperless-mcp" + tier = local.tiers.aux + } + annotations = { + "reloader.stakater.com/auto" = "true" + "keel.sh/policy" = "minor" + "keel.sh/trigger" = "poll" + "keel.sh/pollSchedule" = "@every 1h" + } + } + spec { + replicas = 1 + selector { + match_labels = { + app = "paperless-mcp" + } + } + template { + metadata { + labels = { + app = "paperless-mcp" + } + } + spec { + container { + name = "paperless-mcp" + image = "ghcr.io/barryw/paperlessmcp:v0.1.19" + port { + container_port = 5000 + } + env { + name = "PAPERLESS_BASE_URL" + value = "http://paperless-ngx.paperless-ngx.svc.cluster.local" + } + env { + name = "PAPERLESS_API_TOKEN" + value_from { + secret_key_ref { + name = "paperless-mcp-secrets" + key = "paperless_api_token" + } + } + } + env { + name = "MCP_PORT" + value = "5000" + } + # barryw exposes no HTTP /health; the ping/capabilities probes are + # MCP JSON-RPC over /mcp. TCP-socket probe is what the upstream + # k8s/deployment.yaml uses. + startup_probe { + tcp_socket { + port = 5000 + } + failure_threshold = 30 + period_seconds = 2 + } + liveness_probe { + tcp_socket { + port = 5000 + } + initial_delay_seconds = 5 + period_seconds = 30 + } + readiness_probe { + tcp_socket { + port = 5000 + } + initial_delay_seconds = 3 + period_seconds = 10 + } + resources { + requests = { + memory = "256Mi" + cpu = "10m" + } + limits = { + memory = "256Mi" + } + } + } + } + } + } + lifecycle { + ignore_changes = [ + spec[0].template[0].spec[0].container[0].image, # Keel-managed + spec[0].template[0].spec[0].dns_config, # KYVERNO_LIFECYCLE_V1: Kyverno admission webhook mutates dns_config with ndots=2 + metadata[0].annotations["keel.sh/policy"], + metadata[0].annotations["keel.sh/trigger"], + metadata[0].annotations["keel.sh/pollSchedule"], + ] + } +} + +resource "kubernetes_service" "paperless-mcp" { + metadata { + name = "paperless-mcp" + namespace = kubernetes_namespace.paperless-mcp.metadata[0].name + labels = { + app = "paperless-mcp" + } + } + spec { + selector = { + app = "paperless-mcp" + } + port { + name = "http" + port = 80 + target_port = 5000 + } + } +} + +module "ingress" { + source = "../../modules/kubernetes/ingress_factory" + # auth = "none": barryw/PaperlessMCP has no native auth; the bearer-auth + # Middleware CRD attached below enforces Authorization: Bearer at Traefik. + auth = "none" + dns_type = "proxied" + namespace = kubernetes_namespace.paperless-mcp.metadata[0].name + name = "paperless-mcp" + tls_secret_name = var.tls_secret_name + homepage_enabled = false + extra_middlewares = ["paperless-mcp-bearer-auth@kubernetescrd"] +} diff --git a/stacks/paperless-mcp/secrets b/stacks/paperless-mcp/secrets new file mode 120000 index 00000000..ca54a7cf --- /dev/null +++ b/stacks/paperless-mcp/secrets @@ -0,0 +1 @@ +../../secrets \ No newline at end of file diff --git a/stacks/paperless-mcp/terragrunt.hcl b/stacks/paperless-mcp/terragrunt.hcl new file mode 100644 index 00000000..2bcb6945 --- /dev/null +++ b/stacks/paperless-mcp/terragrunt.hcl @@ -0,0 +1,27 @@ +include "root" { + path = find_in_parent_folders() +} + +dependency "platform" { + config_path = "../platform" + skip_outputs = true +} + +dependency "vault" { + config_path = "../vault" + skip_outputs = true +} + +# Aetherinox bearer middleware must be loaded in Traefik before our +# Middleware CRD can be applied with a non-zero token list. +dependency "traefik" { + config_path = "../traefik" + skip_outputs = true +} + +# We point PAPERLESS_BASE_URL at the in-cluster service to avoid the +# Cloudflare->Traefik hop on every MCP call. +dependency "paperless-ngx" { + config_path = "../paperless-ngx" + skip_outputs = true +} diff --git a/stacks/traefik/modules/traefik/main.tf b/stacks/traefik/modules/traefik/main.tf index c4e9bb0e..5c8c97fd 100644 --- a/stacks/traefik/modules/traefik/main.tf +++ b/stacks/traefik/modules/traefik/main.tf @@ -70,7 +70,10 @@ resource "helm_release" "traefik" { "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\"; ", - "printf '{\"github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin\":\"v1.4.2\"}' ", + "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\"", ])] @@ -176,6 +179,15 @@ resource "helm_release" "traefik" { 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.` on consuming Middleware CRDs. + api-token-middleware = { + moduleName = "github.com/Aetherinox/traefik-api-token-middleware" + version = "v0.1.4" + } } }