From 7cc17813a94ddbcba89082067d923685bd42e6b1 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 15:18:48 +0100 Subject: [PATCH 1/2] p5-03: docker-only release path (drop goreleaser) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single public deliverable per tag: a multi-arch server image, with cross-compiled agent binaries + install scripts + the systemd unit baked under /opt/restic-manager/dist/. The /agent/binary and /install/* handlers fall back from /... to that read-only path so a fresh container Just Works without first-run staging; operators can still drop a custom build into / to override per-host. Architecture rationale: agent distribution already routes through the running server, so the release surface mirrors that — there's no second source of truth to keep in sync. Workflow .gitea/workflows/release.yml triggers on v*.*.* tag-push (fan-out :vX.Y.Z / :X.Y / :X, plus :latest once MAJOR>=1) and workflow_dispatch (snapshot tag only). Pushes to the Gitea container registry on this instance. Both binaries grow main.commit + main.date ldflag targets. Makefile and Dockerfile fill them; release workflow forwards from gitea.sha plus a UTC timestamp. Spec : docs/superpowers/specs/2026-05-05-p5-03-docker-only-release.md Plan : docs/superpowers/plans/2026-05-05-p5-03-docker-only-release.md --- .gitea/workflows/release.yml | 107 ++++++++ Makefile | 12 +- cmd/agent/main.go | 8 +- cmd/server/main.go | 8 +- deploy/Dockerfile.server | 53 +++- .../2026-05-05-p5-03-docker-only-release.md | 131 ++++++++++ .../2026-05-05-p5-03-docker-only-release.md | 229 ++++++++++++++++++ internal/server/config/config.go | 18 +- internal/server/http/agent_assets.go | 46 +++- internal/server/http/agent_assets_test.go | 167 +++++++++++++ tasks.md | 2 +- 11 files changed, 752 insertions(+), 29 deletions(-) create mode 100644 .gitea/workflows/release.yml create mode 100644 docs/superpowers/plans/2026-05-05-p5-03-docker-only-release.md create mode 100644 docs/superpowers/specs/2026-05-05-p5-03-docker-only-release.md create mode 100644 internal/server/http/agent_assets_test.go diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..3d838ab --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,107 @@ +# Release workflow — P5-03 (docker-only release path). +# +# Spec : docs/superpowers/specs/2026-05-05-p5-03-docker-only-release.md +# Plan : docs/superpowers/plans/2026-05-05-p5-03-docker-only-release.md +# +# What it does +# * Triggered by either: +# - tag push matching v[0-9]+.[0-9]+.[0-9]+ (real release), or +# - workflow_dispatch (snapshot iteration without tagging). +# * Cross-builds a multi-arch (linux/amd64,linux/arm64) image of the +# server, with three agent binaries (linux amd64+arm64, windows amd64) +# plus install.sh / install.ps1 / the systemd unit baked in under +# /opt/restic-manager/dist (the read-only fallback path the server +# handlers use when /... is empty). +# * Pushes to this Gitea instance's container registry under +# //restic-manager. +# +# Tag fan-out +# * tag push: :vX.Y.Z, :X.Y, :X +# * tag push and X >= 1: also :latest +# * workflow_dispatch: only :snapshot-; nothing else moves. +# +# Why no goreleaser +# The architecture already routes agent distribution through the +# server's /agent/binary endpoint. The image is the only deliverable; +# binary archives would just be a second source of truth. + +name: Release + +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' + workflow_dispatch: + +env: + REGISTRY: gitea.dcglab.co.uk + IMAGE_NAME: ${{ gitea.repository }} + +jobs: + image: + name: Build + push image + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + + - name: Log in to Gitea registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ gitea.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Compute tags + version + id: meta + shell: bash + run: | + set -euo pipefail + REG="${REGISTRY}/${IMAGE_NAME}" + DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + SHORT_SHA="${GITHUB_SHA::7}" + + if [ "${GITHUB_EVENT_NAME}" = "push" ] && [ "${GITHUB_REF_TYPE}" = "tag" ]; then + TAG="${GITHUB_REF_NAME}" # vX.Y.Z + VER="${TAG#v}" # X.Y.Z + MAJOR="${VER%%.*}" + MINOR="${VER#${MAJOR}.}"; MINOR="${MINOR%%.*}" + + TAGS="${REG}:${TAG}" + TAGS="${TAGS},${REG}:${MAJOR}.${MINOR}" + TAGS="${TAGS},${REG}:${MAJOR}" + # Pre-1.0 holds back :latest by design; operators must + # pin a version explicitly until v1.0.0. + if [ "${MAJOR}" -ge 1 ]; then + TAGS="${TAGS},${REG}:latest" + fi + VERSION="${TAG}" + else + TAGS="${REG}:snapshot-${SHORT_SHA}" + VERSION="0.0.0-snapshot-${SHORT_SHA}" + fi + + { + echo "tags=${TAGS}" + echo "version=${VERSION}" + echo "date=${DATE}" + } >> "${GITHUB_OUTPUT}" + + - name: Build + push + uses: docker/build-push-action@v6 + with: + context: . + file: deploy/Dockerfile.server + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + build-args: | + VERSION=${{ steps.meta.outputs.version }} + COMMIT=${{ gitea.sha }} + DATE=${{ steps.meta.outputs.date }} + labels: | + org.opencontainers.image.version=${{ steps.meta.outputs.version }} + org.opencontainers.image.revision=${{ gitea.sha }} + org.opencontainers.image.created=${{ steps.meta.outputs.date }} diff --git a/Makefile b/Makefile index b24798f..4a1c807 100644 --- a/Makefile +++ b/Makefile @@ -5,9 +5,11 @@ BIN_DIR := bin SERVER_BIN := $(BIN_DIR)/restic-manager-server AGENT_BIN := $(BIN_DIR)/restic-manager-agent VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) -LDFLAGS := -s -w -X main.version=$(VERSION) +COMMIT ?= $(shell git rev-parse HEAD 2>/dev/null || echo none) +DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) +LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE) GOFLAGS := -trimpath -DOCKER_IMAGE ?= ghcr.io/dcglab/restic-manager +DOCKER_IMAGE ?= gitea.dcglab.co.uk/steve/restic-manager DOCKER_TAG ?= dev # Tailwind standalone CLI — single binary, no Node toolchain. @@ -84,7 +86,11 @@ run-agent: agent ## Build and run the agent $(AGENT_BIN) docker: ## Build the server Docker image - docker build -f deploy/Dockerfile.server --build-arg VERSION=$(VERSION) -t $(DOCKER_IMAGE):$(DOCKER_TAG) . + docker build -f deploy/Dockerfile.server \ + --build-arg VERSION=$(VERSION) \ + --build-arg COMMIT=$(COMMIT) \ + --build-arg DATE=$(DATE) \ + -t $(DOCKER_IMAGE):$(DOCKER_TAG) . release: ## Cross-compile for all supported platforms @mkdir -p $(BIN_DIR) diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 123cb50..8e231bc 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -24,7 +24,11 @@ import ( "gitea.dcglab.co.uk/steve/restic-manager/internal/restic" ) -var version = "dev" +var ( + version = "dev" + commit = "none" + date = "unknown" +) func main() { if err := run(); err != nil { @@ -62,7 +66,7 @@ func run() error { flag.Parse() if *showVersion { - fmt.Println("restic-manager-agent", version) + fmt.Printf("restic-manager-agent %s (commit %s, built %s)\n", version, commit, date) return nil } diff --git a/cmd/server/main.go b/cmd/server/main.go index a97022d..8d52bb8 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -25,7 +25,11 @@ import ( "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) -var version = "dev" +var ( + version = "dev" + commit = "none" + date = "unknown" +) func main() { if err := run(); err != nil { @@ -40,7 +44,7 @@ func run() error { flag.Parse() if *showVersion { - fmt.Println("restic-manager-server", version) + fmt.Printf("restic-manager-server %s (commit %s, built %s)\n", version, commit, date) return nil } diff --git a/deploy/Dockerfile.server b/deploy/Dockerfile.server index 6cf3bee..5460ff8 100644 --- a/deploy/Dockerfile.server +++ b/deploy/Dockerfile.server @@ -1,14 +1,17 @@ # syntax=docker/dockerfile:1.7 # ---- Build stage -------------------------------------------------------- -FROM golang:1.25-alpine AS build +# Cross-compiles: +# * the server binary for the image's TARGETARCH (linux/amd64 or arm64), +# * three agent binaries (linux/amd64, linux/arm64, windows/amd64) that +# the running server hands out via /agent/binary. +# Pure-Go SQLite (modernc.org/sqlite) means CGO stays off; static binaries +# run on distroless/static. +FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS build WORKDIR /src -# Pure-Go SQLite (modernc.org/sqlite) means we can keep CGO off and build a -# fully static binary that runs on distroless/static. ENV CGO_ENABLED=0 \ - GOOS=linux \ GOFLAGS="-trimpath" # Cache module downloads in a separate layer. @@ -18,9 +21,34 @@ RUN go mod download COPY . . ARG VERSION=dev -RUN go build -ldflags="-s -w -X main.version=${VERSION}" \ - -o /out/restic-manager-server \ - ./cmd/server +ARG COMMIT=none +ARG DATE=unknown +ARG TARGETOS +ARG TARGETARCH + +ENV LDFLAGS="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" + +# Server: built for the image's runtime arch. +RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build -ldflags="${LDFLAGS}" \ + -o /out/restic-manager-server \ + ./cmd/server + +# Agents: identical across image arches — an arm64 server image still +# ships an amd64 agent binary for amd64 endpoints to download. +RUN mkdir -p /out/agent-binaries && \ + GOOS=linux GOARCH=amd64 \ + go build -ldflags="${LDFLAGS}" \ + -o /out/agent-binaries/restic-manager-agent-linux-amd64 \ + ./cmd/agent && \ + GOOS=linux GOARCH=arm64 \ + go build -ldflags="${LDFLAGS}" \ + -o /out/agent-binaries/restic-manager-agent-linux-arm64 \ + ./cmd/agent && \ + GOOS=windows GOARCH=amd64 \ + go build -ldflags="${LDFLAGS}" \ + -o /out/agent-binaries/restic-manager-agent-windows-amd64.exe \ + ./cmd/agent # ---- Runtime stage ------------------------------------------------------ FROM gcr.io/distroless/static-debian12:nonroot @@ -31,7 +59,18 @@ LABEL org.opencontainers.image.licenses="PolyForm-Noncommercial-1.0.0" USER nonroot:nonroot WORKDIR / +# Server binary on PATH. COPY --from=build /out/restic-manager-server /usr/local/bin/restic-manager-server +# Image-baked bundled assets (P5-03). Read-only; the /agent/binary and +# /install/* handlers fall back here when /... is empty, so a +# fresh container Just Works without first-run staging. Operators can +# still drop a custom build under /agent-binaries/ to +# override per-host. +COPY --from=build --chmod=0755 /out/agent-binaries/ /opt/restic-manager/dist/agent-binaries/ +COPY --chmod=0755 deploy/install/install.sh /opt/restic-manager/dist/install/install.sh +COPY --chmod=0644 deploy/install/install.ps1 /opt/restic-manager/dist/install/install.ps1 +COPY --chmod=0644 deploy/install/restic-manager-agent.service /opt/restic-manager/dist/install/restic-manager-agent.service + EXPOSE 8443 ENTRYPOINT ["/usr/local/bin/restic-manager-server"] diff --git a/docs/superpowers/plans/2026-05-05-p5-03-docker-only-release.md b/docs/superpowers/plans/2026-05-05-p5-03-docker-only-release.md new file mode 100644 index 0000000..891edce --- /dev/null +++ b/docs/superpowers/plans/2026-05-05-p5-03-docker-only-release.md @@ -0,0 +1,131 @@ +# P5-03 implementation plan — Docker-only release + +Spec: `docs/superpowers/specs/2026-05-05-p5-03-docker-only-release.md`. + +Branch: `p5-03-docker-release`. Do not auto-open a PR (see CLAUDE.md +memory: CI runs are expensive on the self-hosted cluster). + +--- + +## Slice 1 — Server config + handler fallback + +**Goal:** server can serve agent binaries / install scripts from a +read-only "bundled assets" path when `` doesn't have them. + +1. `internal/server/config/config.go` (or wherever `Cfg` lives) gains + a `BundledAssetsDir string` field, defaulting to + `/opt/restic-manager/dist`. Wire from `RM_BUNDLED_ASSETS_DIR` env + var, mirroring the existing env-var conventions. +2. `internal/server/http/agent_assets.go`: + - `handleAgentBinary`: try `/agent-binaries/` + first; on `os.Stat` ENOENT, try + `/agent-binaries/`; on second ENOENT, + existing 404. + - `handleInstallAsset`: same dual-path, with `install/` subpath. +3. Tests in `internal/server/http/agent_assets_test.go` (new file): + - DataDir hit serves DataDir bytes. + - DataDir miss + bundled hit serves bundled bytes. + - DataDir hit shadows bundled. + - Both miss → 404 + existing error envelope. + - Path-traversal still rejected for `install/*` (regression check). + +**Verify:** `go vet ./...` + `go test ./internal/server/http/...`. + +--- + +## Slice 2 — Version ldflags on both binaries + +1. `cmd/server/main.go`: keep `var version`, add + `var commit = "none"` and `var date = "unknown"`. Surface via + existing version-log line. +2. `cmd/agent/main.go`: same three vars. Agent already reports + `agent_version` in the WS hello — extend to include commit if + it's already plumbed through `internal/api`; otherwise leave the + commit out of the wire and just log it on startup. +3. `Makefile`: extend the `make build` `-ldflags` to set all three + from `git describe --tags --always` + `git rev-parse HEAD` + + UTC timestamp. Source-build users get real values, not "dev". +4. `deploy/Dockerfile.server`: add `ARG COMMIT=none` and + `ARG DATE=unknown`; pass through `-ldflags`. + +**Verify:** `make build && ./bin/restic-manager-server -version` +(or whatever the existing flag is) prints non-`dev` values. + +--- + +## Slice 3 — Dockerfile bakes agents + install assets + +1. Build stage cross-compiles three agents: + + ```dockerfile + RUN go build -trimpath -ldflags="-s -w \ + -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" \ + -o /out/agent/restic-manager-agent-linux-amd64 ./cmd/agent + ENV GOARCH=arm64 + RUN go build ... -o /out/agent/restic-manager-agent-linux-arm64 ./cmd/agent + ENV GOOS=windows GOARCH=amd64 + RUN go build ... -o /out/agent/restic-manager-agent-windows-amd64.exe ./cmd/agent + ``` + + (Reset `GOOS`/`GOARCH` between layers via `ENV`. Server build + stays at `GOOS=linux GOARCH=$TARGETARCH`.) + +2. Final stage `COPY --from=build`: + - `/out/restic-manager-server` → `/usr/local/bin/` + - `/out/agent/*` → `/opt/restic-manager/dist/agent-binaries/` + - `deploy/install/install.sh` → + `/opt/restic-manager/dist/install/install.sh` + - `deploy/install/install.ps1` → + `/opt/restic-manager/dist/install/install.ps1` + - `deploy/install/restic-manager-agent.service` → + `/opt/restic-manager/dist/install/restic-manager-agent.service` + +3. Set `--chmod=0755` on the agent binaries and `install.sh`, + `--chmod=0644` on the unit file and `install.ps1`. Distroless + final stage runs as `nonroot`; bundled assets are readable by + anyone (mode `o+r`), so the user switch doesn't break reads. + +**Verify:** +```sh +docker build -f deploy/Dockerfile.server -t rm:dev . +docker run --rm -d -p 18080:8080 \ + -e RM_LISTEN=:8080 -e RM_DATA_DIR=/data \ + -e RM_BASE_URL=http://127.0.0.1:18080 \ + -v rm-test:/data rm:dev +curl -fsSL "http://127.0.0.1:18080/agent/binary?os=linux&arch=amd64" | wc -c +curl -fsSL "http://127.0.0.1:18080/install/install.sh" | head -1 +``` + +Both should succeed against a fresh volume (no operator staging). + +--- + +## Slice 4 — Release workflow + +`.gitea/workflows/release.yml` per the spec. Two jobs: + +1. **`image`**: checkout → setup-qemu → setup-buildx → login → compute + tags → buildx build+push. +2. (Future) `release-notes`: stub left as a TODO comment for now. + Operator can hand-write release notes via the Gitea UI on first + cut. + +The `compute tags` shell step is the only non-trivial bit; tested +inline by running the script with mocked `GITHUB_REF_TYPE` / +`GITHUB_REF_NAME` env vars before committing. + +**Verify on first dispatch:** trigger `workflow_dispatch` from the +Gitea UI, check the runner produces `:snapshot-` and pushes +multi-arch. + +--- + +## Slice 5 — Tasks.md + commit + push + +1. `tasks.md`: tick P5-03; add a one-line note that goreleaser was + dropped in favour of Docker-only after a 2026-05-05 design pass + (link the spec). +2. `git add -A && git commit -m "p5-03: docker-only release path"` + (no Co-Authored-By trailer — CLAUDE.md rule). +3. `git push -u origin p5-03-docker-release`. +4. **Stop.** Do not open a PR. Wait for operator review. diff --git a/docs/superpowers/specs/2026-05-05-p5-03-docker-only-release.md b/docs/superpowers/specs/2026-05-05-p5-03-docker-only-release.md new file mode 100644 index 0000000..bee4d61 --- /dev/null +++ b/docs/superpowers/specs/2026-05-05-p5-03-docker-only-release.md @@ -0,0 +1,229 @@ +# P5-03 — Docker-only release path + +**Status:** approved 2026-05-05. Pivots P5-03 away from `goreleaser` + +binary archives toward a single Docker image as the only public +deliverable. + +## Goal + +One artifact per tag: the `restic-manager` server image, multi-arch +(linux amd64 + arm64), published to the Gitea container registry of +this self-hosted instance. The image bakes in cross-compiled agent +binaries (linux amd64, linux arm64, windows amd64), the install +scripts, and the systemd unit at a read-only image path. The running +server distributes those agents and scripts via its existing +`/agent/binary` and `/install/*` endpoints; operators on N hosts never +download a release artifact directly. + +Source builds via `make build` remain a first-class path for anyone +who wants binaries. + +## Non-goals + +- Standalone binary archives (`.tar.gz`, `.zip`) on the release page. +- darwin / windows-arm64 agent targets — neither is service-tested. +- `goreleaser`. Not used. +- `cosign`, `SBOM`, `in-toto`, `minisign`. Re-promote when we ship + binaries outside an image (Phase 6 candidate). +- GHCR / GitHub mirror. Single source of truth = Gitea. + +## Decisions captured (with one-line rationale) + +| ID | Decision | Why | +|----|----------|-----| +| D1 | One artifact: server Docker image | Architecture already routes agent distribution through the server (`/agent/binary`); release surface should mirror that. | +| D2 | Trigger: `tag-push` (`v*.*.*`) **plus** `workflow_dispatch` | Tag for real cuts; dispatch for snapshot iteration without polluting tag history. | +| D3 | Build matrix: linux amd64+arm64 server image; agent cross-compiles for linux amd64+arm64+windows amd64 | Mirrors the existing CI build matrix; nothing ships that hasn't been service-tested. | +| D4 | Image-baked, separate path (`/opt/restic-manager/dist/`); HTTP handler reads `/...` first, falls back to `/opt/...` | Volume stays purely operator state; image content is immutable per tag; eliminates the smoke-env "stale agent" footgun in production. | +| D5 | Tag fan-out: `vX.Y.Z`, `X.Y`, `X`, `latest` — but `latest` is held back until `v1.0.0` | Standard rolling-minor pattern; pre-1.0 forces explicit pinning. | +| D6 | Snapshot tag: `:snapshot-`, never moves `latest` | Operator can never accidentally pull an unblessed build. | +| D7 | Version embedding via `-ldflags`: `main.version`, `main.commit`, `main.date` on both `cmd/server` and `cmd/agent` | Server already had `version`; add `commit`/`date` to both for parity and traceability. | +| D8 | Registry: Gitea container registry on this instance, under `//restic-manager` | One source of truth, no external creds. | +| D9 | Integrity: a `SHA256SUMS` file + the manifest digest in the release notes; nothing else | Image is the unit of trust; pull-by-digest is the verification primitive. | +| D10 | P1-31 (signed binaries) stays deferred | Re-promote the day we ship binaries outside an image. | + +## Image layout + +Multi-stage Dockerfile (extends today's `deploy/Dockerfile.server`): + +``` +build stage (golang:1.25-alpine): + cross-compile cmd/server for $TARGETARCH (linux) + cross-compile cmd/agent for linux/amd64 + cross-compile cmd/agent for linux/arm64 + cross-compile cmd/agent for windows/amd64 + (CGO_ENABLED=0 throughout — pure-Go SQLite) + +final stage (gcr.io/distroless/static-debian12:nonroot): + /usr/local/bin/restic-manager-server (matches image arch) + /opt/restic-manager/dist/agent-binaries/ + restic-manager-agent-linux-amd64 + restic-manager-agent-linux-arm64 + restic-manager-agent-windows-amd64.exe + /opt/restic-manager/dist/install/ + install.sh + install.ps1 + restic-manager-agent.service +``` + +`/opt/restic-manager/dist/` is owned by `root:root`, mode `0755` for +directories, `0755` for `install.sh` (script must be executable when +the install path uses `curl ... | sh` semantics) and `0644` for the +unit file and `install.ps1`. The agent binaries are mode `0755`. + +`` keeps holding only operator state: `restic-manager.db`, +`secret.key`, `secrets.enc`, `audit/`, `tls/`. Nothing the image +owns gets written into the volume. + +## Server-side handler change + +`internal/server/http/agent_assets.go` today reads from +`/agent-binaries/` and `/install/`. + +Change: if the file isn't present in ``, fall back to +`/opt/restic-manager/dist//`. The fallback path is a +new server-config field defaulted to `/opt/restic-manager/dist`, +overridable via `RM_BUNDLED_ASSETS_DIR` for tests and source-build +deployments. If neither path resolves, return 404 (existing +`binary_not_published` / `not_found` body unchanged). + +This means: +- A fresh container without any operator-staged overrides serves the + baked-in agents. No first-run setup needed. +- An operator can still drop a custom-built agent into + `/agent-binaries/` to override the image's copy (handy for + pre-release agent testing without rebuilding the server image). +- Source-build dev (`bin/restic-manager-server` running out of the + working tree) still works exactly as today — the fallback dir is + configurable, and the `` path remains the primary lookup. + +Tests cover four cases: (a) DataDir hit, (b) fallback hit, (c) DataDir +hit shadows fallback, (d) neither — 404. + +## Versioning + +Both binaries grow `commit` and `date` ldflag-targets next to the +existing `version`: + +```go +var ( + version = "dev" + commit = "none" + date = "unknown" +) +``` + +Dockerfile gains `ARG VERSION`, `ARG COMMIT`, `ARG DATE`, all +`""`-defaulted; the `go build` line passes them via `-ldflags`. The +release workflow fills them from `${{ gitea.ref_name }}`, +`${{ gitea.sha }}`, and a UTC ISO-8601 timestamp. + +Snapshot builds (workflow_dispatch) compute +`VERSION=0.0.0-snapshot-${SHORTSHA}` and tag the image as +`:snapshot-${SHORTSHA}` only. They never touch `latest` or any +`vX.Y.Z` tag. + +## Workflow (`.gitea/workflows/release.yml`) + +```yaml +name: Release + +on: + push: + tags: ['v[0-9]+.[0-9]+.[0-9]+'] + workflow_dispatch: + +env: + IMAGE: gitea.dcglab.co.uk/${{ gitea.repository }} + +jobs: + image: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: gitea.dcglab.co.uk + username: ${{ gitea.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: compute tags + id: meta + run: | + # tag-push → :vX.Y.Z, :X.Y, :X (only :latest if X >= 1) + # dispatch → :snapshot- + ... + - uses: docker/build-push-action@v6 + with: + context: . + file: deploy/Dockerfile.server + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + build-args: | + VERSION=${{ steps.meta.outputs.version }} + COMMIT=${{ gitea.sha }} + DATE=${{ steps.meta.outputs.date }} +``` + +The `compute tags` step: + +- For `push:tags`: extract `vMAJOR.MINOR.PATCH`. Always emit + `:vMAJOR.MINOR.PATCH`, `:MAJOR.MINOR`, `:MAJOR`. Emit `:latest` + only when `MAJOR >= 1`. +- For `workflow_dispatch`: emit `:snapshot-`. Nothing else. + +No release-asset upload step yet — the GHCR-equivalent registry push +is the deliverable. A future iteration may attach a `SHA256SUMS` file +to a Gitea release object once `tea release create` is wired in; +that's not in scope for the first cut. + +## Tests / verification + +1. `go vet ./...` (CLAUDE.md rule, runs locally pre-commit). +2. `go test ./internal/server/http/...` covers the new fallback + logic. +3. Local manual smoke: `docker build -f deploy/Dockerfile.server .` + produces an image; `docker run --rm ` starts the server; + `curl http://127.0.0.1:8080/agent/binary?os=linux&arch=amd64` + serves bytes; `curl http://127.0.0.1:8080/install/install.sh` + serves the script. +4. Release workflow itself is exercised on first tag-push; until + then, `workflow_dispatch` is the smoke test. + +## Operator-facing changes + +- `README.md` install snippet becomes + `docker run -v rm-data:/var/lib/restic-manager ... + gitea.dcglab.co.uk//restic-manager:vX.Y.Z`. Pre-1.0 + releases are pinned by exact tag; no `:latest` is published. +- The CLAUDE.md "restage" block is dev-only (smoke env runs the + server out of `bin/`). Production users on the image never see + it. +- `RM_BUNDLED_ASSETS_DIR` is documented in the server config + reference (defaults to `/opt/restic-manager/dist`). + +## Risks / footguns + +- **Image size growth.** Three agent binaries (~15-20 MB each + stripped) add ~50 MB. Acceptable; we're already shipping a + distroless server. Watch the trajectory once Phase 4 alerting is + in. +- **Dockerfile cross-compile multiplies build time** on the runner. + Pure-Go means each leg is just a `go build`; total stage time + should stay under 60s on the self-hosted runner. +- **`ARG VERSION` leakage.** The current Dockerfile already accepts + `ARG VERSION=dev`; we're tightening, not loosening. +- **Operator overriding `/agent-binaries/`** with a + stale binary will silently shadow the image's copy. Documented in + the server config reference; this is a feature (lets operators + hot-patch a pre-release agent) not a bug. + +## Out of scope (tracked for follow-up) + +- Cosign / SBOM / in-toto provenance — defer to Phase 6 with the rest + of the supply-chain hardening. +- GHCR mirror — defer until P5-01 docs site goes public. +- `tea release create` integration — pending until we have something + worth attaching beyond the image digest. diff --git a/internal/server/config/config.go b/internal/server/config/config.go index 3106775..ffb6363 100644 --- a/internal/server/config/config.go +++ b/internal/server/config/config.go @@ -33,6 +33,14 @@ type Config struct { CookieSecure bool `yaml:"cookie_secure"` OIDCRaw *OIDCConfig `yaml:"oidc"` OIDC *OIDCConfig `yaml:"-"` + + // BundledAssetsDir is the read-only path inside the image that + // holds agent binaries (under agent-binaries/) and install + // scripts (under install/). The /agent/binary and /install/* + // handlers fall back here when the file is not present in + // DataDir. Source-build deployments can override via + // RM_BUNDLED_ASSETS_DIR. + BundledAssetsDir string `yaml:"bundled_assets_dir"` } // Load resolves config in this order: @@ -44,9 +52,10 @@ type Config struct { // safe to start. func Load(yamlPath string) (Config, error) { c := Config{ - Listen: ":8080", - DataDir: "/data", - CookieSecure: true, + Listen: ":8080", + DataDir: "/data", + CookieSecure: true, + BundledAssetsDir: "/opt/restic-manager/dist", } if yamlPath != "" { @@ -81,6 +90,9 @@ func Load(yamlPath string) (Config, error) { c.CookieSecure = true } } + if v, ok := os.LookupEnv("RM_BUNDLED_ASSETS_DIR"); ok { + c.BundledAssetsDir = v + } if v, ok := os.LookupEnv("RM_TRUSTED_PROXY"); ok { // Comma-separated CIDRs; allow whitespace for readability. parts := strings.Split(v, ",") diff --git a/internal/server/http/agent_assets.go b/internal/server/http/agent_assets.go index 2efbd8e..3ada1d5 100644 --- a/internal/server/http/agent_assets.go +++ b/internal/server/http/agent_assets.go @@ -11,19 +11,23 @@ import ( ) // agent_assets.go serves the agent binary (one per OS/arch) and the -// install scripts. The binaries live under /agent-binaries/, -// laid down by the release pipeline (or copied by hand for now). -// The install scripts live in /install/ alongside the -// systemd unit. +// install scripts. Lookup is dual-path: +// +// 1. /agent-binaries/ (or /install/) — +// operator-managed override; lets the operator hot-patch a +// pre-release agent without rebuilding the server image. +// 2. /agent-binaries/ — read-only, baked +// into the server image at build time (P5-03). This is what +// makes a fresh container Just Work without first-run staging. // // Both endpoints are intentionally unauthenticated: the install // payload is unprivileged on its own — it's the one-time enrollment // token that grants access. Anyone can pull the binary; only // someone with a valid token can use it productively. // -// P1-31: signed-binary verification is deferred. Today we serve -// whatever the operator dropped on disk. Future work bumps this to -// minisign/cosign signed bundles. +// P1-31: signed-binary verification is deferred. The image is the +// unit of trust; pull-by-digest is the verification primitive. +// Future work bumps standalone-binary delivery to minisign/cosign. // installAssetsRoutes adds /agent/binary and /install/* to r. func (s *Server) handleAgentBinary(w stdhttp.ResponseWriter, r *stdhttp.Request) { @@ -45,8 +49,8 @@ func (s *Server) handleAgentBinary(w stdhttp.ResponseWriter, r *stdhttp.Request) ext = ".exe" } name := fmt.Sprintf("restic-manager-agent-%s-%s%s", osTag, archTag, ext) - path := filepath.Join(s.deps.Cfg.DataDir, "agent-binaries", name) - if _, err := os.Stat(path); err != nil { + path, ok := s.resolveBundledAsset("agent-binaries", name) + if !ok { writeJSONError(w, stdhttp.StatusNotFound, "binary_not_published", fmt.Sprintf("agent binary for %s/%s not published on this server", osTag, archTag)) return @@ -64,14 +68,34 @@ func (s *Server) handleInstallAsset(w stdhttp.ResponseWriter, r *stdhttp.Request writeJSONError(w, stdhttp.StatusBadRequest, "bad_path", "") return } - path := filepath.Join(s.deps.Cfg.DataDir, "install", rel) - if _, err := os.Stat(path); err != nil { + path, ok := s.resolveBundledAsset("install", rel) + if !ok { writeJSONError(w, stdhttp.StatusNotFound, "not_found", "") return } stdhttp.ServeFile(w, r, path) } +// resolveBundledAsset looks up an asset by (subdir, name). DataDir +// wins so an operator can override the image-baked copy by dropping +// a file into //. If neither path resolves, +// returns ("", false). +func (s *Server) resolveBundledAsset(subdir, name string) (string, bool) { + candidates := []string{ + filepath.Join(s.deps.Cfg.DataDir, subdir, name), + } + if s.deps.Cfg.BundledAssetsDir != "" { + candidates = append(candidates, + filepath.Join(s.deps.Cfg.BundledAssetsDir, subdir, name)) + } + for _, p := range candidates { + if _, err := os.Stat(p); err == nil { + return p, true + } + } + return "", false +} + func validOS(s string) bool { switch api.HostOS(s) { case api.OSLinux, api.OSWindows: diff --git a/internal/server/http/agent_assets_test.go b/internal/server/http/agent_assets_test.go new file mode 100644 index 0000000..c9b022b --- /dev/null +++ b/internal/server/http/agent_assets_test.go @@ -0,0 +1,167 @@ +package http + +import ( + "context" + "io" + stdhttp "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/crypto" + "gitea.dcglab.co.uk/steve/restic-manager/internal/server/config" + "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws" + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +// newAssetsTestServer is a minimal scaffold for the /agent/binary and +// /install/* handlers. Two roots: one acts as DataDir, the other as +// the image-baked BundledAssetsDir. Either or both may be empty. +func newAssetsTestServer(t *testing.T, populate func(dataDir, bundleDir string)) string { + t.Helper() + root := t.TempDir() + dataDir := filepath.Join(root, "data") + bundleDir := filepath.Join(root, "dist") + for _, d := range []string{ + filepath.Join(dataDir, "agent-binaries"), + filepath.Join(dataDir, "install"), + filepath.Join(bundleDir, "agent-binaries"), + filepath.Join(bundleDir, "install"), + } { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + } + if populate != nil { + populate(dataDir, bundleDir) + } + + st, err := store.Open(context.Background(), filepath.Join(root, "rm.db")) + if err != nil { + t.Fatalf("store: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + + keyPath := filepath.Join(root, "secret.key") + _ = crypto.GenerateKeyFile(keyPath) + key, _ := crypto.LoadKeyFromFile(keyPath) + aead, _ := crypto.NewAEAD(key) + + deps := Deps{ + Cfg: config.Config{ + Listen: ":0", + DataDir: dataDir, + SecretKeyFile: keyPath, + BundledAssetsDir: bundleDir, + }, + Store: st, + AEAD: aead, + Hub: ws.NewHub(), + BootstrapToken: "test-token", + } + s := New(deps) + ts := httptest.NewServer(s.srv.Handler) + t.Cleanup(ts.Close) + return ts.URL +} + +func writeFile(t *testing.T, path string, body []byte) { + t.Helper() + if err := os.WriteFile(path, body, 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func get(t *testing.T, url string) (int, []byte) { + t.Helper() + res, err := stdhttp.Get(url) + if err != nil { + t.Fatalf("GET %s: %v", url, err) + } + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + return res.StatusCode, body +} + +func TestAgentBinary_DataDirHit(t *testing.T) { + t.Parallel() + url := newAssetsTestServer(t, func(dataDir, _ string) { + writeFile(t, filepath.Join(dataDir, "agent-binaries", "restic-manager-agent-linux-amd64"), + []byte("from-datadir")) + }) + code, body := get(t, url+"/agent/binary?os=linux&arch=amd64") + if code != 200 || string(body) != "from-datadir" { + t.Fatalf("got %d %q", code, string(body)) + } +} + +func TestAgentBinary_BundleFallback(t *testing.T) { + t.Parallel() + url := newAssetsTestServer(t, func(_, bundleDir string) { + writeFile(t, filepath.Join(bundleDir, "agent-binaries", "restic-manager-agent-linux-amd64"), + []byte("from-bundle")) + }) + code, body := get(t, url+"/agent/binary?os=linux&arch=amd64") + if code != 200 || string(body) != "from-bundle" { + t.Fatalf("got %d %q", code, string(body)) + } +} + +func TestAgentBinary_DataDirShadowsBundle(t *testing.T) { + t.Parallel() + url := newAssetsTestServer(t, func(dataDir, bundleDir string) { + writeFile(t, filepath.Join(dataDir, "agent-binaries", "restic-manager-agent-linux-amd64"), + []byte("from-datadir")) + writeFile(t, filepath.Join(bundleDir, "agent-binaries", "restic-manager-agent-linux-amd64"), + []byte("from-bundle")) + }) + code, body := get(t, url+"/agent/binary?os=linux&arch=amd64") + if code != 200 || string(body) != "from-datadir" { + t.Fatalf("operator override should win: got %d %q", code, string(body)) + } +} + +func TestAgentBinary_BothMiss(t *testing.T) { + t.Parallel() + url := newAssetsTestServer(t, nil) + code, _ := get(t, url+"/agent/binary?os=linux&arch=amd64") + if code != 404 { + t.Fatalf("expected 404, got %d", code) + } +} + +func TestAgentBinary_WindowsNameHasExe(t *testing.T) { + t.Parallel() + url := newAssetsTestServer(t, func(_, bundleDir string) { + writeFile(t, filepath.Join(bundleDir, "agent-binaries", "restic-manager-agent-windows-amd64.exe"), + []byte("win")) + }) + code, body := get(t, url+"/agent/binary?os=windows&arch=amd64") + if code != 200 || string(body) != "win" { + t.Fatalf("got %d %q", code, string(body)) + } +} + +func TestInstallAsset_BundleFallback(t *testing.T) { + t.Parallel() + url := newAssetsTestServer(t, func(_, bundleDir string) { + writeFile(t, filepath.Join(bundleDir, "install", "install.sh"), []byte("#!/bin/sh\n")) + }) + code, body := get(t, url+"/install/install.sh") + if code != 200 || string(body) != "#!/bin/sh\n" { + t.Fatalf("got %d %q", code, string(body)) + } +} + +func TestInstallAsset_PathTraversalRejected(t *testing.T) { + t.Parallel() + url := newAssetsTestServer(t, nil) + // chi will normalise some traversal attempts, but the handler + // also rejects any rel containing a slash or backslash. The + // path component of the URL after /install/ is the rel. + code, _ := get(t, url+"/install/..%2fpasswd") + if code == 200 { + t.Fatalf("traversal should not return 200") + } +} diff --git a/tasks.md b/tasks.md index fc2b110..ab64d50 100644 --- a/tasks.md +++ b/tasks.md @@ -328,7 +328,7 @@ Sizes: **S** = under a day, **M** = 1–3 days, **L** = 3–7 days. - [ ] **P5-01** (M) Documentation site (mdBook or similar) with install, concepts, security model, screenshots - [ ] **P5-02** (S) `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, issue + PR templates -- [ ] **P5-03** (S) Release automation: `goreleaser` for binaries + Docker image to GHCR +- [x] **P5-03** (S) Release automation — **pivoted away from goreleaser/binary archives** on 2026-05-05 (spec: `docs/superpowers/specs/2026-05-05-p5-03-docker-only-release.md`). Single deliverable per tag: a multi-arch (linux amd64+arm64) server image, with cross-compiled agent binaries (linux amd64+arm64, windows amd64) + `install.sh` + `install.ps1` + the systemd unit baked under `/opt/restic-manager/dist/`. The `/agent/binary` and `/install/*` handlers fall back from `/...` to `/...` so a fresh container Just Works. Workflow `.gitea/workflows/release.yml` triggers on `v*.*.*` tag-push (real release: fan-out `:vX.Y.Z`, `:X.Y`, `:X`, plus `:latest` once `MAJOR>=1`) and `workflow_dispatch` (snapshot: `:snapshot-` only). Pushed to the Gitea container registry on this instance — no external creds, no GHCR mirror. Cosign / SBOM / minisign / GHCR mirror deferred to Phase 6. Source builds via `make build` remain a first-class path. - [ ] **P5-04** (S) Demo screenshots / short Loom walkthrough in README - [ ] **P5-05** (S) `SECURITY.md` with disclosure process - [ ] **P5-06** (M) End-to-end test suite in CI (Playwright vs. compose stack with sibling Linux agent) -- 2.52.0 From d6f6d19bff8aa60de5cc09504aa7eab8f587b71a Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 17:15:00 +0100 Subject: [PATCH 2/2] p5-07: reference deployment (server-only compose + reverse-proxy docs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reverse proxy is assumed to live outside this project (Caddy, nginx, Traefik, whatever the operator already runs). The reference compose stands up only the server: image-pinned via RM_VERSION, named volume for operator state, localhost-bound so the proxy reaches it on loopback. docs/reverse-proxy.md covers what the proxy must forward — the X-Forwarded-* headers, Host, and Connection: upgrade for the agent WebSocket and live-log streams — plus the RM_TRUSTED_PROXY CIDR rule that gates header trust. Worked examples for Caddy, nginx (with the websocket upgrade map + 1h proxy_read_timeout for live logs), and Traefik. --- deploy/docker-compose.yml | 49 ++++++++++++++--- docs/reverse-proxy.md | 113 ++++++++++++++++++++++++++++++++++++++ tasks.md | 2 +- 3 files changed, 154 insertions(+), 10 deletions(-) create mode 100644 docs/reverse-proxy.md diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index a0a2dcc..3366ab1 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -1,21 +1,52 @@ # Reference deployment for the restic-manager control plane. -# Mirrors spec.md §10.1. Adjust image tag and RM_BASE_URL for your env. +# Mirrors spec.md §10.1 and the P5-07 reference deployment. # -# The server speaks plain HTTP. Front it with a TLS-terminating -# reverse proxy (Caddy/Traefik/nginx). RM_TRUSTED_PROXY must contain -# the proxy's IP/CIDR so X-Forwarded-* headers are honoured. +# Scope: this compose stands up the server only. TLS termination and +# the public hostname belong to a reverse proxy that lives outside +# this stack (Caddy, Traefik, nginx, HAProxy, your existing edge — +# whatever you already operate). See `docs/reverse-proxy.md` for the +# headers + CIDRs that proxy needs to forward. +# +# Architecture: +# * The server speaks plain HTTP on :8080. +# * The agent binaries + install scripts ship inside the image under +# /opt/restic-manager/dist/, so /agent/binary and /install/* +# serve out of the box without first-run staging. +# * The named volume holds *only* operator state (sqlite, +# secrets.enc, audit log, the AEAD key). Image upgrades replace +# the agents/scripts; the volume is untouched. +# * Pre-1.0 releases never publish :latest — pin to an exact +# vX.Y.Z tag and bump deliberately. +# +# Before first start: +# 1. Pick a version: export RM_VERSION=vX.Y.Z (or substitute below). +# 2. Set RM_BASE_URL to the public HTTPS URL the external proxy +# serves on. +# 3. Set RM_TRUSTED_PROXY to the IP/CIDR the proxy connects from +# (the X-Forwarded-* headers are honoured only when the immediate +# peer matches one of these). + services: restic-manager: - image: ghcr.io/dcglab/restic-manager:latest + image: gitea.dcglab.co.uk/steve/restic-manager:${RM_VERSION:?set RM_VERSION to a vX.Y.Z tag} restart: unless-stopped - # Bind to localhost only — the proxy is what the public reaches. + # Bind to localhost only — your reverse proxy reaches the server + # over loopback (or, if it runs in a separate compose / on + # another host, swap this for an internal docker network or a + # private LAN bind). ports: - "127.0.0.1:8080:8080" volumes: - - ./data:/data + - rm-data:/data environment: - RM_DATA_DIR=/data - RM_LISTEN=:8080 - - RM_BASE_URL=https://restic.lab.example + - RM_BASE_URL=${RM_BASE_URL:?set RM_BASE_URL to the public https URL} - RM_SECRET_KEY_FILE=/data/secret.key - - RM_TRUSTED_PROXY=172.16.0.0/12 + - RM_TRUSTED_PROXY=${RM_TRUSTED_PROXY:?set RM_TRUSTED_PROXY to the proxy CIDR} + # Cookies are Secure by default; keep that. Override only for + # local-HTTP smoke tests. + # - RM_COOKIE_SECURE=true + +volumes: + rm-data: diff --git a/docs/reverse-proxy.md b/docs/reverse-proxy.md new file mode 100644 index 0000000..12f8c35 --- /dev/null +++ b/docs/reverse-proxy.md @@ -0,0 +1,113 @@ +# Running behind a reverse proxy + +The restic-manager server is HTTP-only by design (see `spec.md` §11): +TLS termination, public hostname, ACME, HSTS, and edge-level rate +limiting all belong to a reverse proxy that you already operate +outside this project. The reference compose in `deploy/docker-compose.yml` +stands up *only* the server; this page covers what your proxy needs +to do to make the rest of it work. + +## What the proxy must forward + +The server reads four headers when (and only when) the immediate peer +matches `RM_TRUSTED_PROXY`: + +| Header | Value | Why | +|---------------------|----------------------------------------------------------|-----| +| `X-Forwarded-For` | The original client IP (single value, or comma chain) | Rate-limit keys, audit log entries, and OIDC redirect-URI checks all use the real client IP. | +| `X-Forwarded-Proto` | `https` | The server emits absolute URLs (e.g. OIDC redirect URIs) using this. | +| `Host` | The public hostname clients use | Cookies are scoped to this; `RM_BASE_URL` must match. | +| `Connection`/`Upgrade` | Pass through unchanged | The agent connects on `/ws/agent` and the live-log viewer connects on `/api/jobs/{id}/stream` — both are WebSockets and need `Upgrade: websocket` to survive the hop. | + +Set `RM_TRUSTED_PROXY` to the CIDR (or comma-separated list of CIDRs) +the proxy connects from. Anything outside that range has its +`X-Forwarded-*` headers ignored, so a stray request that bypasses the +proxy can't spoof the client IP. + +## Example: Caddy + +```caddyfile +restic.example.com { + # Caddy's default reverse_proxy preserves Host, sets + # X-Forwarded-For/Proto, and passes Connection: upgrade through, + # so a single directive covers HTTP + WebSocket. + reverse_proxy 127.0.0.1:8080 + + encode zstd gzip +} +``` + +`RM_TRUSTED_PROXY=127.0.0.1/32` if Caddy and the server share the +host; the docker-bridge CIDR (commonly `172.16.0.0/12`) if Caddy +runs in another container on the default bridge network. + +## Example: nginx + +```nginx +server { + listen 443 ssl http2; + server_name restic.example.com; + + ssl_certificate /etc/ssl/restic.example.com.fullchain.pem; + ssl_certificate_key /etc/ssl/restic.example.com.key.pem; + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + + # WebSocket support — agent + live-log endpoints need this. + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # Trusted-proxy headers. + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + + # Live job logs are long-running streams. Bump read timeouts + # so nginx doesn't drop them mid-backup. + proxy_read_timeout 1h; + proxy_send_timeout 1h; + } +} + +# Standard websocket upgrade map (define once at the http {} level). +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} +``` + +`RM_TRUSTED_PROXY` for the same-host case: `127.0.0.1/32`. + +## Example: Traefik (label-based) + +```yaml +labels: + - "traefik.enable=true" + - "traefik.http.routers.restic-manager.rule=Host(`restic.example.com`)" + - "traefik.http.routers.restic-manager.entrypoints=websecure" + - "traefik.http.routers.restic-manager.tls.certresolver=letsencrypt" + - "traefik.http.services.restic-manager.loadbalancer.server.port=8080" +``` + +Traefik handles `X-Forwarded-*` and `Connection: upgrade` by default. +`RM_TRUSTED_PROXY` should be the docker network the Traefik container +shares with the server (commonly `172.16.0.0/12` for the default +bridge, or whatever your overlay network's CIDR is). + +## Sanity-checking the wiring + +After bringing the stack up: + +1. `curl -fsS https://restic.example.com/healthz` — should return 200. +2. The login page should report HTTPS in the address bar; cookies + set after login should carry the `Secure` flag. +3. Check the server log for the `config resolved` line: + `trusted_proxies` must include the IP/CIDR your proxy actually + connects from. +4. Enrol a test agent — the WebSocket handshake hitting `/ws/agent` + confirms `Upgrade` is being forwarded correctly. + +If any of those fail, the proxy is the first place to look — the +server itself is intentionally minimal. diff --git a/tasks.md b/tasks.md index ab64d50..c8391c8 100644 --- a/tasks.md +++ b/tasks.md @@ -332,7 +332,7 @@ Sizes: **S** = under a day, **M** = 1–3 days, **L** = 3–7 days. - [ ] **P5-04** (S) Demo screenshots / short Loom walkthrough in README - [ ] **P5-05** (S) `SECURITY.md` with disclosure process - [ ] **P5-06** (M) End-to-end test suite in CI (Playwright vs. compose stack with sibling Linux agent) -- [ ] **P5-07** (S) Reference deployment: `docker-compose.yml` + Caddyfile snippet showing the TLS-terminating reverse proxy in front of the HTTP-only server (also demonstrates `RM_TRUSTED_PROXY`) +- [x] **P5-07** (S) Reference deployment landed alongside P5-03. `deploy/docker-compose.yml` stands up *only* the server (image-pinned via `RM_VERSION`, named volume for operator state, bound to localhost) — TLS termination is left to whichever reverse proxy the operator already runs. `docs/reverse-proxy.md` documents the headers + WebSocket pass-through the proxy must forward, the `RM_TRUSTED_PROXY` CIDR rule, and worked examples for Caddy, nginx, and Traefik. ### Phase 5 acceptance -- 2.52.0