diff --git a/pilot/README-dashboard.md b/pilot/README-dashboard.md index 1048688..1018b14 100644 --- a/pilot/README-dashboard.md +++ b/pilot/README-dashboard.md @@ -4,19 +4,24 @@ A read-only overview + per-repo / per-PR drilldown over the same SQLite feedback DB the webhook writes, plus a small form to mutate `.pr-review.json` on a covered repo via the Gitea contents API. Companion to the [webhook service](README-webhook.md); reuses the webhook image -(`pragent-webhook:optin`) — the pilot modules are baked into `/app/pilot/`, +(`pragent-webhook:dashboard`) — the pilot modules are baked into `/app/pilot/`, and the dashboard is just `python3 -m pilot.dashboard`. ## Architecture ``` -Browser (tailnet) +Browser │ ▼ -NodePort :31540 on 100.74.17.70 (kubernets) - │ +Caddy (TLS, wildcard cert via Cloudflare DNS-01) + │ https://pragent-dashboard.marcospaulo.dev.br → 100.74.17.70:31541 ▼ -Service pragent-dashboard.pragent.svc.cluster.local (NodePort, ns pragent) +Service oauth2-proxy-dashboard.pragent.svc.cluster.local (NodePort 31541, ns pragent) + │ + │ oauth2-proxy fronts the dashboard, enforces Logto SSO + email allowlist + │ sets X-Forwarded-User / X-Forwarded-Email on accepted requests + ▼ +Service pragent-dashboard.pragent.svc.cluster.local (ClusterIP, ns pragent) │ ▼ pragent-dashboard pod (uid 10001, /data RO, no subprocess fan-out) @@ -31,6 +36,10 @@ Fail-soft. Nothing is ever written to local disk by the dashboard — the SQLite file is read-only and `.pr-review.json` mutations go through Gitea's contents API so the commit history records who changed what. +The dashboard `Service` is **ClusterIP** — only oauth2-proxy can reach it. +Public access is gated by Caddy (TLS termination) → oauth2-proxy (Logto SSO ++ allowlist) → dashboard. + ## What it does - **Overview** (`GET /`): summary stats across all onboarded repos — total @@ -50,9 +59,6 @@ contents API so the commit history records who changed what. API). This is how repo-local `focus` / `instructions` / `reviewers` / `severity_threshold` get tuned per-repo after seeing the feedback roll-up. -- **Auth** (`GET /login`, `POST /login`): single-user cookie - `pragent_dash=` when `PRAGENT_DASHBOARD_TOKEN` is set in the - Secret. Unset = no auth (tailnet-only mode; logged on startup). All routes return HTML (or plain text for `/raw`) with the same stylesheet (`/static/style.css`). @@ -62,14 +68,16 @@ All routes return HTML (or plain text for `/raw`) with the same stylesheet | method | path | auth | description | |--------|-----------------------------------|------|----------------------------------------------| | GET | `/` | yes | Overview | -| GET | `/login` | no | Login form | -| POST | `/login` | no | Sets the `pragent_dash` cookie | | GET | `/static/style.css` | no | Stylesheet | | GET | `/r//` | yes | Repo drilldown | | GET | `/r///` | yes | PR drilldown | | GET | `/r////raw` | yes | Most recent review body as markdown | | POST | `/r///edit` | yes | Edit `.pr-review.json` on the default branch | +Auth is enforced by oauth2-proxy upstream; the dashboard itself only +checks the `X-Forwarded-User` header that oauth2-proxy sets after a +successful Logto login + email allowlist match. + There is no `/health` route — don't add one to the k8s probes without updating `pilot/dashboard.py` (the handler returns 404 on unknown paths, so a probe would loop forever). @@ -91,51 +99,66 @@ volumeMount in `~/k8s/pragent-dashboard.yaml`): the dashboard never writes the SQLite file, only the webhook + the daily cronjob do, and keeping it RO means a buggy deploy can't corrupt the harvested feedback. -## Auth (single-user cookie) +## Auth (Logto SSO via oauth2-proxy) -`PRAGENT_DASHBOARD_TOKEN` in the `pragent-webhook` Secret is the value -the dashboard expects on the `pragent_dash` cookie. The login form -accepts it on POST / login and sets the cookie. Empty / unset env var -disables auth (logged on startup: `auth OFF (tailnet-only mode)`). +Authentication is delegated to oauth2-proxy, which fronts the dashboard +in-cluster. The dashboard never sees a cookie or a token — it only +inspects `X-Forwarded-User` (set by oauth2-proxy after a successful +Logto login + email allowlist match). Missing header → 401 with +`WWW-Authenticate: Basic realm="pragent-dashboard"`, which lets +oauth2-proxy intercept and bounce the browser to Logto. -Generate a token once: +Email allowlist lives in the ConfigMap `oauth2-proxy-dashboard-emails` +in namespace `pragent`: -```bash -openssl rand -hex 16 +```yaml +data: + authenticated-emails: | + marcos.paulodasilva.mp@gmail.com + thiago@marcospaulo.dev.br ``` -Add to the Secret (the dashboard reads it as `PRAGENT_DASHBOARD_TOKEN`): +Edit the ConfigMap to add/remove users; oauth2-proxy hot-reloads the +file (it logs `watching ... for updates`), no restart needed. This is +the same isolation pattern as the minecraft-sso / code-server +allowlists — see `~/.claude/memory/minecraft-sso.md`. -```bash -# base64 the token first -printf '' | base64 -# then JSON-patch the Secret -microk8s kubectl patch secret pragent-webhook -n pragent --type=json \ - -p='[{"op":"add","path":"/data/PRAGENT_DASHBOARD_TOKEN","value":""}]' -``` +The Logto app is `pragent-dashboard` (tenant `default`, type +`Traditional`), created by direct INSERT into Logto Postgres mirroring +the proven `minecraft-sso` pattern. Credentials live in +`~/k8s/oauth2-proxy-dashboard-secret.yaml` (mode 600, NOT in git). -The token also lives in `~/.config/pragent/dashboard-token` (mode 600) so -you can paste it into the login form without re-reading the Secret. +Public URL: **https://pragent-dashboard.marcospaulo.dev.br** (Caddy +TLS termination via wildcard cert → Tailscale → NodePort 31541 → +oauth2-proxy → dashboard ClusterIP). + +### Emergency bypass (cookie) + +If Logto goes down and you need to access the dashboard before the +oauth2-proxy restart dance (see `~/.claude/memory/logto-fix.md`), +`pilot/dashboard.py` can be patched to accept a fallback cookie by +re-adding the `PRAGENT_DASHBOARD_TOKEN` env path — the route gate is +isolated in `_is_authed` and the logic is straightforward. The current +commit intentionally has no bypass because Logto SSO is the single +source of truth for "who can touch `.pr-review.json`". ## Deploy -The dashboard shares the webhook image, so there's nothing to rebuild. -After editing `pilot/dashboard.py` or `pilot/dashboard_data.py`, redo the -webhook image rebuild + containerd import (see `README-webhook.md` § "K8s -deployment") and roll both deployments. +The dashboard shares the webhook image, so there's nothing to rebuild +beyond what the webhook already does. After editing `pilot/dashboard.py` +or `pilot/dashboard_data.py`, redo the webhook image rebuild + containerd +import (see `README-webhook.md` § "K8s deployment") and roll both +deployments. ```bash K="microk8s kubectl" -# 1. (one-time) generate + persist the auth token -TOKEN=$(openssl rand -hex 16) -printf "%s" "$TOKEN" > ~/.config/pragent/dashboard-token -chmod 600 ~/.config/pragent/dashboard-token -printf "%s" "$TOKEN" | base64 \ - | xargs -I{} $K -n pragent patch secret pragent-webhook --type=json \ - -p='[{"op":"add","path":"/data/PRAGENT_DASHBOARD_TOKEN","value":"{}"}]' +# 1. (one-time) create the Logto app + cookie secret + oauth2-proxy +# See ~/.claude/memory/minecraft-sso.md for the SQL INSERT recipe +# and ~/k8s/oauth2-proxy-dashboard*.yaml for the manifests. -# 2. apply the manifest +# 2. apply all pragent-dashboard manifests (dashboard + oauth2-proxy) +$K apply -f ~/k8s/oauth2-proxy-dashboard.yaml $K apply -f ~/k8s/pragent-dashboard.yaml # 3. roll on image / code changes @@ -144,86 +167,86 @@ $K -n pragent rollout status deploy/pragent-dashboard --timeout=120s $K -n pragent logs -f deploy/pragent-dashboard ``` -K8s manifest at `~/k8s/pragent-dashboard.yaml`. Key fields: +K8s manifests: -- `image: pragent-webhook:optin` + `imagePullPolicy: Never` — local - containerd only, same image as the webhook. -- `nodeSelector: kubernetes.io/hostname: kubernets` — pinned to the node - holding the `/data` PVC. -- `securityContext: runAsNonRoot: true, runAsUser: 10001, runAsGroup: - 10001, fsGroup: 10001` — matches the image's USER directive; fsGroup - makes the RO hostpath volume readable. -- `volumeMounts.feedback-data.readOnly: true` — dashboard is read-only - over `/data`; mutations go through Gitea, not local fs. -- No `readinessProbe` / `livenessProbe` — the dashboard has no `/health` - route. If you add one to `pilot/dashboard.py`, add a probe here too. -- `resources.requests: {cpu: 100m, memory: 256Mi}` / - `limits: {cpu: 500m, memory: 512Mi}` — read-heavy + tiny writes, no - opencode subprocess fan-out, much smaller than the webhook. -- `Service.type: NodePort`, `nodePort: 31540` — between pages-proxy - (30081) and browserless (30100), outside the 30096..30969 media range. +- `~/k8s/pragent-dashboard.yaml` — Deployment + ClusterIP Service. + - `image: pragent-webhook:dashboard` + `imagePullPolicy: Never` — + local containerd only, same image as the webhook. + - `nodeSelector: kubernetes.io/hostname: kubernets` — pinned to the + node holding the `/data` PVC. + - `securityContext: runAsNonRoot: true, runAsUser: 10001, runAsGroup: + 10001, fsGroup: 10001` — matches the image's USER directive; + fsGroup makes the RO hostpath volume readable. + - `volumeMounts.feedback-data.readOnly: true` — dashboard is + read-only over `/data`; mutations go through Gitea, not local fs. + - No `readinessProbe` / `livenessProbe` — the dashboard has no + `/health` route. If you add one to `pilot/dashboard.py`, add a + probe here too. + - `resources.requests: {cpu: 100m, memory: 256Mi}` / + `limits: {cpu: 500m, memory: 512Mi}` — read-heavy + tiny writes, + no opencode subprocess fan-out, much smaller than the webhook. + - `Service.type: ClusterIP` — only oauth2-proxy can reach it. + +- `~/k8s/oauth2-proxy-dashboard.yaml` — Deployment + ConfigMap + + NodePort Service (`oauth2-proxy-dashboard`, NodePort 31541, + namespace `pragent`). Same shape as the code-server / + minecraft-sso oauth2-proxy. NodePort 31541 was chosen because + 31540 was the old dashboard NodePort and the 30096..30969 media + range + 30350-30351 (other oauth2-proxy NodePorts) were taken. + +- `~/k8s/oauth2-proxy-dashboard-secret.yaml` — client-id / + client-secret / cookie-secret (mode 600, NOT in git). ## Smoke test ```bash -# 1. port-forward (in-cluster) +# 1. anonymous request → 302 redirect to Logto +curl -I https://pragent-dashboard.marcospaulo.dev.br/ + +# 2. pod logs +microk8s kubectl logs -n pragent -l app=oauth2-proxy-dashboard --tail=50 +microk8s kubectl logs -n pragent -l app=pragent-dashboard --tail=50 + +# 3. in-cluster direct probe (should 401 without X-Forwarded-User) microk8s kubectl port-forward -n pragent svc/pragent-dashboard 8181:80 & sleep 2 -curl -s -o /tmp/dash-overview.html -w "HTTP %{http_code}\n" \ - http://localhost:8181/ -grep -o "[^<]*" /tmp/dash-overview.html -head -50 /tmp/dash-overview.html +curl -I http://localhost:8181/ # expect 401 + WWW-Authenticate: Basic +curl -I -H "X-Forwarded-User: marcos@example.com" http://localhost:8181/ # expect 200 kill %1 - -# 2. NodePort (host-side, Tailscale IP — only reachable on 100.74.17.70 -# or 192.168.1.80; no public DNS yet) -curl -s -o /tmp/dash-nodeport.html -w "HTTP %{http_code}\n" \ - http://100.74.17.70:31540/ -grep -o "[^<]*" /tmp/dash-nodeport.html - -# 3. auth: POST the login cookie value, capture into a jar -curl -sS -c /tmp/dash.jar -X POST \ - -d "token=$(cat ~/.config/pragent/dashboard-token)" \ - http://100.74.17.70:31540/login - -# 4. authenticated overview -curl -sS -b /tmp/dash.jar -o /tmp/dash-auth.html -w "HTTP %{http_code}\n" \ - http://100.74.17.70:31540/ -grep -o "[^<]*" /tmp/dash-auth.html - -# 5. pod logs -microk8s kubectl logs -n pragent -l app=pragent-dashboard --tail=50 ``` -The HTML should contain a `` (whatever the dashboard renders) and -**never** `Traceback` or any Python exception output. A 401 / redirect -to `/login` on the unauthenticated GETs is expected when -`PRAGENT_DASHBOARD_TOKEN` is set. +The HTML returned with a valid `X-Forwarded-User` should contain a +`<title>` (whatever the dashboard renders) and **never** `Traceback` or +any Python exception output. A 401 on the unauthenticated GET is the +expected behaviour — oauth2-proxy catches it and redirects to Logto. ## Threat model / security notes -- **Tailnet-only by default.** NodePort 31540 is exposed on the - Tailscale / LAN interfaces of kubernets (100.74.17.70, 192.168.1.80). - No public DNS, no Caddy route yet — keep it that way until the auth - story is solid. When you do add a public route, terminate TLS at - Caddy (wildcard cert via Cloudflare DNS-01) and rely on the cookie - auth + Logto SSO gating pattern from the - [code-server / minecraft-sso / livecodes setups](../). -- **`PRAGENT_BOT_TOKEN` is Gitea Write scoped** to onboarded repos, so a - successful auth bypass on the dashboard is Gitea repo write access, - not just read. The cookie is the only auth factor — there is no CSRF - token in the current implementation (single-operator trust model). If - the dashboard goes public, add a CSRF token to the edit form - (hidden input + double-submit cookie) before opening it up. +- **Behind Logto SSO.** Anonymous traffic gets 302 → Logto. Allowed + emails (marcos, thiago) reach the dashboard after Logto login; all + others see oauth2-proxy's "not authorized" page. Adding a user is a + one-line ConfigMap edit; oauth2-proxy hot-reloads the allowlist. +- **`PRAGENT_BOT_TOKEN` is Gitea Write scoped** to onboarded repos, so + a successful auth bypass on the dashboard is Gitea repo write access, + not just read. oauth2-proxy's email allowlist is the only + authentication factor — there is no second factor. If this becomes a + concern, swap oauth2-proxy for an IdP that supports TOTP/WebAuthn + and the dashboard needs no further changes (it just reads the + forwarded headers). +- **CSRF on the edit form.** Per-process random secret embedded as a + hidden input + double-submit via the `X-Forwarded-User` context. An + attacker would need to (a) steal the user's Logto session cookie + from oauth2-proxy and (b) read the rendered HTML to harvest the + CSRF token. Both have to happen in the same browser. - **Read-only `/data` mount.** The dashboard can't corrupt the harvested SQLite file even if it's compromised. The webhook and the daily cronjob are the only writers. +- **ClusterIP dashboard Service.** Even if a malicious actor discovered + the dashboard's container port, they cannot reach it from outside the + cluster — only oauth2-proxy can. NetworkPolicy is the cluster + default deny. - **`uid 10001` + `runAsNonRoot: true`.** No host-level escalation if - the dashboard is popped — it has no caps, no `/proc` mounts, no - NetworkPolicy exemption (cluster default deny applies until you - grant egress to `gitea-http.gitea.svc.cluster.local:3000` and - `100.74.17.70:8787` if you ever need the headroom proxy — currently - the dashboard doesn't). + the dashboard is popped — it has no caps, no `/proc` mounts. - **No author-controlled input is `eval`-ed.** The edit form parses the JSON, validates types / lengths, and re-serialises before the Gitea PUT. The review-side hostile-input concerns from `README-webhook.md` @@ -232,12 +255,10 @@ to `/login` on the unauthenticated GETs is expected when ## Known limitations (pilot) -- Single-user cookie auth (no per-user sessions, no Logto SSO yet). -- No CSRF protection on the edit form (intentional — single-operator - trust model; add before going public). -- Read-only `/data` means the dashboard can't backfill the DB if the - cronjob is paused; if you turn off feedback harvesting - (`PRAGENT_FEEDBACK_DB=` empty in the webhook), the overview is empty. +- Logto SSO is the only auth factor — no per-user sessions, no CSRF + token tied to a per-user identity (the per-process CSRF secret is + global). Adequate for a single-operator dashboard; not adequate for + multi-tenant. - No `/health` route — if the dashboard process wedges on a Gitea hang, k8s won't restart it. Add a `/health` route to `pilot/dashboard.py` + a probe here before relying on this in production. @@ -245,4 +266,4 @@ to `/login` on the unauthenticated GETs is expected when daily cronjob also writes. A long Gitea hang during a page render can stall the dashboard until the client request times out (30 s). The underlying SQLite reader is read-only and concurrent-safe, so no - data corruption — just a slow page. \ No newline at end of file + data corruption — just a slow page.