docs(deploy): Caddy front-end example + walkthrough (forgejo-mcp-broker-r2c)
Adds deploy/caddy/Caddyfile and docs/deploy-caddy.md, the front-end
half of the production deployment that pairs with deploy-podman.md.
Caddyfile:
- reverse_proxy with flush_interval -1 (mandatory for /mcp SSE)
- structured JSON access log to a separate file
- validated with `caddy validate` and formatted with `caddy fmt`
- omits explicit X-Forwarded-{For,Proto,Host} since Caddy forwards
them by default (caddy validate flags them as redundant)
deploy-caddy.md walks operators through:
- why a reverse proxy at all (TLS, SSE, future rate limits)
- the host-header trap and why FJMCP_BROKER_PUBLIC_URL is the
trusted source of issuer URLs (cross-references the existing
TestDiscovery_IssuerIgnoresHostHeader regression)
- SSE buffering as the most common deployment foot-gun
- optional rate-limit recipe via caddy-ratelimit (defers to backlog
issue -ttl)
- troubleshooting for the four failure modes the broker has actually
seen during dev: wrong issuer, buffered SSE, unreachable upstream,
TLS conflict
README updated to link both deploy guides and the deploy/ subtree.
Closes forgejo-mcp-broker-r2c.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
018f56a4ad
commit
c18120c470
5 changed files with 184 additions and 3 deletions
143
docs/deploy-caddy.md
Normal file
143
docs/deploy-caddy.md
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
# Deploying fjmcp-broker behind Caddy
|
||||
|
||||
This is the front-end half of the production deploy. Pair with
|
||||
[`docs/deploy-podman.md`](deploy-podman.md) for the broker container
|
||||
itself.
|
||||
|
||||
## Why Caddy
|
||||
|
||||
The broker terminates plain HTTP on `127.0.0.1:8080`. Caddy adds:
|
||||
|
||||
- automatic Let's Encrypt TLS;
|
||||
- HTTP→HTTPS redirect;
|
||||
- SSE-friendly proxying for the `/mcp` endpoint;
|
||||
- a clean place to pin rate limits later (issue `-ttl`).
|
||||
|
||||
Other reverse proxies (nginx, Traefik) work too — three knobs to mind:
|
||||
|
||||
1. **Disable response buffering on `/mcp`.** SSE is useless behind a
|
||||
proxy that buffers the response. In Caddy, `flush_interval -1`. In
|
||||
nginx, `proxy_buffering off`.
|
||||
2. **Forward `X-Forwarded-Proto` for free.** The broker doesn't use it
|
||||
for issuer URL construction (intentional — see below), but
|
||||
downstream tooling and access logs benefit.
|
||||
3. **Don't strip `/oauth` or `/.well-known` paths.** The broker mounts
|
||||
every public endpoint at the root.
|
||||
|
||||
## The host-header trap
|
||||
|
||||
Discovery documents at `/.well-known/oauth-authorization-server` and
|
||||
`/.well-known/oauth-protected-resource` carry the **issuer URL** —
|
||||
where clients should send Bearer tokens, where they should land for
|
||||
authorization, etc. If these URLs are constructed from the inbound
|
||||
`Host` header, an attacker can craft a request whose response advertises
|
||||
attacker-controlled endpoints. A naive client follows them and leaks
|
||||
credentials.
|
||||
|
||||
The broker defends against this by deriving every URL from
|
||||
`FJMCP_BROKER_PUBLIC_URL` at startup, ignoring the request's `Host`
|
||||
entirely. Set that env var to *exactly* the hostname Caddy serves —
|
||||
mismatch shows up as redirects to the wrong host.
|
||||
|
||||
There's an explicit regression test for this attack
|
||||
(`TestDiscovery_IssuerIgnoresHostHeader`).
|
||||
|
||||
## Quick start
|
||||
|
||||
Assuming the broker is already running per `deploy-podman.md`:
|
||||
|
||||
```bash
|
||||
sudo cp deploy/caddy/Caddyfile /etc/caddy/Caddyfile
|
||||
sudo $EDITOR /etc/caddy/Caddyfile # replace mcp.example.com
|
||||
sudo systemctl reload caddy
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
curl -fsS https://mcp.example.com/healthz | jq .
|
||||
curl -fsS https://mcp.example.com/.well-known/oauth-authorization-server | jq .issuer
|
||||
# → "https://mcp.example.com" (matches FJMCP_BROKER_PUBLIC_URL)
|
||||
```
|
||||
|
||||
## SSE through Caddy
|
||||
|
||||
The `/mcp` endpoint streams Server-Sent Events for tool responses.
|
||||
Default reverse-proxy buffering breaks this — the client sees nothing
|
||||
until the response completes, defeating the whole streaming model.
|
||||
`flush_interval -1` in the example Caddyfile tells Caddy to flush every
|
||||
write through to the client immediately.
|
||||
|
||||
Quick sanity check (after wiring the OAuth flow end-to-end):
|
||||
|
||||
```bash
|
||||
# Use a long-running tool call as the test. Authorization elided.
|
||||
curl --no-buffer -N \
|
||||
-H "Authorization: Bearer $TOK" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",...}' \
|
||||
https://mcp.example.com/mcp
|
||||
```
|
||||
|
||||
You should see `event: message\ndata: ...\n\n` arrive incrementally,
|
||||
not all at once.
|
||||
|
||||
## Rate limits (optional, recommended)
|
||||
|
||||
Caddy doesn't ship with a rate-limit module by default. The
|
||||
`caddy-ratelimit` plugin adds one:
|
||||
|
||||
```bash
|
||||
xcaddy build --with github.com/mholt/caddy-ratelimit
|
||||
```
|
||||
|
||||
Then in your Caddyfile:
|
||||
|
||||
```caddy
|
||||
mcp.example.com {
|
||||
@oauth_register path /oauth/register
|
||||
@oauth_token path /oauth/token
|
||||
|
||||
rate_limit @oauth_register {
|
||||
zone register {
|
||||
key {remote_host}
|
||||
events 5
|
||||
window 1m
|
||||
}
|
||||
}
|
||||
rate_limit @oauth_token {
|
||||
zone token {
|
||||
key {remote_host}
|
||||
events 30
|
||||
window 1m
|
||||
}
|
||||
}
|
||||
|
||||
reverse_proxy 127.0.0.1:8080 {
|
||||
flush_interval -1
|
||||
# ... rest as above
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This is tracked as backlog issue `-ttl`. Skip it for first deployment;
|
||||
add when the audit log shows abuse worth blocking.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`/.well-known` returns the wrong issuer.** `FJMCP_BROKER_PUBLIC_URL`
|
||||
in the broker's env doesn't match the Caddy hostname. Fix it in
|
||||
`~/.config/fjmcp-broker.env` and `systemctl --user restart fjmcp-broker`.
|
||||
|
||||
**SSE responses arrive only when complete.** Buffering is on
|
||||
somewhere in the chain. Check `flush_interval -1`. If you have another
|
||||
proxy in front of Caddy (a CDN, a load balancer), it may be buffering
|
||||
too — disable response buffering there as well.
|
||||
|
||||
**Caddy can't reach 127.0.0.1:8080.** The Quadlet unit publishes
|
||||
the broker on loopback only. Confirm the container is up
|
||||
(`systemctl --user status fjmcp-broker`) and that the port is
|
||||
actually published (`ss -tlnp | grep 8080`).
|
||||
|
||||
**TLS issues a wrong cert.** Caddy needs ports 80 and 443 open. Make
|
||||
sure no other process is bound to them; Caddy listens by default.
|
||||
Loading…
Add table
Add a link
Reference in a new issue