From b4b2cd98ebf9304a2452c0274c73277ebd994e21 Mon Sep 17 00:00:00 2001 From: Kushagar Goel Date: Thu, 29 Oct 2026 19:55:28 +0530 Subject: [PATCH 1/4] Image hardening Build and run the server image on Docker Hardened Images: dhi.io/rust to build, dhi.io/debian-base (debian-13) to run. The runtime has no shell and no package manager, so: - curl and ca-certificates are no longer installed: TLS roots are compiled into the binary (rustls with webpki-roots). - The Docker HEALTHCHECK is gone; the platform probes /healthz and /readyz over HTTP, which answer 503 until the server is ready. - The server runs as the image's nonroot user (uid 65532) instead of a streamgres user made with useradd (uid 10001). The binary is root-owned; only /var/lib/streamgres is writable. /var/lib/xyne-sync is dropped. - BASE_REGISTRY is replaced by whole-image BUILD_IMAGE and RUNTIME_IMAGE arguments, since DHI image names differ from the official ones. CI logs in to dhi.io (pulls need an account), and publishing now builds, scans with Trivy (failing on fixable critical or high findings), then pushes with an SBOM and provenance. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci-pr.yml | 7 +++++ .github/workflows/docker-publish.yml | 33 ++++++++++++++++++- docker/server/Dockerfile | 47 +++++++++++++++++----------- docs/deploy.md | 14 ++++++--- 4 files changed, 78 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index cd07cff..078c3a1 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -152,6 +152,13 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Log in to Docker Hardened Images + uses: docker/login-action@v3 + with: + registry: dhi.io + username: ${{ secrets.DHI_USERNAME }} + password: ${{ secrets.DHI_TOKEN }} + - name: Build the image uses: docker/build-push-action@v5 with: diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index f3e6d46..3a23828 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -25,6 +25,13 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Log in to Docker Hardened Images + uses: docker/login-action@v3 + with: + registry: dhi.io + username: ${{ secrets.DHI_USERNAME }} + password: ${{ secrets.DHI_TOKEN }} + - name: Log in to GitHub Container Registry uses: docker/login-action@v3 with: @@ -46,12 +53,36 @@ jobs: type=ref,event=tag type=raw,value=${{ env.SHORT_SHA }} - - name: Build and push Docker image + # Built and scanned before anything is pushed: an image with a fixable + # critical or high vulnerability is never published. + - name: Build the image + uses: docker/build-push-action@v5 + with: + context: . + file: docker/server/Dockerfile + load: true + tags: streamgres:scan + build-args: | + SOURCE_COMMIT=${{ env.SHORT_SHA }} + cache-from: type=gha,scope=server + cache-to: type=gha,mode=max,scope=server + + - name: Scan the image + uses: aquasecurity/trivy-action@0.28.0 + with: + image-ref: streamgres:scan + severity: CRITICAL,HIGH + ignore-unfixed: true + exit-code: "1" + + - name: Push the image, with its SBOM and provenance uses: docker/build-push-action@v5 with: context: . file: docker/server/Dockerfile push: true + sbom: true + provenance: mode=max build-args: | SOURCE_COMMIT=${{ env.SHORT_SHA }} tags: ${{ steps.meta.outputs.tags }} diff --git a/docker/server/Dockerfile b/docker/server/Dockerfile index 4455e87..e101a14 100644 --- a/docker/server/Dockerfile +++ b/docker/server/Dockerfile @@ -1,33 +1,44 @@ -# The sync server as an image: built from the locked dependencies, run as an -# unprivileged user on a slim Debian. Configured entirely by environment -# variables (docs/deploy.md lists every one); nothing is baked in but the -# listen address and the JSON log format a container platform expects. +# The sync server as an image: built from the locked dependencies on Docker +# Hardened Images, and run on a hardened Debian runtime that has no shell, +# no package manager and no user but the unprivileged `nonroot` (uid 65532). +# Configured entirely by environment variables (docs/deploy.md lists every +# one); nothing is baked in but the listen address and the JSON log format a +# container platform expects. +# +# The runtime needs nothing installed: TLS roots are compiled into the binary +# (rustls with webpki-roots). There is no HEALTHCHECK: the platform probes +# `/healthz` and `/readyz` over HTTP (docs/deploy.md). Pulling from dhi.io +# needs `docker login dhi.io` first. # # docker build -f docker/server/Dockerfile -t streamgres . -ARG BASE_REGISTRY=docker.io/library -FROM ${BASE_REGISTRY}/rust:1.89-bookworm AS builder +# +# A mirror of the same images is given whole, not as a registry prefix: +# +# docker build --build-arg BUILD_IMAGE=/rust: \ +# --build-arg RUNTIME_IMAGE=/debian-base: ... +ARG BUILD_IMAGE=dhi.io/rust:1.89-debian13-dev +ARG RUNTIME_IMAGE=dhi.io/debian-base:debian13 + +FROM ${BUILD_IMAGE} AS builder WORKDIR /build COPY Cargo.toml Cargo.lock clippy.toml ./ COPY src ./src -RUN cargo build --release --locked --bin server +RUN cargo build --release --locked --bin server \ + && mkdir -p /out/app /out/var/lib/streamgres \ + && cp target/release/server /out/app/server -FROM ${BASE_REGISTRY}/debian:bookworm-slim AS runtime -RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates curl \ - && rm -rf /var/lib/apt/lists/* \ - && useradd --system --uid 10001 --home-dir /app --shell /usr/sbin/nologin streamgres \ - && mkdir -p /app /var/lib/streamgres /var/lib/xyne-sync \ - && chown -R streamgres:streamgres /app /var/lib/streamgres /var/lib/xyne-sync +FROM ${RUNTIME_IMAGE} AS runtime +# The binary is root-owned and read-only to the server; only the plan +# file's directory is its to write. +COPY --from=builder --chown=0:0 /out/app /app +COPY --from=builder --chown=65532:65532 /out/var/lib/streamgres /var/lib/streamgres WORKDIR /app -COPY --from=builder /build/target/release/server /app/server ARG SOURCE_COMMIT=unknown ENV SOURCE_COMMIT=${SOURCE_COMMIT} \ STREAMGRES_ADDR=0.0.0.0:4848 \ STREAMGRES_LOG_FORMAT=json -USER streamgres +USER 65532:65532 EXPOSE 4848 -HEALTHCHECK --interval=10s --timeout=3s --start-period=30s --retries=6 \ - CMD curl -fsS http://127.0.0.1:4848/health || exit 1 CMD ["/app/server"] diff --git a/docs/deploy.md b/docs/deploy.md index 8ac93e9..bcd69dc 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -26,12 +26,18 @@ For a local first run, see the README's Quick start. docker build -f docker/server/Dockerfile -t streamgres . ``` -The image is a slim Debian image of about 125 MB: -- It has one binary, `/app/server`, run as the unprivileged user `streamgres` - (uid 10001). +The image is built on Docker Hardened Images (`dhi.io/rust` to build, +`dhi.io/debian-base` to run), so pulling them needs `docker login dhi.io` +first. The runtime has no shell and no package manager: +- It has one binary, `/app/server`, run as the unprivileged user `nonroot` + (uid 65532). - It listens on port 4848 and writes JSON log lines to stderr. +- It has no Docker `HEALTHCHECK` (there is no curl to run one): probe + `/healthz` and `/readyz` over HTTP from the platform instead, which + answer 503 until the server is ready (section 6). - It needs no file system, except an optional writable directory for - `STREAMGRES_PLAN_FILE` (section 5). + `STREAMGRES_PLAN_FILE` (section 5), and a writable `/tmp` for heap + profiles when Pyroscope is on and the root file system is read-only. `.github/workflows/docker-publish.yml` publishes the image to the GitHub Container Registry as `ghcr.io//streamgres`. It runs on pushes to the From cb702146ab573b9a180dbd60c0a1456dec293ab3 Mon Sep 17 00:00:00 2001 From: Kushagar Goel Date: Thu, 29 Oct 2026 20:42:51 +0530 Subject: [PATCH 2/4] Run on distroless instead of Docker Hardened Images dhi.io refuses anonymous pulls, so every build would need a Docker account. Google's distroless gives the same runtime properties (no shell, no package manager, nonroot uid 65532) from a public registry: run on gcr.io/distroless/cc-debian13:nonroot, built on rust:1.89-trixie so both stages share Debian 13's glibc. The dhi.io login steps are dropped. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci-pr.yml | 7 ------- .github/workflows/docker-publish.yml | 7 ------- docker/server/Dockerfile | 25 +++++++++++++------------ docs/deploy.md | 6 +++--- 4 files changed, 16 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index 078c3a1..cd07cff 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -152,13 +152,6 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Log in to Docker Hardened Images - uses: docker/login-action@v3 - with: - registry: dhi.io - username: ${{ secrets.DHI_USERNAME }} - password: ${{ secrets.DHI_TOKEN }} - - name: Build the image uses: docker/build-push-action@v5 with: diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 3a23828..4bada9b 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -25,13 +25,6 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Log in to Docker Hardened Images - uses: docker/login-action@v3 - with: - registry: dhi.io - username: ${{ secrets.DHI_USERNAME }} - password: ${{ secrets.DHI_TOKEN }} - - name: Log in to GitHub Container Registry uses: docker/login-action@v3 with: diff --git a/docker/server/Dockerfile b/docker/server/Dockerfile index e101a14..15ddf97 100644 --- a/docker/server/Dockerfile +++ b/docker/server/Dockerfile @@ -1,23 +1,24 @@ -# The sync server as an image: built from the locked dependencies on Docker -# Hardened Images, and run on a hardened Debian runtime that has no shell, -# no package manager and no user but the unprivileged `nonroot` (uid 65532). +# The sync server as an image: built from the locked dependencies, and run on +# Google's distroless runtime, which has no shell, no package manager and no +# coreutils, so a compromised server has no tools to run. The runtime is +# `cc`: glibc and libgcc, which the binary links, and nothing else it needs; +# TLS is rustls with its roots compiled in. It runs as `nonroot` (uid 65532). # Configured entirely by environment variables (docs/deploy.md lists every # one); nothing is baked in but the listen address and the JSON log format a # container platform expects. # -# The runtime needs nothing installed: TLS roots are compiled into the binary -# (rustls with webpki-roots). There is no HEALTHCHECK: the platform probes -# `/healthz` and `/readyz` over HTTP (docs/deploy.md). Pulling from dhi.io -# needs `docker login dhi.io` first. +# There is no HEALTHCHECK, since there is no shell to run one: the platform +# probes `/healthz` and `/readyz` over HTTP (docs/deploy.md). # # docker build -f docker/server/Dockerfile -t streamgres . # -# A mirror of the same images is given whole, not as a registry prefix: +# Both stages are on Debian 13, so the binary is linked against the glibc it +# runs on. A mirror of the same images is given whole: # -# docker build --build-arg BUILD_IMAGE=/rust: \ -# --build-arg RUNTIME_IMAGE=/debian-base: ... -ARG BUILD_IMAGE=dhi.io/rust:1.89-debian13-dev -ARG RUNTIME_IMAGE=dhi.io/debian-base:debian13 +# docker build --build-arg BUILD_IMAGE=/rust:1.89-trixie \ +# --build-arg RUNTIME_IMAGE=/distroless/cc-debian13:nonroot ... +ARG BUILD_IMAGE=docker.io/library/rust:1.89-trixie +ARG RUNTIME_IMAGE=gcr.io/distroless/cc-debian13:nonroot FROM ${BUILD_IMAGE} AS builder WORKDIR /build diff --git a/docs/deploy.md b/docs/deploy.md index bcd69dc..f1cfcce 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -26,9 +26,9 @@ For a local first run, see the README's Quick start. docker build -f docker/server/Dockerfile -t streamgres . ``` -The image is built on Docker Hardened Images (`dhi.io/rust` to build, -`dhi.io/debian-base` to run), so pulling them needs `docker login dhi.io` -first. The runtime has no shell and no package manager: +The image runs on Google's distroless `cc-debian13` (built on +`rust:1.89-trixie`). The runtime has no shell, no package manager and no +coreutils: - It has one binary, `/app/server`, run as the unprivileged user `nonroot` (uid 65532). - It listens on port 4848 and writes JSON log lines to stderr. From 071d46bf39f6e8e452ca20a848c07baf0afaa075 Mon Sep 17 00:00:00 2001 From: Kushagar Goel Date: Thu, 29 Oct 2026 20:44:35 +0530 Subject: [PATCH 3/4] Drop the Trivy scan from publishing Co-Authored-By: Claude Opus 5.5 --- .github/workflows/docker-publish.yml | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 4bada9b..ee484da 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -46,29 +46,7 @@ jobs: type=ref,event=tag type=raw,value=${{ env.SHORT_SHA }} - # Built and scanned before anything is pushed: an image with a fixable - # critical or high vulnerability is never published. - - name: Build the image - uses: docker/build-push-action@v5 - with: - context: . - file: docker/server/Dockerfile - load: true - tags: streamgres:scan - build-args: | - SOURCE_COMMIT=${{ env.SHORT_SHA }} - cache-from: type=gha,scope=server - cache-to: type=gha,mode=max,scope=server - - - name: Scan the image - uses: aquasecurity/trivy-action@0.28.0 - with: - image-ref: streamgres:scan - severity: CRITICAL,HIGH - ignore-unfixed: true - exit-code: "1" - - - name: Push the image, with its SBOM and provenance + - name: Build and push the image, with its SBOM and provenance uses: docker/build-push-action@v5 with: context: . From ee208a018d8fd849146883397f2003f8f0aa8c13 Mon Sep 17 00:00:00 2001 From: Kushagar Goel Date: Thu, 29 Oct 2026 20:45:59 +0530 Subject: [PATCH 4/4] Publish the harden-image branch, to build it once before merging Co-Authored-By: Claude Opus 5.5 --- .github/workflows/docker-publish.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index ee484da..c849baa 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -7,6 +7,7 @@ on: - feat/build - feat/schema_upgrade - env_var_namechange + - harden-image tags: - "v*" workflow_dispatch: