diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b4760e0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +# Keep the build context to what `cargo build --locked -p bugwarden` reads. +# Nothing here copies README.md or LICENSE, so the crates/*/ symlinks to them +# land dangling in the build stage; cargo never reads `readme =`, so the build +# is unaffected. +target +.git +.github +docs +assets +examples +# Local run artifacts from the README/compose examples. No COPY reaches them, +# but a token file has no business in a build context. +bugwarden.env +policy.toml +bugzilla-key +audit/ diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1f94789..9066cdb 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -34,3 +34,11 @@ updates: update-types: - "minor" - "patch" + + # Base images in the Dockerfile. Both FROMs are digest-pinned, so without + # this they would never be updated at all. + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b95b467..82fe038 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -180,3 +180,53 @@ jobs: echo "$untracked" exit 1 fi + + changes: + # Gate for the docker job: a full image build on every PR would be the + # slowest leg in CI for changes that cannot affect it. + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + # This job's checkout is shallow, so paths-filter compares against the + # PR base through the API rather than with git. + pull-requests: read + outputs: + docker: ${{ steps.filter.outputs.docker }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 + id: filter + with: + filters: | + docker: + - 'Dockerfile' + - '.dockerignore' + - 'Cargo.toml' + - 'Cargo.lock' + - 'crates/**' + # The workflows themselves: without ci.yml a change to the + # docker job below cannot retrigger it. + - '.github/workflows/ci.yml' + - '.github/workflows/release.yml' + + docker: + # Build-only proof that the image still builds; release.yml owns the + # push. amd64 only — the arm64 leg needs a native runner and exists + # there. + needs: changes + if: needs.changes.outputs.docker == 'true' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + platforms: linux/amd64 + push: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2ff7b88..c44ca73 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -105,6 +105,110 @@ jobs: --generate-notes dist/* + container: + # Native runners per architecture: an emulated aws-lc-sys compile under + # QEMU takes tens of minutes, and ubuntu-24.04-arm makes it unnecessary. + # Sibling of `publish`, not upstream of it: a broken image build must not + # hold back the crates.io release of a tag whose binaries already shipped. + needs: release + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + permissions: + contents: read + # Push the per-architecture images to ghcr.io. + packages: write + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + platform: linux/amd64 + - os: ubuntu-24.04-arm + platform: linux/arm64 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Prepare + shell: bash + run: | + set -euo pipefail + platform="${{ matrix.platform }}" + echo "PLATFORM_PAIR=${platform//\//-}" >>"$GITHUB_ENV" + # Tags here are the bare version, no "v" prefix (see AGENTS.md), and + # the `build` job already refused any tag that disagrees with the + # workspace manifest. + echo "VERSION=$GITHUB_REF_NAME" >>"$GITHUB_ENV" + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push by digest + id: build + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + platforms: ${{ matrix.platform }} + build-args: VERSION=${{ env.VERSION }} + # No cache-from/cache-to: releases build hermetically, same rule as + # the cargo-cache-free `build` job above. + outputs: type=image,name=ghcr.io/plusky/bugwarden,push-by-digest=true,name-canonical=true,push=true + - name: Export digest + shell: bash + run: | + set -euo pipefail + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + container-manifest: + # The tags only ever appear once every architecture pushed its digest, so + # ghcr.io never serves a half-populated multi-arch tag. + needs: container + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + packages: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: /tmp/digests + pattern: digests-* + merge-multiple: true + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Create and push the multi-arch manifest + working-directory: /tmp/digests + shell: bash + run: | + set -euo pipefail + tags=(-t "ghcr.io/plusky/bugwarden:$GITHUB_REF_NAME") + # The tag glob admits pre-releases (0.2.0rc1); those must not + # become `latest`. + if [[ "$GITHUB_REF_NAME" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + tags+=(-t ghcr.io/plusky/bugwarden:latest) + fi + # shellcheck disable=SC2046 # splitting the digest list is intended + docker buildx imagetools create "${tags[@]}" \ + $(printf 'ghcr.io/plusky/bugwarden@sha256:%s ' *) + - name: Inspect the published manifest + shell: bash + run: | + set -euo pipefail + docker buildx imagetools inspect "ghcr.io/plusky/bugwarden:$GITHUB_REF_NAME" + publish: # crates.io last: a version can be yanked but never replaced, so it only # goes out once every platform built and the GitHub release exists. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..37ccc0e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# syntax=docker/dockerfile:1 + +# Pinned to the channel in rust-toolchain.toml, which this stage never COPYs — +# so a floating `rust:1` would silently build releases with an unpinned +# compiler. Bump this tag and rust-toolchain.toml together. +FROM rust:1.97.0-alpine@sha256:ec9c91e77119ce498cd1e87d96d77e0f75b2cee21655a29bc2bf75a51a2b20a4 AS build +# gcc and musl-dev are already in the base; aws-lc-sys (the rustls crypto +# provider) compiles C from source and needs cmake plus a make generator. +RUN apk add --no-cache cmake make +WORKDIR /app +COPY Cargo.toml Cargo.lock ./ +COPY crates ./crates +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/app/target \ + cargo build --release --locked -p bugwarden && \ + cp target/release/bugwarden /out + +# Both FROMs carry a digest for the same reason the workflows SHA-pin actions: +# the release's two architecture legs run on separate runners, and a tag +# republished between them would build each from different base contents. +FROM gcr.io/distroless/static-debian13:nonroot@sha256:f7f8f729987ad0fdf6b05eeeae94b26e6a0f613bdf46feea7fc40f7bd72953e6 +ARG VERSION=dev +LABEL org.opencontainers.image.source="https://github.com/plusky/bugwarden" \ + org.opencontainers.image.description="MCP server for Bugzilla with operator-controlled security guards" \ + org.opencontainers.image.licenses="Apache-2.0" \ + org.opencontainers.image.version="${VERSION}" +COPY --from=build /out /usr/local/bin/bugwarden +# The bare binary falls back to a built-in allow-all policy when --policy is +# absent; naming the mount point here makes the image refuse to start instead. +ENV MCP_TRANSPORT=http \ + MCP_HOST=0.0.0.0 \ + MCP_PORT=8000 \ + BUGWARDEN_POLICY=/etc/bugwarden/policy.toml +EXPOSE 8000 +USER nonroot:nonroot +ENTRYPOINT ["/usr/local/bin/bugwarden"] diff --git a/README.md b/README.md index ae32c93..d8a101a 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,15 @@ one is named via `--policy` / `BUGWARDEN_POLICY`, and an audit configuration only via `--audit-config` / `BUGWARDEN_AUDIT_CONFIG`, so installing the package does not by itself activate anything. +### Container image + +```bash +podman pull ghcr.io/plusky/bugwarden +``` + +A multi-architecture (amd64/arm64) image for HTTP-transport deployments — no +Rust toolchain needed. See [Container](#container) for the run contract. + ### crates.io (cargo) ```bash @@ -196,18 +205,11 @@ bugwarden validates the Bugzilla server's certificate against the **OS trust store**, not a bundled root set. A Bugzilla instance behind a corporate or internal CA works as soon as that CA is installed system-wide — no bugwarden-side configuration is needed. The corollary: an -environment with no CA bundle at all — a `scratch` or `distroless` image, -some minimal base images — fails every HTTPS request to Bugzilla with a TLS -handshake error. Install `ca-certificates` in the image, or mount the -host's bundle into it: - -```dockerfile -FROM debian:stable-slim -RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \ - && rm -rf /var/lib/apt/lists/* -COPY bugwarden /usr/local/bin/bugwarden -ENTRYPOINT ["/usr/local/bin/bugwarden"] -``` +environment with no CA bundle at all — a `scratch` image, some minimal base +images — fails every HTTPS request to Bugzilla with a TLS handshake error. +Install `ca-certificates` in the image, or mount the host's bundle into it. +The [published image](#container) needs neither: its +`gcr.io/distroless/static-debian13` base ships the `ca-certificates` bundle. `HTTPS_PROXY`, `HTTP_PROXY` and `NO_PROXY` from the environment are honored for outbound Bugzilla traffic. Every request to Bugzilla — authenticated @@ -413,6 +415,102 @@ MCP client configuration: } ``` +### Container + +`ghcr.io/plusky/bugwarden` runs the HTTP transport on a distroless base with +no shell, as `nonroot` (uid 65532), built from the [`Dockerfile`](Dockerfile) +in this repository. It presets `MCP_TRANSPORT=http`, `MCP_HOST=0.0.0.0`, +`MCP_PORT=8000` and `BUGWARDEN_POLICY=/etc/bugwarden/policy.toml`; everything +else comes from the environment variables in the +[CLI reference](#cli-reference) below. The bearer tokens are environment-only +by design, and `--insecure-no-auth` is the one setting with no environment +variable at all — see +[Authentication and scopes](#authentication-and-scopes), which applies here +unchanged. + +Mint the token once into a file — generating it inline would start a server +nobody can talk to — and hand the file to the runtime, so the value never +reaches argv or a shell history: + +```bash +umask 077 +printf 'BUGWARDEN_HTTP_TOKEN=%s\n' "$(openssl rand -hex 32)" > ./bugwarden.env +``` + +```bash +podman run --init --rm \ + --env-file ./bugwarden.env \ + -v "$PWD/policy.toml:/etc/bugwarden/policy.toml:ro" \ + -v "$PWD/bugzilla-key:/run/secrets/bugzilla-key:ro" \ + -e BUGZILLA_SERVER=https://bugzilla.opensuse.org \ + -e BUGZILLA_API_KEY_FILE=/run/secrets/bugzilla-key \ + -p 127.0.0.1:8000:8000 \ + ghcr.io/plusky/bugwarden +``` + +`docker run` takes the same arguments. Use `BUGWARDEN_HTTP_READ_TOKEN` +instead to hand out a read-scope credential. Drop `BUGZILLA_API_KEY_FILE` and +its mount to serve each client with the key it sends in the `ApiKey` header +instead (the two modes never fall back to each other — see +[server-held key mode](#server-held-key-mode-fleet-deployments)). Or use the +bundled [`compose.yaml`](compose.yaml), which reads the same `bugwarden.env` +and adds a read-only root filesystem, `cap_drop: ALL` and +`no-new-privileges`: + +```bash +docker compose up +``` + +Gotchas specific to the image: + +- **The policy mount is mandatory.** Run without `--policy` and the bare + binary falls back to a built-in allow-all policy; the image instead + presets `BUGWARDEN_POLICY`, so a missing mount is a startup error and the + container never binds a port. Mount it `:ro` — the guard reads it at + startup and nothing, least of all an MCP client, may reach it afterwards + (I1). Start from + [`examples/policy.toml`](examples/policy.toml), which is not baked into + the image precisely so it cannot be mistaken for a default. +- **The token is the container's, not the caller's.** Delivering it by + environment puts its custody in the runtime: anyone who can + `docker inspect` the container or read `/proc//environ` holds it, and + `--env-file` keeps it out of argv but not out of either of those. It + authenticates the deployment, so one token is shared by every client that + talks to this container; rotate by rewriting the file and restarting. +- **`0.0.0.0` is a container necessity, not a widening of trust.** The + binary's own default is `127.0.0.1`; the image widens it only because a + bind inside the container's network namespace is unreachable from + anywhere else. It grants no `Host` authority — that is + `MCP_ALLOWED_HOSTS` / `--allowed-hosts`, and without it any `Host` header + is served: + + ```sh + -e MCP_ALLOWED_HOSTS=mcp.example.org:8000 + ``` +- **The Bugzilla key is a mounted file.** `BUGZILLA_API_KEY_FILE` reads it + once at startup from a bind mount or a container secret, and nothing is + baked into the image. (Do not also set `BUGZILLA_API_KEY` here: alone over + http it is ignored with a warning — it never silently becomes a + server-held key — but alongside `BUGZILLA_API_KEY_FILE` the two are + mutually exclusive and the container exits at startup.) The mounted file + has to be readable by uid 65532 — a host-side `0600` file owned by your + account is not, so `chown 65532` it or run with `podman --userns=keep-id`. +- **The audit stream needs a persistent volume.** With + `BUGWARDEN_AUDIT_CONFIG` set, the guard writes JSONL files itself — it + exports nothing — so the directory in `path` must be a volume writable by + uid 65532. Without one the records live in the container's writable layer, + which `--rm` throws away; with one that uid 65532 cannot write, startup + fails outright, and over HTTP the default fail mode is `closed_all`, so a + directory that becomes unwritable later stops the server serving. +- **Use an init process.** bugwarden installs no `SIGTERM` handler, and PID 1 + does not get the default terminate action, so without `--init` (or + `init: true` in compose) `docker stop` waits out its full timeout and ends + in `SIGKILL`. + +There is no `HEALTHCHECK`: `/bin` and `/usr/bin` are empty in this base, so +there is no binary to run one with. Use a TCP check on the port, or an +authenticated `initialize` request from outside the container. + ### MCP protocol revisions bugwarden serves four revisions of the Model Context Protocol — diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..9794fd2 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,42 @@ +# ./bugwarden.env must exist or compose refuses to start. ./policy.toml and +# ./bugzilla-key must exist as FILES: docker creates a directory for a missing +# bind source and the container then fails to start on what looks like a parse +# error (podman refuses the mount outright). +services: + bugwarden: + image: ghcr.io/plusky/bugwarden:latest + # build: . + ports: + # Loopback only: the bearer token authenticates callers, it does not + # make the endpoint safe to expose to the internet. + - "127.0.0.1:8000:8000" + environment: + BUGZILLA_SERVER: https://bugzilla.example.org + # Server-held key mode; drop this to have each client send its own + # Bugzilla key in the ApiKey header instead. + BUGZILLA_API_KEY_FILE: /run/secrets/bugzilla-key + # BUGWARDEN_AUDIT_CONFIG: /etc/bugwarden/audit.toml + # Deny-by-default: without a token the server refuses to start. Kept in a + # file rather than the shell environment so the value stays out of argv. + env_file: + - ./bugwarden.env + volumes: + # The image presets BUGWARDEN_POLICY, so a missing mount is a startup + # error; read-only because the guard reads it once and nothing may + # reach it afterwards (I1). + - ./policy.toml:/etc/bugwarden/policy.toml:ro + # Must be readable by uid 65532: `chown 65532 ./bugzilla-key`. + - ./bugzilla-key:/run/secrets/bugzilla-key:ro + # Audit stream. A bind mount, not a named volume: docker creates those + # root:root 0755, which uid 65532 cannot write, and startup then fails. + # Run `mkdir -p ./audit && chown 65532:65532 ./audit` first. + # - ./audit.toml:/etc/bugwarden/audit.toml:ro + # - ./audit:/var/log/bugwarden + # PID 1 does not get SIGTERM's default terminate action and bugwarden + # installs no handler, so without an init `compose down` ends in SIGKILL. + init: true + read_only: true + cap_drop: + - ALL + security_opt: + - no-new-privileges:true diff --git a/docs/DESIGN.md b/docs/DESIGN.md index d8dbc97..cbd2259 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -577,13 +577,14 @@ Bugzilla instance behind a corporate or internal CA, and an accepted, operator-visible change from the previous release. The crypto provider is `aws-lc-rs`, reqwest 0.13's default (0.12 used `ring`); `aws-lc-sys` needs a C toolchain at build time, which the release workflow's `ubuntu-latest` and -`macos-latest` runners provide. `system-proxy` keeps `HTTPS_PROXY` / -`HTTP_PROXY` / `NO_PROXY` honored exactly as 0.12 did unconditionally — -without this feature the 0.13 default is to ignore them, which would be a -silent regression for the corporate deployments this server targets. `query` -is likewise required, not cosmetic: `apply_auth`'s `?api_key=` mode and -`quicksearch_syntax_html` both call `.query(...)`, a compile error without -the feature in 0.13. +`macos-latest` runners provide and which the `Dockerfile`'s alpine build stage +adds as `cmake` + `make` on top of the base image's own gcc. `system-proxy` +keeps `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` honored exactly as 0.12 did +unconditionally — without this feature the 0.13 default is to ignore them, +which would be a silent regression for the corporate deployments this server +targets. `query` is likewise required, not cosmetic: `apply_auth`'s +`?api_key=` mode and `quicksearch_syntax_html` both call `.query(...)`, a +compile error without the feature in 0.13. The operator cost of that switch (issue #65): a deployment with no OS trust store fails every HTTPS request at first contact with Bugzilla, where the @@ -591,16 +592,21 @@ previous release succeeded from bundled `webpki-roots`, and the symptom is a TLS handshake error that does not name the missing CA bundle — nothing in the error points at `ca-certificates`. The fix is the operator's to make: install `ca-certificates` in the image, or mount the host's bundle into it; -the project ships tarballs, not container images, so containerizing the -binary is a choice made downstream of this repo, without the person making -it necessarily knowing the trust-store dependency exists. The rejected -alternative is keeping the bundled Mozilla roots: rejected because a -distro-packaged tool has to follow the system CA bundle — an admin adding or -revoking a root must take effect without a bugwarden rebuild — and because -bundled roots cannot see the internal CA that the target Bugzilla +containerizing a tarball is a choice made downstream of this repo, without +the person making it necessarily knowing the trust-store dependency exists. +The rejected alternative is keeping the bundled Mozilla roots: rejected +because a distro-packaged tool has to follow the system CA bundle — an admin +adding or revoking a root must take effect without a bugwarden rebuild — and +because bundled roots cannot see the internal CA that the target Bugzilla deployments sit behind. Re-evaluate only if this project ever ships its own container image, where the bundle would be under our control instead of the -operator's. +operator's. That trigger fired: **RE-EVALUATED 2026-08-18, when this +repository started shipping its own container image.** The answer is +unchanged. The `gcr.io/distroless/static-debian13` base carries the +`ca-certificates` bundle, so the image works unconfigured, and an operator +behind an internal CA can still mount over the bundle's path — roots baked +into the binary would take that away and make the image the one deployment +shape that cannot follow its own trust store. **Caller identity on the wire (issue #55).** Every request carries a `User-Agent`, set once on the shared `reqwest::Client` so it reaches the @@ -1108,7 +1114,7 @@ clap derive `Cli`, with env fallbacks: | --api-key-file | BUGZILLA_API_KEY_FILE | — | file holding the key (container secret / systemd LoadCredential path); mutually exclusive with --api-key; over http selects server-held key mode (see Key custody) | | --use-auth-header | BUGZILLA_USE_AUTH_HEADER | false | Bearer to Bugzilla instead of api_key query param | | --read-only | MCP_READ_ONLY | false | tighten-only (I9) | -| --policy | BUGWARDEN_POLICY | — | path to guard policy TOML | +| --policy | BUGWARDEN_POLICY | — | path to guard policy TOML; the container image presets it to /etc/bugwarden/policy.toml, so that one artifact fails closed on a missing mount instead of defaulting to allow-all (tightening only, I9) | | --audit-config | BUGWARDEN_AUDIT_CONFIG | — | path to audit configuration TOML; without it no audit stream is written | | --insecure-no-auth | — (deliberately) | false | serve http with no bearer gate; refuses to start together with a token (see HTTP bearer authentication) | | — | BUGWARDEN_HTTP_TOKEN | — | bearer token, write scope; environment only, never a flag |