diff --git a/KOHEN.md b/KOHEN.md index c331544c..ecad5d92 100644 --- a/KOHEN.md +++ b/KOHEN.md @@ -53,8 +53,8 @@ You inherit infrastructure that has been provisioned and verified: | Container registry `the-recognition-protocol` | All 4 bikoret images pushed and tagged `:latest`. | | Block Storage CSI driver | Installed. StorageClass `exoscale-sbs` available. | | cert-manager + nginx-ingress | Running. ClusterIssuer `letsencrypt-prod` working. | -| Gitea (persistent storage) | Running. OAuth app + PAT exist for the operator's account. | -| Gitea MCP server | Running at `https://mcp.shorashim.ch`. Connected to claude.ai. | +| Gitea (persistent storage) | Running. Public HTTPS at `https://git.bikoret.ch` (ROOT_URL) since 2026-10-19; git also over `http://185.19.28.170:3000` and SSH :22. The web UI needs the https door (Secure cookie). | +| Gitea MCP server | `https://mcp.bikoret.ch/mcp`, MCP OAuth mode: each user authorizes with their own Gitea account (Gitea OAuth2 app "Claude", PKCE, client ID in the claude.ai connector's Advanced settings). Apply: `bash ~/.local/bin/gitea-public-apply`; check: `gitea-public-verify`. The old `mcp.shorashim.ch` and the shared static token are retired. | | L1 Torah filesystem | In the `bikoret` repo at `l1/torah/`. SHA-256 `9e1abe4e…3c3086` (full hash in `l1/integrity.sha256`; verified at Day 1). | | Image pull secret `shorashim-registry` | **Verify before assuming.** A prior partial setup may have created it. `kubectl get secrets -n shorashim` is the source of truth. | | `shorashim-secrets` (DATABASE_URL) | **Verify before assuming.** Same as above. If present, also verify the role it points at (the architecture requires `shorashim_app`, not `avnadmin`). | diff --git a/deploy/ARCHITECTURE.md b/deploy/ARCHITECTURE.md index effec3e4..3049cf1d 100644 --- a/deploy/ARCHITECTURE.md +++ b/deploy/ARCHITECTURE.md @@ -36,9 +36,12 @@ Kept in `deploy/k8s/`: - `manna-volume.yaml` — PVC for provision file - `deployment.yaml` — the pod with 3 containers + 2 init containers (`shoresh-engine-install`, `migrate`). Four images are still built and pushed; shoresh-engine ships a binary, not a running container. - `service.yaml` — ClusterIP and external LoadBalancer -- `gitea-mcp-deployment.yaml` — separate MCP server for Gitea connector (optional) -- `gitea-mcp-ingress.yaml` — TLS ingress for the MCP server -- `gitea-mcp-secret.yaml` — template for Gitea PAT +- `gitea-mcp-deployment.yaml` — Gitea MCP server for the claude.ai / Claude Code connector, MCP OAuth mode (users act as themselves in Gitea) +- `gitea-mcp-ingress.yaml` — TLS ingress `mcp.bikoret.ch` for the MCP server +- `gitea-mcp-secret.yaml` — template for a Gitea PAT (static-token mode only; kept for rollback) +- `gitea-ingress.yaml` — TLS ingress `git.bikoret.ch` for Gitea itself (namespace temple-council), required by OAuth mode +- `gitea-public-patch.yaml` — patch that sets Gitea's ROOT_URL/DOMAIN/SSH_DOMAIN to `git.bikoret.ch` +- `gitea-public-apply.sh` / `gitea-public-verify.sh` — the operator's apply and read-only check for the four above - `cert-manager-issuer.yaml` — Let's Encrypt ClusterIssuer ## Containers diff --git a/deploy/k8s/gitea-ingress.yaml b/deploy/k8s/gitea-ingress.yaml new file mode 100644 index 00000000..48a3c802 --- /dev/null +++ b/deploy/k8s/gitea-ingress.yaml @@ -0,0 +1,46 @@ +# Ingress for Gitea itself — public HTTPS at git.bikoret.ch +# +# Added 2026-10-19 so the Gitea MCP server can run in MCP OAuth mode: +# claude.ai and Claude Code send the user to Gitea's own OAuth2 +# authorize page, which must be reachable over HTTPS at Gitea's ROOT_URL +# (gitea-public-patch.yaml sets ROOT_URL = https://git.bikoret.ch/). +# +# Gitea lives in namespace temple-council (Service gitea, 80 → 3000). +# The plain-HTTP TCP passthrough on :3000 and SSH on :22 +# (ingress-tcp-services.yaml) stay as they are; this adds a third door. +# +# Prerequisites: +# 1. DNS: git.bikoret.ch → 185.19.28.170 (Infomaniak zone) +# 2. ClusterIssuer letsencrypt-prod +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: gitea + namespace: temple-council + labels: + app.kubernetes.io/name: gitea + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + nginx.ingress.kubernetes.io/ssl-redirect: "true" + # git push of large packs; Gitea enforces its own limits + nginx.ingress.kubernetes.io/proxy-body-size: "0" + nginx.ingress.kubernetes.io/proxy-read-timeout: "600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "600" +spec: + ingressClassName: nginx + tls: + - hosts: + - git.bikoret.ch + secretName: gitea-bikoret-tls + rules: + - host: git.bikoret.ch + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: gitea + port: + number: 80 diff --git a/deploy/k8s/gitea-mcp-deployment.yaml b/deploy/k8s/gitea-mcp-deployment.yaml index 7aa8eace..574d2fde 100644 --- a/deploy/k8s/gitea-mcp-deployment.yaml +++ b/deploy/k8s/gitea-mcp-deployment.yaml @@ -1,13 +1,28 @@ -# Gitea MCP Server — public connector for claude.ai +# Gitea MCP Server — public connector for claude.ai and Claude Code # # Deploys the official Gitea MCP server (gitea.com/gitea/gitea-mcp) -# in HTTP mode, exposing /mcp endpoint for Streamable HTTP transport. +# in HTTP mode with MCP OAuth (-oauth): every client authorizes against +# Gitea with its OWN account (PKCE, no client secret), and the server +# forwards that bearer token to Gitea per request. No shared static +# token is used any more (gitea-mcp-secrets is kept only for a rollback +# to the pre-2026-10-19 static-token mode). # -# The MCP server connects to the Gitea backend over the cluster network -# (gitea.temple-council.svc.cluster.local) using a Personal Access Token -# stored in gitea-mcp-secrets. Until 2026-10-06 it went out to Gitea's -# own NLB (185.19.29.94) and back in; that NLB is gone — Gitea is behind -# the shared ingress now (ingress-tcp-services.yaml). +# The server serves /.well-known/oauth-protected-resource naming Gitea +# as the authorization server, so BOTH origins must be public HTTPS: +# -oauth-public-url https://mcp.bikoret.ch (gitea-mcp-ingress.yaml) +# -H https://git.bikoret.ch (gitea-ingress.yaml; +# Gitea ROOT_URL must be the same, gitea-public-patch.yaml) +# /mcp accepts only the Host from -oauth-public-url, and reverse proxies +# must pass Mcp-Protocol-Version, Mcp-Method and Mcp-Name through +# (ingress-nginx does). +# +# One-time in Gitea (Settings → Applications → Manage OAuth2 Applications): +# name Claude +# redirect URIs https://claude.ai/api/mcp/auth_callback +# http://127.0.0.1/callback (Claude Code; any port) +# confidential UNCHECKED (PKCE) +# The client ID goes into the claude.ai connector's Advanced settings, +# with the connector URL https://mcp.bikoret.ch/mcp. --- apiVersion: apps/v1 kind: Deployment @@ -33,18 +48,13 @@ spec: args: - "-t" - "http" + - "-oauth" + - "-oauth-public-url" + - "https://mcp.bikoret.ch" - "--host" - - "http://gitea.temple-council.svc.cluster.local" + - "https://git.bikoret.ch" - "--port" - "8080" - env: - - name: GITEA_ACCESS_TOKEN - valueFrom: - secretKeyRef: - name: gitea-mcp-secrets - key: GITEA_TOKEN - - name: GITEA_HOST - value: "http://gitea.temple-council.svc.cluster.local" ports: - containerPort: 8080 name: http @@ -56,12 +66,14 @@ spec: memory: "256Mi" cpu: "250m" livenessProbe: - tcpSocket: + httpGet: + path: /healthz port: 8080 initialDelaySeconds: 10 periodSeconds: 30 readinessProbe: - tcpSocket: + httpGet: + path: /healthz port: 8080 initialDelaySeconds: 5 periodSeconds: 10 diff --git a/deploy/k8s/gitea-mcp-ingress.yaml b/deploy/k8s/gitea-mcp-ingress.yaml index 10987125..283a043e 100644 --- a/deploy/k8s/gitea-mcp-ingress.yaml +++ b/deploy/k8s/gitea-mcp-ingress.yaml @@ -1,12 +1,14 @@ # Ingress for Gitea MCP Server — public HTTPS endpoint -# Routes mcp.shorashim.ch to the gitea-mcp service +# Routes mcp.bikoret.ch to the gitea-mcp service (moved from +# mcp.bikoret.ch on 2026-10-19 with the switch to MCP OAuth; the +# server only answers /mcp for the host in -oauth-public-url). # TLS via cert-manager (Let's Encrypt) # # Prerequisites: # 1. nginx-ingress controller installed # 2. cert-manager installed # 3. ClusterIssuer letsencrypt-prod applied -# 4. DNS: mcp.shorashim.ch → Ingress external IP +# 4. DNS: mcp.bikoret.ch → 185.19.28.170 (the one NLB; Infomaniak zone) --- apiVersion: networking.k8s.io/v1 kind: Ingress @@ -22,10 +24,10 @@ spec: ingressClassName: nginx tls: - hosts: - - mcp.shorashim.ch - secretName: gitea-mcp-tls + - mcp.bikoret.ch + secretName: gitea-mcp-bikoret-tls rules: - - host: mcp.shorashim.ch + - host: mcp.bikoret.ch http: paths: - path: / diff --git a/deploy/k8s/gitea-mcp-secret.yaml b/deploy/k8s/gitea-mcp-secret.yaml index 0f5d3876..a47a2c12 100644 --- a/deploy/k8s/gitea-mcp-secret.yaml +++ b/deploy/k8s/gitea-mcp-secret.yaml @@ -1,15 +1,21 @@ # Gitea MCP Server — secrets template # NEVER commit actual values. Fill in and apply manually. # -# Two secrets needed: +# Since 2026-10-19 the MCP server runs in OAuth mode and uses NO static +# token: users authorize with their own Gitea account. This secret is +# kept only for a rollback to static-token mode (see the deployment). +# +# Two secrets needed (static-token mode): # 1. GITEA_TOKEN: Personal Access Token from Gitea # Create at: http://185.19.28.170:3000/user/settings/applications # Permissions: repo (read/write) # # 2. OAuth Client ID/Secret: Registered OAuth app in Gitea # Create at: http://185.19.28.170:3000/user/settings/applications -# Redirect URI: https://claude.ai/api/mcp/auth_callback -# (These go into claude.ai connector settings, not this secret) +# Redirect URIs: https://claude.ai/api/mcp/auth_callback +# http://127.0.0.1/callback (Claude Code) +# Confidential Client: UNCHECKED (PKCE). Only the Client ID is needed; +# it goes into the claude.ai connector's Advanced settings, not here. --- apiVersion: v1 kind: Secret diff --git a/deploy/k8s/gitea-public-apply.sh b/deploy/k8s/gitea-public-apply.sh new file mode 100755 index 00000000..394b6b20 --- /dev/null +++ b/deploy/k8s/gitea-public-apply.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Move Gitea and its MCP server to public HTTPS on bikoret.ch and switch +# the MCP server to MCP OAuth (2026-10-19). Run by the transmitter: +# ! bash ~/.local/bin/gitea-public-apply +# Idempotent. Needs DNS first: git.bikoret.ch and mcp.bikoret.ch → 185.19.28.170. +# +# Rollback (static-token mode on mcp.shorashim.ch, as before 2026-10-19): +# kubectl -n temple-council set env deploy/gitea \ +# GITEA__server__ROOT_URL=http://gitea.temple-council.ch/ \ +# GITEA__server__DOMAIN=gitea.temple-council.ch GITEA__server__SSH_DOMAIN- +# then restore deploy/k8s/gitea-mcp-*.yaml from commit 861cd4e6 and apply them. +set -euo pipefail +REPO="${REPO:-$HOME/bikoret}" +cd "$REPO" +NLB=185.19.28.170 +for h in git.bikoret.ch mcp.bikoret.ch; do + ip=$(dig +short "$h" | tail -1 || true) + if [ "$ip" != "$NLB" ]; then + echo "DNS: $h → '${ip:-nothing}' (need $NLB). Add the A record at Infomaniak first." >&2 + exit 2 + fi +done +echo "== 1/4 ingresses (certificates issue once DNS resolves)" +kubectl apply -f deploy/k8s/gitea-ingress.yaml +kubectl apply -f deploy/k8s/gitea-mcp-ingress.yaml +echo "== 2/4 Gitea ROOT_URL → https://git.bikoret.ch/ (one restart)" +kubectl -n temple-council patch deploy gitea --patch-file deploy/k8s/gitea-public-patch.yaml +kubectl -n temple-council rollout status deploy/gitea --timeout=180s +echo "== 3/4 gitea-mcp in OAuth mode at https://mcp.bikoret.ch" +kubectl apply -f deploy/k8s/gitea-mcp-deployment.yaml +kubectl -n shorashim rollout status deploy/gitea-mcp --timeout=120s +echo "== 4/4 certificates" +for i in $(seq 1 24); do + ready=$(kubectl get certificate -A -o jsonpath='{range .items[?(@.metadata.name=="gitea-bikoret-tls")]}{.status.conditions[?(@.type=="Ready")].status}{end}{range .items[?(@.metadata.name=="gitea-mcp-bikoret-tls")]}{.status.conditions[?(@.type=="Ready")].status}{end}' 2>/dev/null || true) + [ "$ready" = "TrueTrue" ] && break + sleep 5 +done +kubectl get certificate -A | grep -E "NAME|bikoret" +echo "== verify" +bash "$REPO/deploy/k8s/gitea-public-verify.sh" diff --git a/deploy/k8s/gitea-public-patch.yaml b/deploy/k8s/gitea-public-patch.yaml new file mode 100644 index 00000000..e39d478a --- /dev/null +++ b/deploy/k8s/gitea-public-patch.yaml @@ -0,0 +1,28 @@ +# Strategic-merge patch for the Gitea Deployment (namespace temple-council, +# which is NOT otherwise managed from this repo): make Gitea's own URL the +# public HTTPS host, so its OAuth2 authorize/token endpoints and the +# issuer in /.well-known/openid-configuration are reachable by claude.ai. +# +# kubectl -n temple-council patch deploy gitea --patch-file deploy/k8s/gitea-public-patch.yaml +# +# The Gitea image rewrites app.ini from GITEA__section__KEY at start, so +# this replaces ROOT_URL/DOMAIN (were http://gitea.temple-council.ch, a +# name that never resolved) and SSH_DOMAIN (was 185.19.29.94, an NLB IP +# released on 2026-10-06). Clone URLs shown by Gitea become +# https://git.bikoret.ch/... and git@git.bikoret.ch:...; the old +# http://185.19.28.170:3000 remote keeps working for push/pull. +# Side effect: with an https ROOT_URL Gitea marks its session cookie +# Secure, so the WEB UI must be used at https://git.bikoret.ch, not on +# the plain-HTTP :3000 door (git over :3000 is unaffected). +spec: + template: + spec: + containers: + - name: gitea + env: + - name: GITEA__server__ROOT_URL + value: "https://git.bikoret.ch/" + - name: GITEA__server__DOMAIN + value: "git.bikoret.ch" + - name: GITEA__server__SSH_DOMAIN + value: "git.bikoret.ch" diff --git a/deploy/k8s/gitea-public-verify.sh b/deploy/k8s/gitea-public-verify.sh new file mode 100755 index 00000000..6584d8c5 --- /dev/null +++ b/deploy/k8s/gitea-public-verify.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Read-only checks for the public Gitea + OAuth MCP setup. Exit 0 = all pass. +# bash ~/.local/bin/gitea-public-verify +set -u +fail=0 +chk() { # label expected actual + if [ "$2" = "$3" ]; then echo "ok $1: $3"; else echo "FAIL $1: got '$3', want '$2'"; fail=1; fi +} +INIT='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}' +chk "https://git.bikoret.ch/ status" 200 "$(curl -sS -m 15 -o /dev/null -w '%{http_code}' https://git.bikoret.ch/ || echo err)" +chk "Gitea OIDC issuer" "https://git.bikoret.ch" "$(curl -sS -m 15 https://git.bikoret.ch/.well-known/openid-configuration | python3 -c 'import sys,json;print(json.load(sys.stdin)["issuer"].rstrip("/"))' 2>/dev/null || echo err)" +chk "mcp /healthz" 200 "$(curl -sS -m 15 -o /dev/null -w '%{http_code}' https://mcp.bikoret.ch/healthz || echo err)" +prm=$(curl -sS -m 15 https://mcp.bikoret.ch/.well-known/oauth-protected-resource || echo err) +chk "protected-resource names Gitea" yes "$(printf '%s' "$prm" | grep -q 'git.bikoret.ch' && echo yes || echo no)" +chk "POST /mcp without token → 401" 401 "$(curl -sS -m 15 -o /dev/null -w '%{http_code}' -X POST https://mcp.bikoret.ch/mcp -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -d "$INIT" || echo err)" +chk "git over :3000 still answers" 200 "$(curl -sS -m 15 -o /dev/null -w '%{http_code}' http://185.19.28.170:3000/api/v1/version || echo err)" +echo "protected-resource: $prm" +exit $fail