Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.git
bin/
dist/
coverage.txt
*.test
Dockerfile
.dockerignore
55 changes: 55 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions docs/PROJECT-MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <upstream-tag>` 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

Expand Down
1 change: 1 addition & 0 deletions docs/knowledge/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
42 changes: 42 additions & 0 deletions docs/knowledge/codebase/32.md
Original file line number Diff line number Diff line change
@@ -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: <upstream-tag>` 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.
82 changes: 82 additions & 0 deletions docs/knowledge/features/docker-image.md
Original file line number Diff line number Diff line change
@@ -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: <upstream-tag>` 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).
Loading