feat(deploy): rootless podman + Quadlet deployment (forgejo-mcp-broker-8yd)

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) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-04-27 17:42:09 +02:00
commit 018f56a4ad
7 changed files with 343 additions and 3 deletions

View file

@ -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}

73
Containerfile Normal file
View file

@ -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"]

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

169
docs/deploy-podman.md Normal file
View file

@ -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://<your-broker-hostname>/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.