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
|
|
@ -16,8 +16,8 @@
|
||||||
{"id":"forgejo-mcp-broker-9nq","title":"Phase 1: internal/config package with flag + env parsing and validation","description":"Implement internal/config: a Config struct populated from CLI flags and environment variables (flags win), with validation at startup. Parse public-url, listen addr, forgejo-url, forgejo-oauth-client-id/secret/scopes, forgejo-mcp-binary, store-path, max-sessions, session-idle-timeout, debug. Validation: required fields present and non-empty; public-url parses as an https URL; store-path writable; idle-timeout positive; max-sessions positive. Unit tests cover happy path + every validation error branch.","acceptance_criteria":"go test ./internal/config passes with \u003e=90% coverage; missing required env produces a clear error message listing all missing fields; flag values override env; --help prints all config options.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T14:46:19Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:10:26Z","started_at":"2026-04-24T15:05:57Z","closed_at":"2026-04-24T15:10:26Z","close_reason":"Config package shipped with 94.1% coverage, handles all required-vs-optional fields, env/flag precedence, URL validation with loopback-http exception, and store-path writability. Full test suite green.","dependencies":[{"issue_id":"forgejo-mcp-broker-9nq","depends_on_id":"forgejo-mcp-broker-n84","type":"blocks","created_at":"2026-04-24T16:46:18Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
{"id":"forgejo-mcp-broker-9nq","title":"Phase 1: internal/config package with flag + env parsing and validation","description":"Implement internal/config: a Config struct populated from CLI flags and environment variables (flags win), with validation at startup. Parse public-url, listen addr, forgejo-url, forgejo-oauth-client-id/secret/scopes, forgejo-mcp-binary, store-path, max-sessions, session-idle-timeout, debug. Validation: required fields present and non-empty; public-url parses as an https URL; store-path writable; idle-timeout positive; max-sessions positive. Unit tests cover happy path + every validation error branch.","acceptance_criteria":"go test ./internal/config passes with \u003e=90% coverage; missing required env produces a clear error message listing all missing fields; flag values override env; --help prints all config options.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T14:46:19Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:10:26Z","started_at":"2026-04-24T15:05:57Z","closed_at":"2026-04-24T15:10:26Z","close_reason":"Config package shipped with 94.1% coverage, handles all required-vs-optional fields, env/flag precedence, URL validation with loopback-http exception, and store-path writability. Full test suite green.","dependencies":[{"issue_id":"forgejo-mcp-broker-9nq","depends_on_id":"forgejo-mcp-broker-n84","type":"blocks","created_at":"2026-04-24T16:46:18Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||||||
{"id":"forgejo-mcp-broker-n84","title":"Phase 1: bootstrap Go project layout","description":"Set up the Go project skeleton so all subsequent phase 1 packages have somewhere to live. Initialize go.mod with module path kode.naiv.no/olemd/forgejo-mcp-broker, create the directory layout (cmd/broker, internal/config, internal/log, internal/store, internal/httpserver), add a Makefile with build/test/lint targets, and wire build-info injection (version, git revision, build date) via -ldflags.","acceptance_criteria":"go.mod present with correct module path; make build produces ./fjmcp-broker binary; make test and make lint targets exist and pass against an empty codebase; binary prints --version with injected build info.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T14:45:44Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T14:55:39Z","started_at":"2026-04-24T14:50:57Z","closed_at":"2026-04-24T14:55:39Z","close_reason":"Bootstrap complete: go.mod, Makefile, directory layout, ldflags-injected build info, --version flag all working. make build/test/lint pass.","dependency_count":0,"dependent_count":4,"comment_count":0}
|
{"id":"forgejo-mcp-broker-n84","title":"Phase 1: bootstrap Go project layout","description":"Set up the Go project skeleton so all subsequent phase 1 packages have somewhere to live. Initialize go.mod with module path kode.naiv.no/olemd/forgejo-mcp-broker, create the directory layout (cmd/broker, internal/config, internal/log, internal/store, internal/httpserver), add a Makefile with build/test/lint targets, and wire build-info injection (version, git revision, build date) via -ldflags.","acceptance_criteria":"go.mod present with correct module path; make build produces ./fjmcp-broker binary; make test and make lint targets exist and pass against an empty codebase; binary prints --version with injected build info.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T14:45:44Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T14:55:39Z","started_at":"2026-04-24T14:50:57Z","closed_at":"2026-04-24T14:55:39Z","close_reason":"Bootstrap complete: go.mod, Makefile, directory layout, ldflags-injected build info, --version flag all working. make build/test/lint pass.","dependency_count":0,"dependent_count":4,"comment_count":0}
|
||||||
{"id":"forgejo-mcp-broker-q6n","title":"Phase 7a: Claude.ai end-to-end validation on staging","description":"Stand up staging Forgejo + broker on a public hostname with real TLS. Register as a Claude.ai MCP connector. Walk through OAuth, tool discovery, tool invocation, session timeout, reconnect, Forgejo token refresh mid-session. Capture findings.","acceptance_criteria":"Claude.ai completes OAuth and lists tools. All forgejo-mcp tools invocable. 30-min idle session reconnects. Token refresh during active session invisible to user. docs/phase7-findings.md written.","status":"open","priority":2,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:19Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:45:19Z","dependencies":[{"issue_id":"forgejo-mcp-broker-q6n","depends_on_id":"forgejo-mcp-broker-8yd","type":"blocks","created_at":"2026-04-24T17:45:34Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-q6n","depends_on_id":"forgejo-mcp-broker-q4x","type":"blocks","created_at":"2026-04-24T17:45:34Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-q6n","depends_on_id":"forgejo-mcp-broker-r2c","type":"blocks","created_at":"2026-04-24T17:45:35Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-q6n","depends_on_id":"forgejo-mcp-broker-t81","type":"blocks","created_at":"2026-04-24T17:45:33Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-q6n","depends_on_id":"forgejo-mcp-broker-ytw","type":"blocks","created_at":"2026-04-24T17:45:33Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0}
|
{"id":"forgejo-mcp-broker-q6n","title":"Phase 7a: Claude.ai end-to-end validation on staging","description":"Stand up staging Forgejo + broker on a public hostname with real TLS. Register as a Claude.ai MCP connector. Walk through OAuth, tool discovery, tool invocation, session timeout, reconnect, Forgejo token refresh mid-session. Capture findings.","acceptance_criteria":"Claude.ai completes OAuth and lists tools. All forgejo-mcp tools invocable. 30-min idle session reconnects. Token refresh during active session invisible to user. docs/phase7-findings.md written.","status":"open","priority":2,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:19Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:45:19Z","dependencies":[{"issue_id":"forgejo-mcp-broker-q6n","depends_on_id":"forgejo-mcp-broker-8yd","type":"blocks","created_at":"2026-04-24T17:45:34Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-q6n","depends_on_id":"forgejo-mcp-broker-q4x","type":"blocks","created_at":"2026-04-24T17:45:34Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-q6n","depends_on_id":"forgejo-mcp-broker-r2c","type":"blocks","created_at":"2026-04-24T17:45:35Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-q6n","depends_on_id":"forgejo-mcp-broker-t81","type":"blocks","created_at":"2026-04-24T17:45:33Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-q6n","depends_on_id":"forgejo-mcp-broker-ytw","type":"blocks","created_at":"2026-04-24T17:45:33Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0}
|
||||||
{"id":"forgejo-mcp-broker-r2c","title":"Phase 6a: Caddy front-end example + docs/deploy-caddy.md","description":"deploy/caddy/Caddyfile example and docs/deploy-caddy.md: TLS via Let's Encrypt, flush_interval -1 for SSE, X-Forwarded-* config (issuer URL still from broker config, never inbound headers).","acceptance_criteria":"Caddyfile works against a local broker (curl --resolve test). docs/deploy-caddy.md linked from README and deploy-podman.md. Explains host-header attack defense.","status":"open","priority":2,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:19Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:45:19Z","dependencies":[{"issue_id":"forgejo-mcp-broker-r2c","depends_on_id":"forgejo-mcp-broker-8yd","type":"blocks","created_at":"2026-04-24T17:45:32Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
{"id":"forgejo-mcp-broker-r2c","title":"Phase 6a: Caddy front-end example + docs/deploy-caddy.md","description":"deploy/caddy/Caddyfile example and docs/deploy-caddy.md: TLS via Let's Encrypt, flush_interval -1 for SSE, X-Forwarded-* config (issuer URL still from broker config, never inbound headers).","acceptance_criteria":"Caddyfile works against a local broker (curl --resolve test). docs/deploy-caddy.md linked from README and deploy-podman.md. Explains host-header attack defense.","status":"in_progress","priority":2,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:19Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T15:42:37Z","started_at":"2026-04-27T15:42:37Z","dependencies":[{"issue_id":"forgejo-mcp-broker-r2c","depends_on_id":"forgejo-mcp-broker-8yd","type":"blocks","created_at":"2026-04-24T17:45:32Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||||||
{"id":"forgejo-mcp-broker-8yd","title":"Deploy via rootless podman with persistent state","description":"Operators will deploy fjmcp-broker via rootless podman (user's stated preference over docker). The broker needs: (1) a working Containerfile that bundles both fjmcp-broker and a pinned forgejo-mcp binary so the broker can spawn subprocesses; (2) deployment artefacts that use a named podman volume for /data so the SQLite store persists across container restarts and image updates; (3) documentation an operator can follow end-to-end. Container must run as non-root, carry OCI labels (org.opencontainers.image.created, .revision) and /etc/build-info per the user's global container standards. Note: full usefulness depends on OAuth (phase 2) being implemented, but writing the Containerfile + deploy docs early forces runtime concerns (volumes, env vars, non-root) to surface before they become harder to fix.","design":"Container: multi-stage build. Stage 1 compiles fjmcp-broker with -trimpath and ldflags for build info. Stage 2 fetches forgejo-mcp at a pinned version (pinning matters — the broker subprocess contract depends on flags/env). Final stage uses distroless/static or alpine-nonroot, copies both binaries, USER 65532, EXPOSE 8080, ENTRYPOINT ['/usr/local/bin/fjmcp-broker']. Volume strategy: /data is a podman named volume ('fjmcp-state') mounted with the right uid. SQLite WAL files live inside it and survive restarts. Env vars for secrets via --env-file pointing to a 0600-permissioned file outside the repo. Build args: BUILD_DATE and GIT_REVISION, wired into labels and /etc/build-info. Quadlet unit: use Podman Quadlet (not the deprecated podman generate systemd) — drop fjmcp-broker.container under ~/.config/containers/systemd/, daemon-reload, start.","acceptance_criteria":"make image builds locally under rootless podman; podman run -p 8080:8080 -v fjmcp-state:/data --env-file .env fjmcp-broker:latest starts the broker and /healthz returns 200 with store=ok; stopping the container and starting a new one against the same fjmcp-state volume preserves schema_migrations rows; pulling a newer image and recreating the container preserves state; a Quadlet .container unit example brings the broker up under systemd --user; docs/deploy-podman.md walks a fresh operator through build → run → persistent state → secret injection → image upgrade → troubleshooting.","notes":"Related to plan.md Phase 6 (packaging). If we build a compose example later for non-podman users, it can piggyback off the same Containerfile. Caddy sits in front of this container — include a brief note about Caddy+flush_interval requirements for SSE, referencing design.md §7.2.","status":"in_progress","priority":2,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:30:50Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T15:37:37Z","started_at":"2026-04-27T15:37:37Z","dependency_count":0,"dependent_count":2,"comment_count":0}
|
{"id":"forgejo-mcp-broker-8yd","title":"Deploy via rootless podman with persistent state","description":"Operators will deploy fjmcp-broker via rootless podman (user's stated preference over docker). The broker needs: (1) a working Containerfile that bundles both fjmcp-broker and a pinned forgejo-mcp binary so the broker can spawn subprocesses; (2) deployment artefacts that use a named podman volume for /data so the SQLite store persists across container restarts and image updates; (3) documentation an operator can follow end-to-end. Container must run as non-root, carry OCI labels (org.opencontainers.image.created, .revision) and /etc/build-info per the user's global container standards. Note: full usefulness depends on OAuth (phase 2) being implemented, but writing the Containerfile + deploy docs early forces runtime concerns (volumes, env vars, non-root) to surface before they become harder to fix.","design":"Container: multi-stage build. Stage 1 compiles fjmcp-broker with -trimpath and ldflags for build info. Stage 2 fetches forgejo-mcp at a pinned version (pinning matters — the broker subprocess contract depends on flags/env). Final stage uses distroless/static or alpine-nonroot, copies both binaries, USER 65532, EXPOSE 8080, ENTRYPOINT ['/usr/local/bin/fjmcp-broker']. Volume strategy: /data is a podman named volume ('fjmcp-state') mounted with the right uid. SQLite WAL files live inside it and survive restarts. Env vars for secrets via --env-file pointing to a 0600-permissioned file outside the repo. Build args: BUILD_DATE and GIT_REVISION, wired into labels and /etc/build-info. Quadlet unit: use Podman Quadlet (not the deprecated podman generate systemd) — drop fjmcp-broker.container under ~/.config/containers/systemd/, daemon-reload, start.","acceptance_criteria":"make image builds locally under rootless podman; podman run -p 8080:8080 -v fjmcp-state:/data --env-file .env fjmcp-broker:latest starts the broker and /healthz returns 200 with store=ok; stopping the container and starting a new one against the same fjmcp-state volume preserves schema_migrations rows; pulling a newer image and recreating the container preserves state; a Quadlet .container unit example brings the broker up under systemd --user; docs/deploy-podman.md walks a fresh operator through build → run → persistent state → secret injection → image upgrade → troubleshooting.","notes":"Related to plan.md Phase 6 (packaging). If we build a compose example later for non-podman users, it can piggyback off the same Containerfile. Caddy sits in front of this container — include a brief note about Caddy+flush_interval requirements for SSE, referencing design.md §7.2.","status":"closed","priority":2,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:30:50Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T15:42:17Z","started_at":"2026-04-27T15:37:37Z","closed_at":"2026-04-27T15:42:17Z","close_reason":"Containerfile + Quadlet unit + docs/deploy-podman.md shipped. Image builds under rootless podman; verified end-to-end that fjmcp-state volume preserves broker.db + WAL + SHM across container swap and that /healthz returns store: ok on the new container. Unblocks 6a (Caddy front-end docs).","dependency_count":0,"dependent_count":2,"comment_count":0}
|
||||||
{"id":"forgejo-mcp-broker-0sq","title":"Backlog: Forgejo Actions CI workflow","description":".forgejo/workflows/ci.yml running go vet, go test -race, go build, and golangci-lint on PRs and pushes to main. Block merge on failure.","acceptance_criteria":"CI green on main. PR failures block merge. Badge/status referenced in README.","status":"open","priority":3,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:24Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:45:24Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"id":"forgejo-mcp-broker-0sq","title":"Backlog: Forgejo Actions CI workflow","description":".forgejo/workflows/ci.yml running go vet, go test -race, go build, and golangci-lint on PRs and pushes to main. Block merge on failure.","acceptance_criteria":"CI green on main. PR failures block merge. Badge/status referenced in README.","status":"open","priority":3,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:24Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:45:24Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"id":"forgejo-mcp-broker-ttl","title":"Backlog: rate limits on /oauth/register and /oauth/token","description":"design.md §8: Start with Caddy-level rate limits in the example Caddyfile (coarse but free). Add broker-side middleware only if operator experience shows Caddy is too coarse.","acceptance_criteria":"Caddy example extended with rate_limit directive. If broker-side added: unit tests cover quota exhaustion and reset windows.","status":"open","priority":3,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:23Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:45:23Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"id":"forgejo-mcp-broker-ttl","title":"Backlog: rate limits on /oauth/register and /oauth/token","description":"design.md §8: Start with Caddy-level rate limits in the example Caddyfile (coarse but free). Add broker-side middleware only if operator experience shows Caddy is too coarse.","acceptance_criteria":"Caddy example extended with rate_limit directive. If broker-side added: unit tests cover quota exhaustion and reset windows.","status":"open","priority":3,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:23Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:45:23Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"id":"forgejo-mcp-broker-1n2","title":"Backlog: upstream forgejo-mcp --token-fd flag","description":"Contribute --token-fd to upstream forgejo-mcp so broker passes token via inherited FD instead of env, closing /proc/\u003cpid\u003e/environ leak (design.md §11). Tracked in a sibling repo; this issue tracks our consumption side.","acceptance_criteria":"Upstream PR merged and broker switches to --token-fd, OR design.md §11 records that we accept the env exposure for now.","status":"open","priority":3,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:22Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:45:22Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"id":"forgejo-mcp-broker-1n2","title":"Backlog: upstream forgejo-mcp --token-fd flag","description":"Contribute --token-fd to upstream forgejo-mcp so broker passes token via inherited FD instead of env, closing /proc/\u003cpid\u003e/environ leak (design.md §11). Tracked in a sibling repo; this issue tracks our consumption side.","acceptance_criteria":"Upstream PR merged and broker switches to --token-fd, OR design.md §11 records that we accept the env exposure for now.","status":"open","priority":3,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:22Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:45:22Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,10 @@ Process-level isolation. Each user's Forgejo token lives in exactly one subproce
|
||||||
| [`docs/design.md`](docs/design.md) | Architecture, components, token flow, deployment, security |
|
| [`docs/design.md`](docs/design.md) | Architecture, components, token flow, deployment, security |
|
||||||
| [`docs/plan.md`](docs/plan.md) | Seven-phase implementation plan with acceptance criteria |
|
| [`docs/plan.md`](docs/plan.md) | Seven-phase implementation plan with acceptance criteria |
|
||||||
| [`docs/deploy-podman.md`](docs/deploy-podman.md) | End-to-end production deploy with rootless podman + Quadlet |
|
| [`docs/deploy-podman.md`](docs/deploy-podman.md) | End-to-end production deploy with rootless podman + Quadlet |
|
||||||
|
| [`docs/deploy-caddy.md`](docs/deploy-caddy.md) | Caddy reverse-proxy front-end (TLS, SSE, host-header defense) |
|
||||||
| [`Containerfile`](Containerfile) | Multi-stage build; bundles broker + pinned forgejo-mcp |
|
| [`Containerfile`](Containerfile) | Multi-stage build; bundles broker + pinned forgejo-mcp |
|
||||||
| [`deploy/podman/`](deploy/podman/) | Quadlet unit and example env file |
|
| [`deploy/podman/`](deploy/podman/) | Quadlet unit and example env file |
|
||||||
|
| [`deploy/caddy/`](deploy/caddy/) | Example Caddyfile |
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
36
deploy/caddy/Caddyfile
Normal file
36
deploy/caddy/Caddyfile
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
# Caddyfile for fjmcp-broker.
|
||||||
|
#
|
||||||
|
# Place at /etc/caddy/Caddyfile (or wherever your Caddy reads from) and
|
||||||
|
# replace `mcp.example.com` with your real hostname. Caddy fetches an
|
||||||
|
# automatic Let's Encrypt cert on first start.
|
||||||
|
#
|
||||||
|
# This config front-ends the broker container that listens on
|
||||||
|
# 127.0.0.1:8080 (matching deploy/podman/fjmcp-broker.container).
|
||||||
|
|
||||||
|
mcp.example.com {
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
# Reverse-proxy everything to the broker. The broker mounts every
|
||||||
|
# endpoint at the root: /healthz, /oauth/*, /.well-known/*, /mcp.
|
||||||
|
#
|
||||||
|
# Caddy already forwards X-Forwarded-For / X-Forwarded-Proto / Host
|
||||||
|
# by default, so they're not listed below. The broker derives its
|
||||||
|
# own identity from FJMCP_BROKER_PUBLIC_URL anyway and ignores
|
||||||
|
# these headers (see TestDiscovery_IssuerIgnoresHostHeader).
|
||||||
|
reverse_proxy 127.0.0.1:8080 {
|
||||||
|
# SSE responses on /mcp need flushed-as-we-go forwarding;
|
||||||
|
# default buffering would defeat the streaming model. -1 means
|
||||||
|
# "flush every write".
|
||||||
|
flush_interval -1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Optional: drop a structured access log under a separate file so
|
||||||
|
# broker stderr stays clean for application events.
|
||||||
|
log {
|
||||||
|
output file /var/log/caddy/fjmcp-broker.log {
|
||||||
|
roll_size 50mb
|
||||||
|
roll_keep 5
|
||||||
|
}
|
||||||
|
format json
|
||||||
|
}
|
||||||
|
}
|
||||||
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.
|
||||||
|
|
@ -85,7 +85,7 @@ curl -fsS http://127.0.0.1:8080/healthz | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
A healthy response includes `{"status":"ok","store":"ok",…}`. Wire
|
A healthy response includes `{"status":"ok","store":"ok",…}`. Wire
|
||||||
Caddy in front next; see `docs/deploy-caddy.md`.
|
Caddy in front next; see [`deploy-caddy.md`](deploy-caddy.md).
|
||||||
|
|
||||||
## 5. Persist state across image upgrades
|
## 5. Persist state across image upgrades
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue