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/<uuid>
   ?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.
This commit is contained in:
Joakim Persson
2026-08-13 16:21:32 +02:00
parent 08e344b047
commit 4cb70ce3e1
+41 -4
View File
@@ -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/<uuid>?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 <token>`, `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.