From 4cb70ce3e1fc0b23c857142e27ba4a7f45f38813 Mon Sep 17 00:00:00 2001 From: Joakim Persson Date: Thu, 13 Aug 2026 16:21:32 +0200 Subject: [PATCH] docs: record the 302 auth-redirect fingerprint and the server's exact HTTP surface Phase 1 exposure now verified end to end (2026-08-12): /healthz -> ok and an unauthenticated POST /mcp -> 401, both from a client container and from the primary itself. 3.4 and 3.6 marked passed. Two findings from the failure in between, worth more than a checkbox: 1. Leaving Pangolin's resource authentication on does NOT surface as 401 or 403. It is a 302 with `location: https://pangolin.jordbo.se/auth/resource/ ?redirect=...`, sent *before* the request is proxied -- so the palace never sees it and its journal stays silent, `curl -s` prints an empty body, and an MCP client gets non-JSON. That is 1.1's "Pangolin's HTTP auth is browser-shaped; the clients are not" arriving as a concrete symptom rather than an argument. Now recorded with the exact header shape and the two curl flags that reveal it (-D-, -w '%{redirect_url}'), plus the reading: a 302 is good news, because DNS, TLS and routing all worked and only auth intervened. 2. Read the server's routing to settle whether path-scoped proxy rules are sufficient. The entire HTTP surface is two exact paths (mcp_server.py:5299- 5318): GET /healthz (no token, Host/Origin gated) and POST /mcp (Bearer, compare_digest on the exact string). Everything else is a 404 from the palace itself. So path-scoped rules are tighter than a host-wide proxy and lose nothing. Two client-facing consequences: /mcp is matched exactly, so a trailing slash 404s -- configure clients without one; and there is no GET /mcp, no SSE, no session id, no DELETE, so this is plain JSON-RPC over POST, not MCP streamable-HTTP. A strict client opening with a GET handshake sees 404. Also means the verify step needs no initialize and no Accept: text/event-stream, which the old snippet left ambiguous. Also: run the 200 and 401 from two different networks, not one -- passing from only the primary leaves split-horizon DNS untested. --- docs/phase-1-exposure-runbook.md | 45 +++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/docs/phase-1-exposure-runbook.md b/docs/phase-1-exposure-runbook.md index 38bcdc2..161d1e0 100644 --- a/docs/phase-1-exposure-runbook.md +++ b/docs/phase-1-exposure-runbook.md @@ -194,10 +194,11 @@ If that hangs or refuses while the Pangolin dashboard shows the site online, the *target* is wrong — look at the resource's upstream address (§3.5), not at newt. Note this check needs §3.1 done first: if `mempalace-serve` is not running yet, it fails for that reason alone. -### 3.4 DNS at the web hotel +### 3.4 DNS at the web hotel — ✅ done 2026-08-12 One CNAME: `mempalace` → **the same target your existing Pangolin resources use** (nyvaken's public hostname). RFC §6.2 costed this as *"one DNS record per service on the web hotel is the whole setup cost."* +Done: `mempalace.jordbo.se` resolves and terminates TLS at Pangolin on nyvaken — the §6.2 estimate held. ⚠️ Not verified from here: nyvaken's public FQDN, and whether your web hotel permits a CNAME at that label (some require an A record, or forbid CNAME where other records exist). Confirm before assuming a 5-minute job. @@ -212,20 +213,56 @@ hostname). RFC §6.2 costed this as *"one DNS record per service on the web hote looks perfectly healthy on `curl 172.17.0.1:8765/healthz`. (Got this wrong on the first attempt 2026-08-12, because this line used to omit the scheme.) - **Auth: none at the Pangolin layer** (§1.1). TLS termination only. +- ⚠️ **If resource auth is left on, the signature is a `302`, not a 401 or 403** — hit for real 2026-08-12: + ``` + HTTP/2 302 + location: https://pangolin.jordbo.se/auth/resource/?redirect=https%3A%2F%2Fmempalace.jordbo.se%2Fhealthz + content-length: 0 + ``` + This is §1.1's "browser-shaped auth" arriving as a concrete symptom: Pangolin sends the login redirect + **before** proxying, so the palace never sees the request and its journal stays silent. `curl -s` shows + an empty body and an MCP client sees non-JSON. Diagnose with `-D-` or + `-w '%{http_code} %{redirect_url}'` — a `location:` pointing at `/auth/resource/…` means the fix is in + the Pangolin UI (switch the site's authentication off), not in the palace, the unit, or newt. + **A 302 is unambiguously good news:** DNS, TLS and routing all worked — only the auth layer intervened. - Do **not** attach an `Origin`-injecting proxy or browser client: a *present* non-loopback `Origin` is a hard 403 with no override (runbook §2.4 B3). -### 3.6 Verify end-to-end before flipping any client +**The server's entire HTTP surface is two exact paths**, so path-scoped rules cover it completely +(`mcp_server.py:5299-5318`, read 2026-08-12): + +| Method + path | Auth | Notes | +| --- | --- | --- | +| `GET /healthz` | none (Host/Origin gated only) | the liveness probe; works with no creds by design | +| `POST /mcp` | `Authorization: Bearer `, `hmac.compare_digest` on the exact string | the whole tool surface | +| anything else | — | `send_error(404)` from the palace itself | + +Three consequences worth having in writing: + +- **Path-scoped Pangolin rules are not a compromise here, they are tighter than a host-wide proxy** and + lose nothing — there is no third endpoint to forget. +- **`/mcp` is matched exactly** (`if path != "/mcp"`), so a client URL with a trailing slash gets a 404 + from the palace. Configure clients as `https://mempalace.jordbo.se/mcp` — no trailing slash. +- **There is no `GET /mcp`, no SSE, no session id, no `DELETE`.** This is plain JSON-RPC over POST, not MCP + streamable-HTTP. A strict client that opens with a `GET` handshake will see 404; pi's extension, + opencode `type:remote` and the feeder all POST directly and are fine. + +### 3.6 Verify end-to-end before flipping any client — ✅ passed 2026-08-12 ```sh -curl -s https://mempalace.jordbo.se/healthz # ok +curl -s https://mempalace.jordbo.se/healthz # ok ✓ from devbox AND synlig curl -s -o /dev/null -w '%{http_code}\n' -X POST \ - https://mempalace.jordbo.se/mcp # 401 — the token is doing its job + https://mempalace.jordbo.se/mcp # 401 ✓ the token is doing its job +TOKEN=$(cat ~/.mempalace/server/f5d849287f6d73f0141b29d7/token) # on synlig curl -s -X POST https://mempalace.jordbo.se/mcp \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | head -c 300 # 36 tools ``` +No `initialize` and no `Accept: text/event-stream` needed — see the surface table above; a bare +`tools/list` POST is a complete request. Run the 200 and the 401 from **two different networks** (a client +box and the primary itself): passing from only one leaves split-horizon DNS untested. + The 401 check matters as much as the 200: it is the only evidence that the thing you just published to the internet is not open. Then, and only then, Phase 1 client flip — **one machine first** (RFC §8), and remember opencode containers need the §4.1 sidecar merge before their `.env` takes effect.