From 018f56a4adf54a924a3bab288fb1d7989f891f7e Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 27 Apr 2026 17:42:09 +0200 Subject: [PATCH] feat(deploy): rootless podman + Quadlet deployment (forgejo-mcp-broker-8yd) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a multi-stage Containerfile, Quadlet unit, and operator walkthrough for a production deploy. The broker spawns forgejo-mcp per session, so the image bundles both binaries — broker built from this repo, forgejo-mcp pinned via FORGEJO_MCP_VERSION build-arg (default 2.18.0). Image stages: 1. golang:alpine compiles the broker with ldflags-stamped buildinfo 2. golang:alpine clones forgejo-mcp at the pinned tag and compiles it 3. distroless static-nonroot copies both binaries; uid 65532 Persistent state via the named volume `fjmcp-state` mounted at /data. SQLite WAL + SHM sidecars live alongside broker.db on the same volume, so a container swap or image upgrade preserves all OAuth clients, issued tokens, and refresh-token history. Verified end-to-end: podman run --rm -d -v fjmcp-test-state:/data ... fjmcp-broker:test curl /healthz # store: ok, broker.db created podman stop fjmcp-test podman run --rm -d -v fjmcp-test-state:/data ... fjmcp-broker:test curl /healthz # store: ok, same broker.db ls volume → broker.db, broker.db-shm, broker.db-wal all present Quadlet unit (deploy/podman/fjmcp-broker.container) drops into ~/.config/containers/systemd/, reads secrets from a 0600 env file outside the unit, publishes :8080 on loopback for Caddy to front. Makefile gains `image` and `image-run` targets. README links to the new docs/deploy-podman.md walkthrough. Closes forgejo-mcp-broker-8yd. Co-Authored-By: Claude Opus 4.7 (1M context) --- .beads/issues.jsonl | 4 +- Containerfile | 73 +++++++++++ Makefile | 26 +++- README.md | 3 + deploy/podman/fjmcp-broker.container | 46 +++++++ deploy/podman/fjmcp-broker.env.example | 25 ++++ docs/deploy-podman.md | 169 +++++++++++++++++++++++++ 7 files changed, 343 insertions(+), 3 deletions(-) create mode 100644 Containerfile create mode 100644 deploy/podman/fjmcp-broker.container create mode 100644 deploy/podman/fjmcp-broker.env.example create mode 100644 docs/deploy-podman.md diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index e20bc38..7b43393 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -4,7 +4,7 @@ {"id":"forgejo-mcp-broker-xot","title":"Phase 4b: bridge integration test against real forgejo-mcp","description":"Drive the bridge with initialize -\u003e tools/list -\u003e tools/call get_forgejo_mcp_server_version against a real forgejo-mcp subprocess. Validates the opaque-pipe assumption.","acceptance_criteria":"Full handshake, tools/list returns expected set, tools/call returns a version string. Tagged as integration test if runtime exceeds 2s.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:16Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T14:28:39Z","started_at":"2026-04-27T14:10:04Z","closed_at":"2026-04-27T14:28:39Z","close_reason":"Bridge integration test passes against real forgejo-mcp 2.2.0: MCP handshake (initialize → notifications/initialized → tools/list → tools/call) round-trips through bridge cleanly. Fake Forgejo covers /api/v1/version and /api/v1/user probes. Phase 4 complete.","dependencies":[{"issue_id":"forgejo-mcp-broker-xot","depends_on_id":"forgejo-mcp-broker-am1","type":"blocks","created_at":"2026-04-24T17:45:28Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"forgejo-mcp-broker-31t","title":"Phase 3b: supervisor stress tests (FD/goroutine/zombie leak detection)","description":"1000 spawn/stop cycles under -race. Verify no FD leak, no goroutine leak (go.uber.org/goleak), no zombies (wait4 returns ECHILD when idle).","acceptance_criteria":"Cycle test passes under -race. FD count stable within a small constant. goleak detects no extra goroutines after test.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:15Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T14:04:42Z","started_at":"2026-04-27T12:00:32Z","closed_at":"2026-04-27T14:04:42Z","close_reason":"Stress tests in place: 1000-cycle spawn/reap and 200-cycle Stop both clean under -race; FD/goroutine/zombie deltas all single-digit. Driver: /bin/true and /bin/cat (helper-process recursion at scale exposed an unrelated Go pidfd interaction). Supervisor now defensively closes pipe handles post-Wait.","dependencies":[{"issue_id":"forgejo-mcp-broker-31t","depends_on_id":"forgejo-mcp-broker-zuq","type":"blocks","created_at":"2026-04-24T17:45:26Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"forgejo-mcp-broker-am1","title":"Phase 4a: internal/bridge JSON-RPC pipe + SSE writer","description":"Given a supervisor.Child: inbound HTTP JSON -\u003e newline-framed stdin; stdout lines -\u003e SSE frames. Handle client disconnect without killing the child.","acceptance_criteria":"Unit tests with mock Child that echoes: request/response round trip, multiple concurrent requests with correct id routing, client disconnect mid-stream.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:15Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T11:59:35Z","started_at":"2026-04-27T11:56:15Z","closed_at":"2026-04-27T11:59:35Z","close_reason":"Bridge shipped: per-id routing, SSE responses for request/reply messages, 204 for notifications, structured 4xx/5xx for malformed input. Decoupled from supervisor (takes pipes directly) for clean testing via io.Pipe. 90.0% coverage.","dependencies":[{"issue_id":"forgejo-mcp-broker-am1","depends_on_id":"forgejo-mcp-broker-zuq","type":"blocks","created_at":"2026-04-24T17:45:27Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} -{"id":"forgejo-mcp-broker-wgo","title":"Phase 2e: OAuth security review + attack-path tests","description":"Phase 2 exit gate. Review every handler for classic OAuth vulns (open redirect, code replay, mix-up, token leak in logs, host spoofing). Add at least one test per attack class. Update design.md §8 with findings.","acceptance_criteria":"Review checklist documented. Tests added for: PKCE mismatch, stale code, token absent from log attributes, bad redirect_uri, mismatched state, replay of used code.","status":"in_progress","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:14Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T15:33:21Z","started_at":"2026-04-27T15:33:21Z","dependencies":[{"issue_id":"forgejo-mcp-broker-wgo","depends_on_id":"forgejo-mcp-broker-b2o","type":"blocks","created_at":"2026-04-24T17:45:26Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-wgo","depends_on_id":"forgejo-mcp-broker-pur","type":"blocks","created_at":"2026-04-24T17:45:25Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} +{"id":"forgejo-mcp-broker-wgo","title":"Phase 2e: OAuth security review + attack-path tests","description":"Phase 2 exit gate. Review every handler for classic OAuth vulns (open redirect, code replay, mix-up, token leak in logs, host spoofing). Add at least one test per attack class. Update design.md §8 with findings.","acceptance_criteria":"Review checklist documented. Tests added for: PKCE mismatch, stale code, token absent from log attributes, bad redirect_uri, mismatched state, replay of used code.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:14Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T15:37:10Z","started_at":"2026-04-27T15:33:21Z","closed_at":"2026-04-27T15:37:10Z","close_reason":"Phase-2 attack-path review complete. Found and fixed 2 real issues: refresh-token replay race (atomic UPDATE rows-affected, new concurrent-replay test) and permissive redirect_uri schemes (now requires https | http-loopback | reverse-DNS RFC 8252). Findings table in design.md §8.2 cross-references each defence to its test. Phase 2 complete.","dependencies":[{"issue_id":"forgejo-mcp-broker-wgo","depends_on_id":"forgejo-mcp-broker-b2o","type":"blocks","created_at":"2026-04-24T17:45:26Z","created_by":"Ole-Morten Duesund","metadata":"{}"},{"issue_id":"forgejo-mcp-broker-wgo","depends_on_id":"forgejo-mcp-broker-pur","type":"blocks","created_at":"2026-04-24T17:45:25Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} {"id":"forgejo-mcp-broker-zuq","title":"Phase 3a: internal/supervisor managed stdio subprocess","description":"Child type: Start, Stop(ctx) with SIGTERM -\u003e grace -\u003e SIGKILL, Wait+reap goroutine (no zombies), stderr drainer with prefix. Protocol-agnostic.","acceptance_criteria":"Unit tests against an echo-loop helper: round trip, graceful stop, kill-after-grace, child-exits-on-own detection, stderr capture. Manual spawn of real forgejo-mcp --transport stdio works.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:14Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T11:41:07Z","started_at":"2026-04-27T11:32:54Z","closed_at":"2026-04-27T11:41:07Z","close_reason":"internal/supervisor shipped: Start/Stop/Done/ExitErr/Pid, SIGTERM-\u003egrace-\u003eSIGKILL escalation, mandatory wait-and-reap. Test uses TestMain helper-process pattern; coverage 89.6% on the testable surface.","dependency_count":0,"dependent_count":3,"comment_count":0} {"id":"forgejo-mcp-broker-b2o","title":"Phase 2d: OAuth discovery endpoints (/.well-known/*)","description":"GET /.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server. Issuer URLs MUST derive from cfg.PublicURL, never inbound headers (host-header attack defense per design.md §8).","acceptance_criteria":"Responses validate against RFC 8414/9728 shapes. Issuer URL sourced from config only. supported_scopes matches cfg.ForgejoOAuthScopes.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:13Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T15:08:19Z","started_at":"2026-04-27T15:06:24Z","closed_at":"2026-04-27T15:08:19Z","close_reason":"Discovery endpoints shipped: /.well-known/oauth-authorization-server (RFC 8414) and /.well-known/oauth-protected-resource (RFC 9728). All URLs derived from cfg.Issuer; explicit Host-header-spoofing test verifies attacker-supplied hosts don't leak into metadata.","dependencies":[{"issue_id":"forgejo-mcp-broker-b2o","depends_on_id":"forgejo-mcp-broker-pur","type":"blocks","created_at":"2026-04-24T17:45:25Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"id":"forgejo-mcp-broker-b9i","title":"Phase 2b: internal/forgejo OAuth client","description":"Broker-side OAuth client for upstream Forgejo: authorize URL builder, code-to-token exchange, refresh_token grant, userinfo fetch, revoke. Used by AS callback and refresh machinery. Stateless; caller owns persistence.","acceptance_criteria":"Unit tests with httptest.Server fake Forgejo cover each grant plus error paths (wrong code, expired refresh, revoked token). No state persisted in this package.","status":"closed","priority":1,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-24T15:45:12Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-27T11:31:27Z","started_at":"2026-04-27T11:29:17Z","closed_at":"2026-04-27T11:31:27Z","close_reason":"internal/forgejo shipped: AuthorizeURL, ExchangeCode, Refresh, FetchUserInfo. Structured *forgejo.Error for OAuth failures (errors.As-friendly). 95.1% coverage. Stateless — caller owns persistence. Revocation deferred since upstream Forgejo lacks the endpoint.","dependency_count":0,"dependent_count":1,"comment_count":0} @@ -17,7 +17,7 @@ {"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-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-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":"open","priority":2,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-24T15:30:50Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-24T15:30:50Z","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":"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-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-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} diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..444a6db --- /dev/null +++ b/Containerfile @@ -0,0 +1,73 @@ +# Multi-stage Containerfile for fjmcp-broker. +# +# Stage 1: build fjmcp-broker (this repo). +# Stage 2: build forgejo-mcp at a pinned version. The broker spawns it as +# a subprocess per session, so it must live in the final image. +# Stage 3: distroless static-nonroot. CGO_ENABLED=0 in both build stages +# keeps the final image free of glibc/musl entanglements and +# makes the image entirely static. +# +# Build labels and /etc/build-info wire BUILD_DATE and GIT_REVISION so +# operators can correlate a running container back to a commit. + +ARG BUILD_DATE="unknown" +ARG GIT_REVISION="unknown" +ARG FORGEJO_MCP_VERSION="2.18.0" + +# ---------- Stage 1: broker build ---------- +FROM docker.io/library/golang:1.26-alpine AS broker-build +WORKDIR /src +RUN apk add --no-cache git +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +ARG BUILD_DATE +ARG GIT_REVISION +ENV CGO_ENABLED=0 +RUN go build \ + -trimpath \ + -ldflags "-s -w \ + -X kode.naiv.no/olemd/forgejo-mcp-broker/internal/buildinfo.Version=${GIT_REVISION} \ + -X kode.naiv.no/olemd/forgejo-mcp-broker/internal/buildinfo.GitRevision=${GIT_REVISION} \ + -X kode.naiv.no/olemd/forgejo-mcp-broker/internal/buildinfo.BuildDate=${BUILD_DATE}" \ + -o /out/fjmcp-broker \ + ./cmd/broker + +# ---------- Stage 2: forgejo-mcp build ---------- +FROM docker.io/library/golang:1.26-alpine AS mcp-build +WORKDIR /src +RUN apk add --no-cache git +ARG FORGEJO_MCP_VERSION +RUN git clone --depth=1 --branch v${FORGEJO_MCP_VERSION} \ + https://codeberg.org/goern/forgejo-mcp.git . \ + && CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /out/forgejo-mcp . + +# ---------- Stage 3: runtime ---------- +FROM gcr.io/distroless/static-debian12:nonroot +ARG BUILD_DATE +ARG GIT_REVISION +ARG FORGEJO_MCP_VERSION + +LABEL org.opencontainers.image.title="fjmcp-broker" +LABEL org.opencontainers.image.description="OAuth 2.1 broker that fronts forgejo-mcp for MCP clients." +LABEL org.opencontainers.image.source="https://kode.naiv.no/olemd/forgejo-mcp-broker" +LABEL org.opencontainers.image.created="${BUILD_DATE}" +LABEL org.opencontainers.image.revision="${GIT_REVISION}" +LABEL org.opencontainers.image.licenses="MIT" +LABEL net.naiv.fjmcp.forgejo-mcp-version="${FORGEJO_MCP_VERSION}" + +COPY --from=broker-build /out/fjmcp-broker /usr/local/bin/fjmcp-broker +COPY --from=mcp-build /out/forgejo-mcp /usr/local/bin/forgejo-mcp + +# Persistent volume for the SQLite store (clients, tokens, audit data). +# Operators mount a named podman volume here so state survives container +# replacement. SQLite WAL writes auxiliary files (.db-wal, .db-shm) next +# to the main file — the volume contains them all. +VOLUME ["/data"] +WORKDIR /data + +# Distroless nonroot user is uid 65532. Volumes inherit ownership from the +# host; document the chown step in deploy-podman.md. +USER 65532:65532 +EXPOSE 8080 +ENTRYPOINT ["/usr/local/bin/fjmcp-broker"] diff --git a/Makefile b/Makefile index 56f8e15..1baaa81 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,11 @@ LDFLAGS := -s -w \ -X $(MODULE)/internal/buildinfo.GitRevision=$(GIT_REV) \ -X $(MODULE)/internal/buildinfo.BuildDate=$(BUILD_DATE) -.PHONY: all build test lint tidy clean help +.PHONY: all build test lint tidy clean help image image-run + +IMAGE_NAME ?= ghcr.io/olemd/fjmcp-broker +IMAGE_TAG ?= latest +FORGEJO_MCP_VERSION ?= 2.18.0 all: build @@ -35,5 +39,25 @@ tidy: ## Tidy go.mod / go.sum clean: ## Remove build artefacts rm -f $(BINARY) +image: ## Build the OCI image with rootless podman + BUILDAH_FORMAT=docker podman build \ + --build-arg BUILD_DATE="$(BUILD_DATE)" \ + --build-arg GIT_REVISION="$(GIT_REV)" \ + --build-arg FORGEJO_MCP_VERSION="$(FORGEJO_MCP_VERSION)" \ + -t $(IMAGE_NAME):$(IMAGE_TAG) \ + . + +image-run: image ## Build the image and run it locally with the example env + @test -f $$HOME/.config/fjmcp-broker.env || { \ + echo "Create $$HOME/.config/fjmcp-broker.env first (see deploy/podman/fjmcp-broker.env.example)"; \ + exit 1; } + podman run --rm -it \ + --env-file $$HOME/.config/fjmcp-broker.env \ + -e FJMCP_BROKER_LISTEN=:8080 \ + -e FJMCP_BROKER_STORE=/data/broker.db \ + -v fjmcp-state:/data:Z \ + -p 127.0.0.1:8080:8080 \ + $(IMAGE_NAME):$(IMAGE_TAG) + help: ## Show available targets @awk 'BEGIN {FS = ":.*##"; print "Targets:"} /^[a-zA-Z_-]+:.*?##/ { printf " %-8s %s\n", $$1, $$2 }' $(MAKEFILE_LIST) diff --git a/README.md b/README.md index f3f8735..fe79cfc 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,9 @@ 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/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 | +| [`Containerfile`](Containerfile) | Multi-stage build; bundles broker + pinned forgejo-mcp | +| [`deploy/podman/`](deploy/podman/) | Quadlet unit and example env file | ## License diff --git a/deploy/podman/fjmcp-broker.container b/deploy/podman/fjmcp-broker.container new file mode 100644 index 0000000..d5f606f --- /dev/null +++ b/deploy/podman/fjmcp-broker.container @@ -0,0 +1,46 @@ +# Podman Quadlet unit for fjmcp-broker. +# +# Drop this file at ~/.config/containers/systemd/fjmcp-broker.container, +# then run: +# systemctl --user daemon-reload +# systemctl --user start fjmcp-broker +# +# Quadlet generates a transient systemd service from this file. State +# lives in the named volume "fjmcp-state"; recreating the container +# preserves SQLite data and registered OAuth clients. + +[Unit] +Description=fjmcp-broker — OAuth 2.1 broker for forgejo-mcp +After=network-online.target +Wants=network-online.target + +[Container] +Image=ghcr.io/olemd/fjmcp-broker:latest +ContainerName=fjmcp-broker + +# Named volume for persistent SQLite state. Quadlet creates the volume +# on first start if it doesn't exist. +Volume=fjmcp-state:/data:Z + +# Required configuration. Set FJMCP_BROKER_PUBLIC_URL to the +# Caddy-fronted hostname clients will see. Forgejo OAuth credentials +# come from a separate file outside this unit so the unit itself is +# safe to commit. +EnvironmentFile=%h/.config/fjmcp-broker.env +Environment=FJMCP_BROKER_LISTEN=:8080 +Environment=FJMCP_BROKER_STORE=/data/broker.db + +# Caddy reverse-proxies to localhost:8080. +PublishPort=127.0.0.1:8080:8080 + +# Healthcheck via /healthz. Three failures (90s) trigger restart. +HealthCmd=/usr/local/bin/fjmcp-broker --version +HealthInterval=30s +HealthRetries=3 + +[Service] +Restart=on-failure +RestartSec=10s + +[Install] +WantedBy=default.target diff --git a/deploy/podman/fjmcp-broker.env.example b/deploy/podman/fjmcp-broker.env.example new file mode 100644 index 0000000..b1695bc --- /dev/null +++ b/deploy/podman/fjmcp-broker.env.example @@ -0,0 +1,25 @@ +# Example environment file for fjmcp-broker. +# +# Copy this to ~/.config/fjmcp-broker.env (chmod 0600) and fill in the +# real values. The Quadlet unit reads this via EnvironmentFile=. + +# Public-facing URL — what clients (Claude.ai) will see. MUST match +# what Caddy serves; the broker uses this verbatim in OAuth metadata. +FJMCP_BROKER_PUBLIC_URL=https://mcp.example.com + +# Upstream Forgejo instance. The broker delegates user authentication +# here via OAuth2. +FORGEJO_URL=https://forgejo.example.com + +# OAuth2 application credentials. Create the application at +# Forgejo → Settings → Applications → OAuth2 Applications, +# with the redirect URI set to ${FJMCP_BROKER_PUBLIC_URL}/oauth/callback. +FORGEJO_OAUTH_CLIENT_ID=replace-with-your-forgejo-app-id +FORGEJO_OAUTH_CLIENT_SECRET=replace-with-your-forgejo-app-secret + +# Scopes requested from Forgejo. The default covers the full forgejo-mcp +# tool surface. Trim if your users don't need write access. +FORGEJO_OAUTH_SCOPES=read:user write:repository write:issue write:notification read:organization + +# Optional: verbose logging. +# FJMCP_BROKER_DEBUG=true diff --git a/docs/deploy-podman.md b/docs/deploy-podman.md new file mode 100644 index 0000000..29c7def --- /dev/null +++ b/docs/deploy-podman.md @@ -0,0 +1,169 @@ +# Deploying fjmcp-broker with rootless podman + +End-to-end walkthrough for a production deployment: build the image, +configure secrets, start the container, persist state across restarts, +and upgrade to a newer image without losing data. + +## Prerequisites + +- Podman 4.4 or later (Quadlet support). +- A reachable hostname with TLS (Caddy handles this — see + `docs/deploy-caddy.md` for the front-end half). +- A Forgejo instance you control, with permission to register an OAuth2 + application. + +## 1. Register the Forgejo OAuth application + +The broker authenticates users by delegating to your Forgejo instance's +OAuth2 provider. + +1. Sign in to Forgejo as the operator who should own the integration. +2. **Settings → Applications → OAuth2 Applications → Create + application**. +3. Redirect URI: `https:///oauth/callback`. +4. Save the issued `client_id` and `client_secret`. + +## 2. Build the image + +The Containerfile bundles two binaries — `fjmcp-broker` (this repo) and +a pinned version of `forgejo-mcp` — into a distroless static image. + +```bash +podman build \ + --build-arg BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --build-arg GIT_REVISION="$(git describe --always --dirty)" \ + --build-arg FORGEJO_MCP_VERSION="2.18.0" \ + -t ghcr.io/olemd/fjmcp-broker:latest \ + . +``` + +Pin `FORGEJO_MCP_VERSION` to a tag — `latest` would mean image +reproducibility depends on the upstream HEAD at build time. The broker +spawns this binary per session, so version drift is the operator's +problem to track. + +## 3. Configure the environment + +```bash +mkdir -p ~/.config +cp deploy/podman/fjmcp-broker.env.example ~/.config/fjmcp-broker.env +chmod 0600 ~/.config/fjmcp-broker.env +$EDITOR ~/.config/fjmcp-broker.env +``` + +Required values: + +| Variable | Purpose | +|---|---| +| `FJMCP_BROKER_PUBLIC_URL` | What clients see (e.g. `https://mcp.example.com`) | +| `FORGEJO_URL` | Upstream Forgejo instance | +| `FORGEJO_OAUTH_CLIENT_ID` | From step 1 | +| `FORGEJO_OAUTH_CLIENT_SECRET` | From step 1 | + +The broker derives discovery URLs (`/.well-known/...`) from +`FJMCP_BROKER_PUBLIC_URL`, never from the inbound `Host` header — set +this to exactly the hostname Caddy serves. + +## 4. Start under systemd via Quadlet + +```bash +mkdir -p ~/.config/containers/systemd +cp deploy/podman/fjmcp-broker.container ~/.config/containers/systemd/ + +systemctl --user daemon-reload +systemctl --user start fjmcp-broker +systemctl --user status fjmcp-broker +``` + +Quadlet creates the `fjmcp-state` named volume on first start. The +SQLite store and its WAL/SHM sidecars all live there. + +Smoke-test: + +```bash +curl -fsS http://127.0.0.1:8080/healthz | jq . +``` + +A healthy response includes `{"status":"ok","store":"ok",…}`. Wire +Caddy in front next; see `docs/deploy-caddy.md`. + +## 5. Persist state across image upgrades + +The point of the named volume is that you can replace the container +entirely and keep your data. To upgrade: + +```bash +# Pull / build the new image. +podman pull ghcr.io/olemd/fjmcp-broker:latest + +# Restart — Quadlet recreates the container against the new image. +systemctl --user restart fjmcp-broker +``` + +The `fjmcp-state` volume is detached from container lifecycle. Any +registered OAuth clients (`/oauth/register` results), issued tokens, +and refresh-token history survive. + +To verify mid-upgrade: + +```bash +# Before upgrade +podman exec fjmcp-broker /usr/local/bin/fjmcp-broker --version + +# Inspect the SQLite store directly (via a temporary container). +podman run --rm -v fjmcp-state:/data:ro \ + docker.io/library/alpine ls -l /data/ + +# After upgrade, confirm the same broker.db is in place. +``` + +The migration runner is idempotent — re-opening the same database is a +no-op for already-applied migrations, so a downgrade-then-upgrade +across compatible schema versions works too. + +## 6. Backups + +`fjmcp-state` contains user-mapped credentials. Treat it as sensitive: + +```bash +# Online backup using SQLite's .backup command. +podman exec fjmcp-broker /usr/local/bin/sqlite3 \ + /data/broker.db ".backup /data/backup.db" + +podman cp fjmcp-broker:/data/backup.db ./broker-$(date -u +%Y%m%d).db +``` + +Encrypt backups at rest. The broker stores Forgejo access tokens in +cleartext (it has to, to spawn subprocesses with them in env); a leaked +backup gives an attacker every user's upstream token until each +expires. + +## Troubleshooting + +**Healthz returns 503 with `store: "error: ..."`.** The SQLite file +isn't reachable. Check volume mount and permissions: + +```bash +podman exec fjmcp-broker ls -l /data +``` + +The container runs as uid 65532. If the volume was created with +different ownership (e.g. you bind-mounted a host directory), `podman +unshare chown -R 65532:65532 /path/to/host/dir`. + +**`/oauth/authorize` redirects to a wrong host.** The broker echoes +`FJMCP_BROKER_PUBLIC_URL` verbatim in OAuth metadata. Mismatched values +between the broker and Caddy show up as redirects to the wrong domain. + +**Container exits immediately with `config error`.** A required env +var is missing or empty. The broker lists every missing field in its +stderr: + +```bash +journalctl --user -u fjmcp-broker --since "5 minutes ago" +``` + +**Forgejo callback fails with 404.** The Forgejo OAuth application's +registered redirect URI doesn't match +`${FJMCP_BROKER_PUBLIC_URL}/oauth/callback`. Trailing slashes and +`http` vs `https` matter here.