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:
@@ -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
|
*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.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
|
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."*
|
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
|
⚠️ 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.
|
(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
|
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.)
|
2026-08-12, because this line used to omit the scheme.)
|
||||||
- **Auth: none at the Pangolin layer** (§1.1). TLS termination only.
|
- **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
|
- 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).
|
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
|
```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 \
|
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 \
|
curl -s -X POST https://mempalace.jordbo.se/mcp \
|
||||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | head -c 300 # 36 tools
|
-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
|
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
|
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.
|
remember opencode containers need the §4.1 sidecar merge before their `.env` takes effect.
|
||||||
|
|||||||
Reference in New Issue
Block a user