From d0a073f11b27de268990efe7726ea3b3f9475ecf Mon Sep 17 00:00:00 2001 From: Juhana Ilmoniemi Date: Mon, 11 May 2026 10:04:26 +0300 Subject: [PATCH 1/3] =?UTF-8?q?spec:=20Dockerfile=20+=20base=20hardening?= =?UTF-8?q?=20=E2=80=94=20portable=20deploy=20artifact=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../32-dockerfile-base-hardening.md | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 docs/specs/architecture/32-dockerfile-base-hardening.md diff --git a/docs/specs/architecture/32-dockerfile-base-hardening.md b/docs/specs/architecture/32-dockerfile-base-hardening.md new file mode 100644 index 0000000..78f9a53 --- /dev/null +++ b/docs/specs/architecture/32-dockerfile-base-hardening.md @@ -0,0 +1,239 @@ +# Spec: Dockerfile + base hardening — portable deploy artifact (#32) + +## Files to read first + +- `cmd/pyrycode-relay/main.go:20-35` — `Version` variable and `--version` flag; the no-network startup form used to verify the image runs. +- `cmd/pyrycode-relay/main.go:40-43` — required-flag check; explains why `pyrycode-relay` with no args exits `2` and `--version` is the only flagless success path. +- `Makefile:6-13` — `PKG`, `LDFLAGS`, and the `-X main.Version=$(VERSION)` injection the Dockerfile mirrors. +- `go.mod:1-3` — module path (`github.com/pyrycode/pyrycode-relay`) and Go toolchain version (`go 1.26.2`); the build stage tag tracks this. +- `README.md` § *Build* and § *Run* — the docs to extend with a *Docker* subsection. +- `docs/threat-model.md` § *Deploy security — VPS compromise*, § *Supply chain — Go dependencies*, § *Cert & key handling* — the three operational surfaces this artifact contributes hardening to. Used to scope the spec's security review. +- `docs/PROJECT-MEMORY.md` § *Patterns established* — for tone and convention (loud failure, deliberate dependency surface, policy at the wiring site). + +## Context + +The relay has no Dockerfile today. Without one, it can't ship to any platform that expects an OCI image. This ticket delivers **only the portable artifact** — host-agnostic, deliberately stripped, with hardening baked in at build time. Host-specific wiring (TLS termination, port mapping, volume backing, single-instance constraint, healthcheck wiring) lives in #38 / #39 / #42. + +The relay is internet-exposed. Image-layer hardening (small base, no shell, no package manager, non-root, digest-pinned bases, stripped binary) is defence-in-depth on top of the runtime hardening already in place (autocert cache permission check, slow-loris timeouts, header-gate before WS upgrade, 256 KiB frame cap, opaque payload routing). + +## Design + +### Files + +1. **`Dockerfile`** (new, repo root) — multi-stage: `build` → `runtime`. +2. **`.dockerignore`** (new, repo root) — minimal, excludes `.git`, build outputs, and the Dockerfile itself from the build context. Justification under § *On `.dockerignore`* below. +3. **`README.md`** — new *Docker* subsection under *Build*; ~10 lines. + +No Go code changes. No new types or interfaces. + +### Stage 1: build + +```dockerfile +# syntax=docker/dockerfile:1.7 + +# Tracks: golang:1.26-bookworm +# Pinned by digest so a tag-only swap upstream can't shift what we build. +# Renovate keeps the digest fresh; replace the placeholder below with the +# current digest at implementation time (see § "Pinning the digests"). +FROM golang:1.26-bookworm@sha256: AS build + +ARG VERSION=dev + +WORKDIR /src + +# Pull module deps first so source-only changes don't bust the layer cache. +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +# CGO_ENABLED=0 produces a fully static binary that runs on +# distroless/static (no glibc on the runtime image). GOFLAGS unset. +# -trimpath strips host paths from the binary; -s -w strips the symbol +# table and DWARF. -X mirrors the Makefile's main.Version injection so +# `pyrycode-relay --version` reports something useful in containerised +# builds. +RUN CGO_ENABLED=0 GOOS=linux \ + go build \ + -trimpath \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -o /out/pyrycode-relay \ + ./cmd/pyrycode-relay +``` + +### Stage 2: runtime + +```dockerfile +# Tracks: gcr.io/distroless/static-debian12:nonroot +# Pinned by digest; see § "Pinning the digests". +FROM gcr.io/distroless/static-debian12:nonroot@sha256: + +# Belt-and-suspenders: the :nonroot variant already sets USER 65532 +# upstream, but a future base swap could silently regress it. The +# explicit line guarantees the invariant survives any base-image change +# that doesn't also update this Dockerfile. +USER nonroot:nonroot + +COPY --from=build --chown=nonroot:nonroot /out/pyrycode-relay /pyrycode-relay + +# Documented mount point for the autocert cache. The host manifest (#38) +# bind-mounts this; without a mount, the directory does not exist inside +# the container and --cert-cache must be overridden by the host. +# Pre-creation with the correct ownership lives in the host manifest — +# distroless has no shell to mkdir here, and VOLUME with a missing dir +# behaves consistently for both bind- and anonymous-mount cases. +VOLUME ["/var/lib/relay/autocert"] + +# 80: ACME http-01 challenge listener (autocert mode). +# 443: WSS listener (autocert mode). +# The portable artifact exposes both; the host manifest (#38) chooses +# whether TLS terminates here (publish both) or upstream of the relay +# (publish neither, set --insecure-listen instead). +EXPOSE 80 443 + +ENTRYPOINT ["/pyrycode-relay"] +``` + +### `VERSION` strategy: build arg, defaults to `dev` + +The Dockerfile accepts `--build-arg VERSION=…` and injects it via the same `-X main.Version=…` ldflag the Makefile uses. Two reasons over "omit and accept `dev`": + +- **Symmetry with `make build`.** A contributor inspecting `bin/pyrycode-relay` and `pyrycode-relay:dev` should see the same `--version` semantics. Diverging here would surprise them when release tooling lands. +- **Future-proofing for #38.** When release tooling sets the image tag, it'll already want to pass a real version through; baking the wiring in now means #38 doesn't need to touch this Dockerfile. + +Cost: one `ARG` line + one term in the ldflag. Trivial. + +### Pinning the digests + +The AC requires both base images be pinned by `@sha256:…`. Tag selection drives which **upstream** the digest tracks; the digest itself is fetched at implementation time and refreshed by Renovate thereafter. + +Developer steps (at implementation time, once per base): + +```bash +docker pull golang:1.26-bookworm +docker inspect --format='{{index .RepoDigests 0}}' golang:1.26-bookworm +# → golang@sha256: — paste the hex into the Dockerfile. + +docker pull gcr.io/distroless/static-debian12:nonroot +docker inspect --format='{{index .RepoDigests 0}}' gcr.io/distroless/static-debian12:nonroot +# → gcr.io/distroless/static-debian12@sha256: +``` + +Both digest lines carry a `# Tracks: ` comment naming the upstream tag they were pinned from. Renovate (or a human reviewer) uses that comment to sanity-check what the digest *should* refer to when proposing a bump. This satisfies the AC's last bullet. + +### Why distroless/static-debian12:nonroot + +- **`static-debian12`** — no glibc, no apt, no shell, no package manager, no `/etc/passwd` games. Smallest attack surface available for a fully-static Go binary. Build stage uses `bookworm` (== debian12) so any C deps Go's toolchain pulls in match the static base's expectations; we still build with `CGO_ENABLED=0`, so this is belt-and-suspenders, not a hard requirement. +- **`:nonroot`** — runs as uid `65532`, gid `65532`, with `$HOME=/home/nonroot`. The relay's `defaultCertCache()` (`cmd/pyrycode-relay/main.go:120-125`) resolves to `/home/nonroot/.pyrycode-relay/certs` inside the container, but real deployments will pass `--cert-cache /var/lib/relay/autocert` (the documented mount point); the default is only relevant for `--version` smoke tests, which never touch the directory. + +### On `.dockerignore` + +The AC marks `.dockerignore` in-scope only if it materially affects build correctness. Including a minimal one keeps the build context bounded (the relay repo's `.git` directory is ~the largest item; `bin/` and `dist/` are present after a `make build` on the host). A bloated context isn't a correctness bug today, but it's adjacent — a memory-constrained CI runner could fail `docker build` with `.git` included and succeed without. Included for parity with the rest of the project's defensive defaults. + +``` +.git +bin/ +dist/ +coverage.txt +*.test +Dockerfile +.dockerignore +``` + +`docs/` is **not** excluded — `COPY . .` pulls it in, but the build stage only invokes `go build` against `./cmd/pyrycode-relay`, so the docs land in the build stage filesystem and are dropped at the multi-stage boundary. Excluding them is unnecessary and would surprise a contributor running `docker build` from the same checkout they edit docs in. + +### README update + +A new *Docker* subsection under *Build* in `README.md`: + +```markdown +## Docker + +```bash +docker build -t pyrycode-relay:dev . +docker run --rm pyrycode-relay:dev --version +``` + +The image is host-agnostic: it exposes `:80` and `:443` for autocert, +and mounts the autocert cache at `/var/lib/relay/autocert`. Host-specific +deploy wiring (TLS termination policy, port publishing, volume backing, +single-instance enforcement) lives in #38. +``` + +(Fenced-code markers are escaped in this spec; the README update uses real backticks.) + +### Verification (developer's AC checklist) + +1. `docker build -t pyrycode-relay:dev .` from a clean checkout completes successfully. +2. `docker run --rm pyrycode-relay:dev --version` prints `dev` and exits `0`. (The `flag` package treats `--help`/`-h` as a usage print and exits `2`; `--version` is the only zero-exit no-network startup form. The AC says "or equivalent" — `--version` is the equivalent.) +3. `docker inspect --format '{{.Config.User}}' pyrycode-relay:dev` prints `nonroot:nonroot` (or equivalent `65532:65532` — both are how distroless surfaces it depending on docker version). Distroless has no `id`/`whoami`, so image-inspection is the AC-permitted verification path. +4. `docker inspect --format '{{.Config.ExposedPorts}}'` lists `80/tcp` and `443/tcp`. +5. `docker inspect --format '{{.Config.Volumes}}'` lists `/var/lib/relay/autocert`. + +These are AC verification steps, not test code — there is no test suite to extend. + +## Concurrency model + +Not applicable. The artifact is a single binary in a container; the binary's own concurrency model (already specified across #3, #7, #16, #21, #25, #26) is unchanged. + +## Error handling + +Not applicable at the Dockerfile level — `go build` either succeeds or the build fails. The runtime image inherits the binary's existing failure modes (autocert cache permission check, etc.); the Dockerfile does not introduce new ones. + +## Testing strategy + +No unit tests; the artifact is verified by the AC steps above. Two structural defences against future regression: + +- **Belt-and-suspenders `USER` line.** A base-image swap that drops the upstream `USER 65532` would not silently regress us — the explicit line stays. +- **`# Tracks: ` comment alongside each digest pin.** A reviewer (or Renovate) can spot a digest that no longer corresponds to its tracked tag without consulting external state. + +Image-layer scanning (Trivy / Grype) lands in a separate ticket per the issue's *Out of scope* note. + +## Open questions + +- **Digest values at implementation time.** Resolved by the developer running `docker pull` + `docker inspect` (see § *Pinning the digests*). Not a design question; just a transient lookup. +- **Tag granularity (`1.26` vs `1.26.2`).** Spec picks `1.26` (minor): the digest pin makes byte-equivalence non-negotiable regardless of tag, and tracking the minor lets Renovate roll up patch bumps as digest-only changes instead of tag churn. If the developer prefers `1.26.2` for tighter human-readable provenance, that's an acceptable swap — the digest is the load-bearing part. + +## Security review + +The ticket carries the `security-sensitive` label. The pass below walks the spec against the adversarial-design categories that apply to a containerised internet-exposed relay. Performed before commit; verdict: PASS. + +### Trust boundaries + +- **Image build is build-time-trusted.** The Dockerfile runs `go build` over the working tree; the developer controls the inputs. No build-time network access beyond `go mod download` (already covered by `go.sum` integrity and the project's existing supply-chain posture). +- **Runtime trust boundary is unchanged from the host binary.** The container exposes `:80` and `:443`; the binary's existing header-gate, autocert cache check, and WS adapter caps remain the chokepoints. The Dockerfile adds no new code paths. + +### Adversarial inputs + +- **No new input surface.** The image does not introduce new listeners, flags, env vars, or filesystem paths the binary will read from. `/var/lib/relay/autocert` is declared via `VOLUME` but the binary only touches it if `--cert-cache` points there; the host manifest (#38) is responsible for that wiring. +- **`VOLUME` with no mount.** If the operator runs the image without bind- or anonymous-mounting the volume, the binary's `--cert-cache` default (`/home/nonroot/.pyrycode-relay/certs`) takes effect. Autocert will then create `/home/nonroot/.pyrycode-relay/certs` with `0700` on first start, satisfying the existing permission check in `internal/relay/tls.go`. This is a degraded posture (cache vanishes on container restart, forces re-issuance), but not a security regression — the host manifest closes it. + +### Privilege + +- **Runs as uid 65532, not 0.** Distroless `:nonroot` + explicit `USER nonroot:nonroot` belt-and-suspenders. A future contributor cannot accidentally drop the `USER` line and root the runtime, because the base also enforces it; both layers would need to regress simultaneously. +- **No `setuid`, no capabilities granted in the artifact.** Binding `:80` and `:443` from uid 65532 is the host's problem (port mapping, `CAP_NET_BIND_SERVICE`, or a host-side proxy); the portable artifact stays uid-neutral. + +### Supply chain + +- **Both bases digest-pinned.** A tag-swap attack on `golang` or `distroless/static` cannot change what we build against between Renovate bumps. The `# Tracks:` comment makes a malicious digest swap (changing the digest while leaving the tag comment unchanged) reviewable. +- **No `ADD` from URLs, no `RUN curl | sh`, no third-party scripts.** The only thing that enters the image is `go build` output. +- **No `apt-get install`, no `apk add`.** Distroless has neither; the build stage doesn't add packages. + +### Data at rest in the image + +- **No secrets baked in.** No copies of `.env`, no credentials, no `.git` (excluded by `.dockerignore`), no docs that might carry stray secrets (docs do enter the build stage but are dropped at the multi-stage boundary; they are not COPYed into the runtime image). +- **Binary stripped (`-s -w`).** No symbol table, no DWARF. Not a security boundary on its own, but reduces post-exploitation reverse-engineering convenience and signals build hygiene. +- **`-trimpath`.** Strips host paths from the binary; defends against accidental disclosure of build-host directory structure via panic stack traces. + +### Logging / observability + +- **No new log lines.** The Dockerfile does not change any logging behaviour; the existing log-hygiene rules in `docs/threat-model.md` § *Log hygiene* continue to apply unchanged. + +### Failure modes + +- **`docker build` failure** is loud and local; no security implication. +- **Missing digest at pin time** (developer pastes a placeholder) — `docker build` errors out with a manifest-fetch failure. Loud, local, no silent fallback. + +### Verdict + +**PASS.** The spec adds image-layer hardening without introducing new trust boundaries, new input surfaces, or new privilege paths. The runtime threat model is unchanged; the operational threat model (`docs/threat-model.md` § *Deploy security*, § *Supply chain*) gains defence-in-depth on small base, non-root, digest-pinned bases, and stripped binary. From 801c2a5489042a3649e29d0182710d996426533c Mon Sep 17 00:00:00 2001 From: Juhana Ilmoniemi Date: Mon, 11 May 2026 10:06:42 +0300 Subject: [PATCH 2/3] =?UTF-8?q?relay:=20Dockerfile=20+=20base=20hardening?= =?UTF-8?q?=20=E2=80=94=20portable=20deploy=20artifact=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-stage Dockerfile producing a host-agnostic, hardened image: - Build stage: golang:1.26-bookworm pinned by digest. Builds with CGO_ENABLED=0, -trimpath, -s -w, and -X main.Version=${VERSION} so the container build mirrors `make build` semantics. VERSION defaults to "dev" via ARG; release tooling (#38) can pass a real version through. - Runtime stage: gcr.io/distroless/static-debian12:nonroot pinned by digest. Belt-and-suspenders `USER nonroot:nonroot` so a base swap cannot silently regress non-root execution. EXPOSE 80 443; VOLUME /var/lib/relay/autocert as the documented cache mount point. The host manifest (#38) wires actual port publishing, volume backing, and TLS termination policy. Both base-image digests carry `# Tracks: ` comments so a reviewer (or Renovate) can sanity-check what the digest is supposed to track. .dockerignore trims the build context: `.git`, build outputs, and the Dockerfile itself. README gains a short Docker subsection under Build. Co-Authored-By: Claude Opus 4.7 --- .dockerignore | 7 +++++++ Dockerfile | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 9 +++++++++ 3 files changed, 71 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e1f4adb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +bin/ +dist/ +coverage.txt +*.test +Dockerfile +.dockerignore diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1cc156d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,55 @@ +# syntax=docker/dockerfile:1.7 + +# Tracks: golang:1.26-bookworm +# Pinned by digest so a tag-only swap upstream can't shift what we build +# against. Renovate keeps the digest fresh; refresh in lockstep with the +# tag comment so a future reviewer can sanity-check what the digest tracks. +FROM golang:1.26-bookworm@sha256:252599aeb51ad60b83e4d8821802068127c528c707cb7dd7afd93be057c6011c AS build + +ARG VERSION=dev + +WORKDIR /src + +# Pull module deps first so source-only changes don't bust the layer cache. +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +# CGO_ENABLED=0 produces a fully-static binary that runs on +# distroless/static (no glibc on the runtime image). -trimpath strips +# host paths from the binary; -s -w strips the symbol table and DWARF. +# -X mirrors the Makefile's main.Version injection so +# `pyrycode-relay --version` reports something useful in container builds. +RUN CGO_ENABLED=0 GOOS=linux \ + go build \ + -trimpath \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -o /out/pyrycode-relay \ + ./cmd/pyrycode-relay + +# Tracks: gcr.io/distroless/static-debian12:nonroot +# Pinned by digest; see the build-stage pin comment above for the rationale. +FROM gcr.io/distroless/static-debian12:nonroot@sha256:a9329520abc449e3b14d5bc3a6ffae065bdde0f02667fa10880c49b35c109fd1 + +# Belt-and-suspenders: the :nonroot variant already sets USER 65532 +# upstream, but a future base swap could silently regress it. The explicit +# line guarantees the invariant survives any base-image change that +# doesn't also update this Dockerfile. +USER nonroot:nonroot + +COPY --from=build --chown=nonroot:nonroot /out/pyrycode-relay /pyrycode-relay + +# Documented mount point for the autocert cache. The host manifest (#38) +# bind-mounts this; without a mount, the directory does not exist inside +# the container and --cert-cache must be overridden by the host. +VOLUME ["/var/lib/relay/autocert"] + +# 80: ACME http-01 challenge listener (autocert mode). +# 443: WSS listener (autocert mode). +# The portable artifact exposes both; the host manifest (#38) chooses +# whether TLS terminates here (publish both) or upstream of the relay +# (publish neither, set --insecure-listen instead). +EXPOSE 80 443 + +ENTRYPOINT ["/pyrycode-relay"] diff --git a/README.md b/README.md index 3af20cb..1c79040 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,15 @@ make vet # go vet make lint # gosec + govulncheck (requires both installed locally) ``` +### Docker + +```bash +docker build -t pyrycode-relay:dev . +docker run --rm pyrycode-relay:dev --version +``` + +The image is host-agnostic: it exposes `:80` and `:443` for autocert, and declares a volume mount point at `/var/lib/relay/autocert` for the cert cache. Host-specific deploy wiring (TLS termination policy, port publishing, volume backing, single-instance enforcement) lives in #38. + ## Run Production (autocert): From c9454e63395851a73c2cfc84e6b18337aed93304 Mon Sep 17 00:00:00 2001 From: Juhana Ilmoniemi Date: Mon, 11 May 2026 10:19:38 +0300 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20Dockerfile=20+=20base=20hardening?= =?UTF-8?q?=20=E2=80=94=20portable=20deploy=20artifact=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Feature doc (docs/knowledge/features/docker-image.md) — evergreen description of the portable OCI artifact: multi-stage build, digest-pinned bases with # Tracks: comments, static binary into distroless/static-debian12:nonroot, :80/:443 exposure, /var/lib/relay/autocert volume. - Per-ticket codebase notes (docs/knowledge/codebase/32.md) — what landed and the judgement calls (VERSION as build arg, .dockerignore minimal, USER nonroot belt-and-suspenders). - INDEX update — top entry under Features. - PROJECT-MEMORY patterns — digest-pin + # Tracks: sibling comment; belt-and-suspenders invariants across base layers. - Lessons — flag.Parse() exits 2 on --help (only --version is zero-exit no-network); distroless/static has no shell so pre-creation belongs in the host manifest. --- docs/PROJECT-MEMORY.md | 2 + docs/knowledge/INDEX.md | 1 + docs/knowledge/codebase/32.md | 42 +++++++++++++ docs/knowledge/features/docker-image.md | 82 +++++++++++++++++++++++++ docs/lessons.md | 8 +++ 5 files changed, 135 insertions(+) create mode 100644 docs/knowledge/codebase/32.md create mode 100644 docs/knowledge/features/docker-image.md diff --git a/docs/PROJECT-MEMORY.md b/docs/PROJECT-MEMORY.md index 147440d..2391bb8 100644 --- a/docs/PROJECT-MEMORY.md +++ b/docs/PROJECT-MEMORY.md @@ -51,6 +51,8 @@ Stateless WebSocket router between mobile clients and pyry binaries. Internet-ex - **Active-conn application close codes go through `WSConn.CloseWithCode`; stillborn-conn close codes go through the underlying `*websocket.Conn`.** ADR-0005's stillborn-conn pattern (`c.Close(websocket.StatusCode(4409), reason)` directly on the underlying conn before any `Send` could have run) coexists with ADR-0007's active-conn pattern (`wsconn.CloseWithCode(1011, "heartbeat timeout")` post-claim, where `closeOnce`/`writeMu` invariants are in play). Both share the same `closeOnce` guard via `Close()` delegating to `CloseWithCode(StatusNormalClosure, "")`. The two patterns describe structurally distinct windows of a WSConn's lifecycle. Adopted: stillborn `4409`/`4404` (#16, #5); active `1011` (#7). - **Input-bounding policy applied at the adapter constructor, not per-handler.** When a library default is dangerously generous (nhooyr's 32 MiB `SetReadLimit` default), set the policy at the single chokepoint *both* endpoints reach — the wrapping adapter's constructor — rather than at each handler entry. `NewWSConn(c, connID, maxFrameBytes)` calls `c.SetReadLimit(maxFrameBytes)` before returning the struct, which structurally discharges "before any `Read` is performed": no goroutine other than the constructor holds a reference at that point. Distinct from the "policy values live at the wiring site" pattern (which is about *literal placement* — the value still lives as a `const` in `main`); this one is about *enforcement placement* — apply at the choke point so neither handler can forget. Adopted in `WSConn` (#29). Same shape applies to any future "bound the adversarial input at the wrapping seam" policy (e.g. max-frames-per-second, if it lands). - **Capture process-state timestamps in `main` after `flag.Parse()`, not as package-level vars.** `startedAt := time.Now()` lives inside `main` and is passed into the handler factory. A package-level `var startedAt = time.Now()` would fire at import time — before flag parsing, before `--version` early-returns — and be wrong for short-lived test binaries and any future deferred-serve setup. Adopted in #10. +- **Digest-pin third-party base images with a `# Tracks: ` sibling comment.** `FROM image:tag@sha256:…` cannot be silently shifted by a tag-swap attack between Renovate bumps; the `# Tracks:` line tells a reviewer (or Renovate's diff) what the digest is supposed to correspond to, so a digest-changed-while-tag-comment-unchanged proposal is structurally reviewable. Same shape applies to any future supply-chain pin (action SHAs, downloaded toolchain archives) — never pin by digest alone, always pair with the human-readable tag the digest tracks. Adopted in `Dockerfile` for both `golang:1.26-bookworm` and `gcr.io/distroless/static-debian12:nonroot` (#32). +- **Belt-and-suspenders invariants survive base-layer regressions.** When a base image already enforces a security property upstream (distroless `:nonroot` sets `USER 65532`), re-state the property in our own layer (`USER nonroot:nonroot`) so a future base swap that drops the upstream property doesn't silently regress us. Both layers would need to regress simultaneously for the property to lapse. Cost is one line; benefit is regression resistance under unattended dependency updates. Adopted in the runtime stage of `Dockerfile` (#32). ## Conventions diff --git a/docs/knowledge/INDEX.md b/docs/knowledge/INDEX.md index 8581a39..14fe385 100644 --- a/docs/knowledge/INDEX.md +++ b/docs/knowledge/INDEX.md @@ -4,6 +4,7 @@ One-line pointers into the evergreen knowledge base. Newest entries at the top o ## Features +- [Docker image](features/docker-image.md) — portable OCI artifact: multi-stage `Dockerfile` builds a fully-static binary (`CGO_ENABLED=0`, `-trimpath -s -w`) into `distroless/static-debian12:nonroot`; both base images digest-pinned with `# Tracks:` comments; exposes `:80`/`:443` and declares `/var/lib/relay/autocert` volume; host-specific wiring (TLS policy, ports, volumes, healthcheck) is #38's problem (#32). - [Binary-side frame forwarder](features/binary-forwarder.md) — per-binary read pump: unwraps each inbound routing envelope, linear-scans `PhonesFor(serverID)` for `env.ConnID`, writes `env.Frame` verbatim to that phone; opaque inner bytes; synchronous (handler discards the return); diverges from #25 in error policy — unknown `conn_id`, malformed envelope, phone `Send` error all log+continue (a single bad frame never tears down the binary); replaced `/v1/server`'s `CloseRead` placeholder (#26). - [WebSocket heartbeat](features/heartbeat.md) — per-conn goroutine on both endpoints sends RFC 6455 ping every 30s; closes with `1011 "heartbeat timeout"` if no pong within 30s. Detects half-open TCP within 60s; ctx-cancel exit path leaves close to the handler defer (#7). - [Phone-side frame forwarder](features/phone-forwarder.md) — per-phone read pump: wraps each inbound phone frame in the routing envelope keyed by the phone's `conn_id` and `Send`s it to the binary holding `serverID`; opaque inner bytes; synchronous (handler discards the return); replaced `/v1/client`'s `CloseRead` placeholder; added `WSConn.Read` (single-caller) (#25). diff --git a/docs/knowledge/codebase/32.md b/docs/knowledge/codebase/32.md new file mode 100644 index 0000000..e0d6613 --- /dev/null +++ b/docs/knowledge/codebase/32.md @@ -0,0 +1,42 @@ +# Ticket #32 — Dockerfile + base hardening: portable deploy artifact + +First container image for the relay. Multi-stage `Dockerfile` at the repo root produces a host-agnostic, hardened OCI artifact: digest-pinned bases, fully-static `CGO_ENABLED=0` Go build, runtime on `distroless/static-debian12:nonroot` with no shell and no package manager. Host-specific wiring (TLS termination policy, port publishing, volume backing, single-instance constraint, healthcheck) is deliberately deferred to #38 / #39 / #42 — this ticket ships **only** the portable artifact. + +## Implementation + +- **`Dockerfile`** (new, repo root) — two stages. Build stage: `golang:1.26-bookworm@sha256:2525…011c` (tracks `golang:1.26-bookworm`), `go mod download` in a separate layer before `COPY . .` for cache stability, then `CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" -o /out/pyrycode-relay ./cmd/pyrycode-relay`. Runtime stage: `gcr.io/distroless/static-debian12:nonroot@sha256:a932…9fd1` (tracks `gcr.io/distroless/static-debian12:nonroot`), explicit `USER nonroot:nonroot` (belt-and-suspenders against future base swap), `COPY --from=build --chown=nonroot:nonroot /out/pyrycode-relay /pyrycode-relay`, `VOLUME ["/var/lib/relay/autocert"]`, `EXPOSE 80 443`, `ENTRYPOINT ["/pyrycode-relay"]`. Each digest carries a `# Tracks: ` comment so a reviewer (or Renovate diff) can sanity-check what the digest is supposed to refer to. +- **`VERSION` build arg.** `ARG VERSION=dev` in the build stage, threaded into the same `-X main.Version=…` ldflag the `Makefile` uses. Default `dev` matches the bare-binary default; release tooling (#38) will override via `--build-arg VERSION=…`. Chosen over "omit and accept `dev`" for symmetry with `make build` (a contributor inspecting `bin/pyrycode-relay` and `pyrycode-relay:dev` sees the same `--version` semantics) and to avoid touching this Dockerfile again when #38 wires release tooling. +- **`.dockerignore`** (new, repo root) — excludes `.git`, `bin/`, `dist/`, `coverage.txt`, `*.test`, `Dockerfile`, `.dockerignore`. Bounds the build context so a memory-constrained CI runner doesn't OOM on `.git`. `docs/` is intentionally NOT excluded; it lands in the build stage and is dropped at the multi-stage boundary, so excluding it would surprise contributors who edit docs in the same checkout. +- **`README.md`** — new *Docker* subsection under *Build*: build command, run-with-`--version` smoke test, one-line statement that host-specific deploy wiring lives in #38. +- **No Go code changes.** The binary's behaviour is unchanged; the image is a packaging change only. + +## Hardening choices + +- **`distroless/static-debian12:nonroot`** — no glibc, no `apt`, no shell, no `/etc/passwd`, runs as uid `65532`. Smallest attack surface available for a fully-static Go binary. +- **`CGO_ENABLED=0`** — required for distroless/static (no glibc), and removes the implicit C-toolchain supply-chain edge. +- **`-trimpath -s -w`** — strips host build paths, symbol table, and DWARF. Defence-in-depth against incidental disclosure via panic traces and against reverse-engineering convenience. +- **Digest-pinned bases.** Tag-swap attacks on `golang` or `distroless/static` cannot change what we build against between Renovate bumps. `# Tracks:` comments make a malicious digest swap reviewable. + +## Verification + +Manual AC checks (no test suite to extend — the artifact is a packaging change): + +1. `docker build -t pyrycode-relay:dev .` from a clean checkout completes successfully. +2. `docker run --rm pyrycode-relay:dev --version` prints `dev` and exits `0`. (`--help` exits `2`; bare invocation exits `2` from the required-flag check. `--version` is the only zero-exit no-network startup form.) +3. `docker inspect --format '{{.Config.User}}' pyrycode-relay:dev` → `nonroot:nonroot` (or `65532:65532` depending on Docker version). +4. `docker inspect --format '{{.Config.ExposedPorts}}' pyrycode-relay:dev` lists `80/tcp` and `443/tcp`. +5. `docker inspect --format '{{.Config.Volumes}}' pyrycode-relay:dev` lists `/var/lib/relay/autocert`. + +## Out of scope (separate tickets) + +- **#38** — host-specific manifest (fly.toml / compose.yaml / k8s / systemd unit), TLS termination policy, port publishing, volume backing for `/var/lib/relay/autocert`, healthcheck wiring against `/healthz`. +- **#39** — single-instance constraint enforcement. +- **#42** — startup security posture self-check. +- **Image scanning in CI** (Trivy / Grype) — separate ticket, not yet filed. + +## Cross-links + +- [Feature: Docker image](../features/docker-image.md) — evergreen description of the artifact. +- [Spec: 32-dockerfile-base-hardening](../../specs/architecture/32-dockerfile-base-hardening.md) — architect's design and security review. +- [Threat model](../../threat-model.md) — § *Deploy security*, § *Supply chain*, § *Cert & key handling* are the operational surfaces this contributes defence-in-depth to. +- [Autocert TLS](../features/autocert-tls.md) — what `:80` / `:443` exposure and `/var/lib/relay/autocert` feed. diff --git a/docs/knowledge/features/docker-image.md b/docs/knowledge/features/docker-image.md new file mode 100644 index 0000000..37b4927 --- /dev/null +++ b/docs/knowledge/features/docker-image.md @@ -0,0 +1,82 @@ +# Docker image — portable deploy artifact + +Host-agnostic OCI image for the relay, produced by a multi-stage `Dockerfile` at the repo root. Image-layer hardening (small base, no shell, no package manager, non-root, digest-pinned bases, stripped static binary) is defence-in-depth on top of the runtime hardening already in place (autocert cache permission check, slow-loris timeouts, header-gate before WS upgrade, 256 KiB frame cap, opaque payload routing). + +The image is intentionally **portable, not deployable on its own**: it exposes both `:80` and `:443` and declares a volume mount at `/var/lib/relay/autocert`, but TLS termination policy, port publishing, volume backing, single-instance enforcement, and healthcheck wiring are decisions the host manifest owns (#38 / #39 / #42). + +## Build and verification + +```bash +docker build -t pyrycode-relay:dev . +docker run --rm pyrycode-relay:dev --version # zero-exit no-network startup form +``` + +The image is invoked with `--version` for smoke tests; `--help` exits `2` (Go's `flag` package treats usage prints as errors), and the bare `pyrycode-relay` invocation also exits `2` (missing required `--domain` / `--insecure-listen`). `--version` is the only zero-exit no-network startup form. + +## Structure + +### Build stage + +`golang:1.26-bookworm@sha256:…` (digest-pinned). Tag tracks the Go toolchain version in `go.mod`. Builds with: + +``` +CGO_ENABLED=0 GOOS=linux +go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" \ + -o /out/pyrycode-relay ./cmd/pyrycode-relay +``` + +- `CGO_ENABLED=0` → fully-static binary that runs on distroless/static (no glibc on the runtime image). +- `-trimpath` → strips host build paths from the binary; defends against accidental disclosure of build-host directory structure via panic stack traces. +- `-s -w` → strips symbol table and DWARF; reduces post-exploitation reverse-engineering convenience and signals build hygiene. +- `-X main.Version=${VERSION}` → mirrors the `Makefile`'s `LDFLAGS`. Image builds default to `VERSION=dev` (matches the bare-binary default); release tooling lands in #38 and overrides via `--build-arg VERSION=…`. + +`go mod download` runs in a separate layer before `COPY . .` so source-only edits don't bust the dependency-cache layer. + +### Runtime stage + +`gcr.io/distroless/static-debian12:nonroot@sha256:…` (digest-pinned). The distroless `:nonroot` variant runs as uid `65532` upstream; the Dockerfile re-asserts `USER nonroot:nonroot` as belt-and-suspenders so a future base swap can't silently regress the invariant. The binary is the only thing in the runtime image — no shell, no package manager, no `apt`/`apk`, no `/etc/passwd` games. + +- `EXPOSE 80 443` — `:80` for autocert ACME http-01 challenges, `:443` for WSS. The portable artifact exposes both; the host manifest chooses publish-both (autocert mode) or publish-neither (`--insecure-listen` behind a reverse proxy). +- `VOLUME ["/var/lib/relay/autocert"]` — documented mount point for the autocert cache. Without a mount, `--cert-cache` defaults to `/home/nonroot/.pyrycode-relay/certs` (degraded posture: cache vanishes on container restart, forces re-issuance). The host manifest (#38) wires a real backing store. +- `ENTRYPOINT ["/pyrycode-relay"]` — args at `docker run` go straight to the binary (`--domain …`, `--insecure-listen …`, etc.). + +## Digest pinning convention + +Both `FROM` lines pin the base image by `@sha256:…` and carry a `# Tracks: ` comment naming the tag they were pinned from. Renovate keeps the digest fresh as the upstream tag moves; the comment lets a human reviewer (or Renovate's diff) sanity-check that the proposed digest still corresponds to the tracked tag. A malicious digest swap (digest changed, tag-comment unchanged) is structurally reviewable rather than relying on out-of-band trust. + +Refreshing a pin (developer steps): + +```bash +docker pull golang:1.26-bookworm +docker inspect --format='{{index .RepoDigests 0}}' golang:1.26-bookworm +# → paste the hex into the Dockerfile, keep the # Tracks: line in sync +``` + +## `.dockerignore` + +Minimal, excludes `.git`, `bin/`, `dist/`, `coverage.txt`, `*.test`, and the Dockerfile / `.dockerignore` themselves. Keeps the build context bounded — `.git` is the largest item and a memory-constrained CI runner could OOM `docker build` with it included. `docs/` is **not** excluded; it lands in the build stage and is dropped at the multi-stage boundary, so excluding it would surprise a contributor running `docker build` from the same checkout they edit docs in. + +## Threat-model contributions + +The image layer contributes hardening to three operational surfaces (see `docs/threat-model.md`): + +- **Deploy security — VPS compromise.** Non-root uid `65532`, no shell, no package manager. A compromised binary cannot `apt-get install`, cannot `exec` a shell, cannot escalate via setuid (none in the image). +- **Supply chain — Go dependencies.** Both base images digest-pinned with reviewable `# Tracks:` comments. Tag-swap attacks on `golang` or `distroless/static` cannot change what we build against between Renovate bumps. No `ADD` from URLs, no `RUN curl | sh`, no third-party scripts. +- **Cert & key handling.** No secrets baked into the image. `.git` excluded via `.dockerignore`. Docs enter the build stage but drop at the multi-stage boundary; they never reach the runtime layer. + +## What this artifact deliberately does NOT do + +- **No `HEALTHCHECK` directive.** `/healthz` (#10) is already exposed; platform health checks belong in the host manifest (#38), not in the portable artifact. +- **No host-specific config.** No `fly.toml`, `compose.yaml`, k8s manifest, or systemd unit — those live in #38. +- **No single-instance enforcement.** The relay's binary-slot single-instance constraint is #39's problem; the image can be run N times, but only one will hold the slot. +- **No startup security-posture self-check.** #42 covers runtime self-validation. +- **No image scanning wiring.** Trivy / Grype in CI is a separate ticket, not yet filed. +- **No `--cert-cache` baked in.** The default (`/home/nonroot/.pyrycode-relay/certs`) is only relevant for `--version` smoke tests; real deployments pass `--cert-cache /var/lib/relay/autocert` via the host manifest. + +## Cross-links + +- [Ticket #32 codebase notes](../codebase/32.md) — what landed in this ticket. +- [Spec: 32-dockerfile-base-hardening](../../specs/architecture/32-dockerfile-base-hardening.md) — architect's design and security review. +- [Threat model](../../threat-model.md) — § *Deploy security*, § *Supply chain*, § *Cert & key handling* are the surfaces this image layer hardens. +- [Autocert TLS](autocert-tls.md) — what the `:80` / `:443` exposure and `/var/lib/relay/autocert` mount feed. +- [`/healthz` endpoint](healthz.md) — what platform health checks will probe (wired in #38, not here). diff --git a/docs/lessons.md b/docs/lessons.md index 0c7f3ea..7d30bfd 100644 --- a/docs/lessons.md +++ b/docs/lessons.md @@ -82,6 +82,14 @@ The library's auto-pong machinery runs inline with `Read` (or `CloseRead`'s back The library's `*websocket.Conn` accepts frames up to ~32 MiB out of the box. For a relay routing typed envelopes (worst-case `message_chunk` ≈ 200 KiB), that default lets a misbehaving peer pin two orders of magnitude more read buffer than any legitimate message needs. `SetReadLimit(n)` adjusts the cap; on an over-cap frame the library closes with `StatusMessageTooBig` (1009) and surfaces a non-nil error on the next `Read`. Apply it inside the wrapping adapter's constructor *before the struct returns* — that closes the window where a `Read` could fire against an uncapped conn without each handler having to remember. The cap value belongs at the composition root (`cmd/.../main.go` as a `const`), threaded through the handler constructor: one literal, one place, no package-level constant in the relay package. Source: `NewWSConn(c, connID, maxFrameBytes)` (#29). +## Go's `flag` package treats `--help` as an error — exits `2`, not `0` + +`flag.Parse()` prints usage and calls `os.Exit(2)` on `-h` / `--help` because it routes them through the same error path as unknown flags. The only zero-exit no-network startup form for `pyrycode-relay` is `--version`, which is checked before `flag.Parse()` (or before the required-flag gate) and `os.Exit(0)`s explicitly. Bare invocation also exits `2` from the required-flag check (`--domain` or `--insecure-listen` must be set). When writing smoke tests for a containerised binary, use `--version`; `--help` looks like the obvious "does it run?" probe but fails the AC's "exits cleanly" wording. Source: `Dockerfile` smoke-test wording / `cmd/pyrycode-relay/main.go` (#32). + +## `distroless/static` has no shell, no `/etc/passwd`, no `mkdir` — pre-create dirs in the host manifest, not in the Dockerfile + +The runtime image used in #32 ships with the binary and nothing else: no `sh`, no `mkdir`, no `id`, no `apt`, no `apk`. A `RUN mkdir -p /var/lib/relay/autocert` in the runtime stage would fail. The `VOLUME` directive declares the mount point but does not create the directory inside the image — the directory only exists at runtime if a bind- or anonymous-mount provides it, or if the binary creates it on first run (autocert does, with `0700`). Pre-creation with the correct ownership lives in the host manifest (#38) where there's still a shell available. Implication for verification: don't `docker run --rm pyrycode-relay:dev sh -c '…'`; that fails with `exec: "sh": not found`. Use `docker inspect` for image-level assertions and `--version` for runtime smoke tests. Source: `Dockerfile` runtime stage (#32). + ## `autocert.Manager.TLSConfig()` doesn't set `MinVersion` `gosec` G402 fires on `make lint` if you use it raw. Wrap it in a helper that pins `MinVersion = tls.VersionTLS12` (or 1.3) before handing it to `http.Server`. Centralising the override means a future bump is a one-line change. Source: `relay.TLSConfig` (#9).