diff --git a/server/.env.example b/server/.env.example index 7f0747d..a449db2 100644 --- a/server/.env.example +++ b/server/.env.example @@ -82,8 +82,9 @@ TURN_TIMEOUT_SECONDS=30 # OpenTelemetry (compiled in by default). Off unless OTEL_ENABLED is # truthy; exports traces (HTTP/WebSocket) and process metrics, plus custom # tavolo.active_users / tavolo.ongoing_games gauges and logs over OTLP/HTTP. -# The endpoint defaults to the standard OTEL_EXPORTER_OTLP_ENDPOINT -# (localhost:4318) when unset. +# The endpoint defaults to http://localhost:4318 when unset and must use +# http:// (the OTLP client is built without a TLS backend; terminate TLS in +# front of a remote collector). #OTEL_ENABLED=true #OTEL_SERVICE_NAME=tavolo #OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 diff --git a/server/Cargo.toml b/server/Cargo.toml index dd0213d..353a52c 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -43,3 +43,4 @@ uuid = { version = "1", features = ["v4", "serde"] } [profile.release] lto = "thin" codegen-units = 1 +strip = "symbols" diff --git a/server/Dockerfile b/server/Dockerfile index 90f4281..5785a58 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -3,13 +3,22 @@ # Sycamore/WASM frontend). The Docker build context is the REPOSITORY ROOT # (see docker-compose.yml) so this single image assembles both parts: # -# web-builder rust + trunk -> compiles web/ into web/dist -# rust-builder rust -> compiles server/ into the tavolo binary -# runtime debian slim -> the binary + the compiled frontend +# web-builder rust + trunk -> compiles web/ into web/dist +# rust-builder rust (alpine) -> compiles server/ into a static musl binary +# runtime scratch -> static binary + compiled frontend # -# The backend embeds its sqlx migrations, serves /static and /assets itself -# and hosts the SPA shell, so the runtime image needs no Python, Granian or -# migration files. +# The runtime is a scratch image: the backend is a fully static musl binary, +# embeds its sqlx migrations and serves /static, /assets and the SPA shell +# itself, so no shell, package manager or libc is needed. +# +# TLS trust model: the OIDC client (reqwest 0.12) and Postgres (sqlx) validate +# against Mozilla's roots embedded in the binary by the webpki-roots crate, so +# no certificate files exist in the image. The OTLP exporter is built without +# a TLS backend, so OTEL_EXPORTER_OTLP_ENDPOINT must be http:// (terminate TLS +# in front of the collector if needed). +# +# There is deliberately no HEALTHCHECK (no curl in scratch): Kubernetes +# probes /api/health over HTTP, and Compose gates on its dependency checks. # # Build and run it with (from the repository root): # docker compose up --build @@ -47,8 +56,11 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/web/target \ trunk build --release --public-url /static/ -# --- Rust builder ------------------------------------------------------------ -FROM rust:1-slim-bookworm AS rust-builder +# --- Rust builder (static musl) ---------------------------------------------- +FROM rust:1-alpine AS rust-builder + +# ring (the only C dependency) compiles with the musl toolchain from build-base. +RUN apk add --no-cache build-base WORKDIR /build @@ -59,37 +71,24 @@ COPY server/crates ./crates # and the binary is copied out of the mount into the image layer. RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/build/target \ - cargo build --release --locked \ - && cp target/release/tavolo /tavolo + cargo build --release --locked --target x86_64-unknown-linux-musl \ + && cp target/x86_64-unknown-linux-musl/release/tavolo /tavolo -# --- Runtime ----------------------------------------------------------------- -FROM debian:bookworm-slim +# --- Runtime (scratch) -------------------------------------------------------- +FROM scratch -# The slim base ships no CA store; seed it from the builder so the HTTPS apt -# mirrors (and later, outbound TLS) can be verified. -COPY --from=web-builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt - -RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ - --mount=type=cache,target=/var/lib/apt,sharing=locked \ - { sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list 2>/dev/null || true; } \ - && { sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || true; } \ - && apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates curl tzdata \ - && rm -rf /var/lib/apt/lists/* \ - && addgroup --system app && adduser --system --ingroup app app - -COPY --from=rust-builder /tavolo /usr/local/bin/tavolo +# Fully static musl binary; no libc, shell or package manager in the image. +# Installed on PATH so `tavolo migrate` / `tavolo serve` work as commands. +COPY --from=rust-builder --chmod=755 /tavolo /usr/local/bin/tavolo # The compiled single-page application: hashed js/wasm/css under /static and # the card images under /assets (mounted by the server from STATIC_DIR). COPY --from=web-builder /web/dist /app/web/dist ENV STATIC_DIR=/app/web/dist -USER app +# Numeric user: scratch has no /etc/passwd and Kubernetes verifies runAsNonRoot. +USER 10001:10001 EXPOSE 8080 -HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \ - CMD curl -fsS http://127.0.0.1:8080/api/health || exit 1 - CMD ["tavolo", "serve"] diff --git a/server/README.md b/server/README.md index b7c44b0..f9bc6ce 100644 --- a/server/README.md +++ b/server/README.md @@ -50,6 +50,14 @@ WebSocket endpoint. OpenAPI documentation is served at `/api/docs` (`/api/openapi.json`). +The release image is a **scratch image** containing a fully static musl +binary and the compiled frontend: no shell, package manager or libc. It runs +as the numeric user `10001:10001` and has no Docker `HEALTHCHECK` (scratch has +no `curl`); Kubernetes probes `/api/health` over HTTP, and Compose gates on +the Postgres/Redis/OIDC dependency checks. To debug a running container use +`docker debug` (Docker Desktop) or `kubectl debug` with an ephemeral +container. + ## Configuration All configuration comes from environment variables (see `.env.example`): @@ -87,7 +95,7 @@ All configuration comes from environment variables (see `.env.example`): | `CORS_MAX_AGE` | `600` | Seconds browsers may cache the preflight response | | `OTEL_ENABLED` | `false` | `1`/`true`/`yes`/`on` enable OpenTelemetry traces, metrics and logs (compiled in by default) | | `OTEL_SERVICE_NAME` | `tavolo` | `service.name` resource attribute of the exported telemetry | -| `OTEL_EXPORTER_OTLP_ENDPOINT` | unset | Base URL of an OTLP/HTTP collector (e.g. `http://localhost:4318`); unset uses the exporter default | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | unset | Base URL of an OTLP/HTTP collector (e.g. `http://localhost:4318`; unset uses that default). Must be `http://`: the OTLP client is built without a TLS backend | | `OTEL_EXPORTER_OTLP_HEADERS` | unset | Comma-separated `key=value` headers sent to the collector (e.g. authentication) | | `OTEL_EXCLUDED_PATHS` | `/api/health` | Comma-separated paths excluded from tracing and metrics (exact matches) | | `OTEL_METRIC_EXPORT_INTERVAL_MILLIS` | `60000` | How often the OTLP metric reader exports | @@ -161,6 +169,13 @@ cluster-wide counts, in-memory stores the process's own). With multiple replicas each process reports the same global counts: aggregate with `max`, not `sum`. +The OTLP client is built without a TLS backend, so +`OTEL_EXPORTER_OTLP_ENDPOINT` must use `http://`; terminate TLS in front of +the collector (or run a local sidecar) if the collector is remote. The +OIDC/Postgres clients do not use this client and validate HTTPS against +Mozilla's roots embedded in the binary (no certificate files are needed, and +private CAs are not trusted by them). + ## Data model ### Redis (live games) @@ -411,7 +426,7 @@ crates/tavolo-app/ # deployable app: composition root (own crate) src/{main,app,config,cors,logging,static_files,telemetry}.rs migrations/ # embedded sqlx migrations dev/mockoauth/ # dev OIDC provider image (compose) -Dockerfile # web-builder + cargo + slim runtime +Dockerfile # web-builder + musl cargo + scratch runtime .env.example ```