diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 406816ccc..600271673 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -247,7 +247,7 @@ jobs: # Blocking since v1.15.20 — a HIGH/CRITICAL finding in the built # image fails the job. For a finding without an upstream fix - # (e.g. a base-image CVE Alpine has not patched yet), add the CVE + # (e.g. a base-image CVE Debian has not patched yet), add the CVE # id to a `.trivyignore` file at the repository root — Trivy reads # it automatically — one CVE per line, each with a comment naming # the tracking issue and the re-check condition. Never suppress a diff --git a/Dockerfile b/Dockerfile index 9b6a9a6d9..21dc3d305 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,15 @@ +# ── Base image ───────────────────────────────────────────── +# Debian (glibc), not Alpine (musl), in every stage. The native modules the +# runtime loads (the @napi-rs/canvas Skia renderer, @node-rs/argon2, the +# Prisma schema engine) then run their glibc builds. The arm64 musl Skia +# build needs ARMv8.2 (LSE atomics, RCpc loads) and dies with SIGILL on an +# ARMv8.0 core such as the Raspberry Pi 4's Cortex-A72; the glibc arm64 +# build does not use those instructions (#1311). trixie over bookworm: on +# the day of the switch the bookworm-slim base carried a CRITICAL finding +# (zlib, marked will-not-fix) that trixie-slim does not. + # ── Stage 1: Dependencies ────────────────────────────────── -FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS deps +FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f AS deps RUN corepack enable && corepack prepare pnpm@11.15.1 --activate WORKDIR /app @@ -11,7 +21,7 @@ COPY scripts/prepare.mjs scripts/prepare.mjs RUN pnpm install --frozen-lockfile --prod=false # ── Stage 2: Build ───────────────────────────────────────── -FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS builder +FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f AS builder RUN corepack enable && corepack prepare pnpm@11.15.1 --activate WORKDIR /app @@ -67,23 +77,62 @@ RUN node scripts/verify-message-catalogs.mjs --dir messages \ --config src/lib/i18n/config.ts --write /app/.message-locales # ── Stage 3: Production runner ───────────────────────────── -FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS runner +FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f AS runner # `tzdata` is required so Europe/Berlin schedules (pg-boss cron, locale-aware # timestamp formatting) resolve to the actual offset instead of silently -# falling back to UTC on Alpine images that ship without it. +# falling back to UTC; the slim base ships without it. # -# `apk upgrade` pulls in OS-package security fixes the pinned base digest +# `apt-get upgrade` pulls in OS-package security fixes the pinned base digest # has not absorbed yet (the digest pin stays for build reproducibility; -# security patches from the Alpine repo override it deliberately — +# security patches from the Debian archive override it deliberately — # CVE-2026-14456 in openssl was the first instance the container scan # caught between base-image rebuilds). # -# `postgresql18-client` carries `pg_dump` and `pg_restore` for the nightly +# `postgresql-client-18` carries `pg_dump` and `pg_restore` for the nightly # database dump, the pre-migration dump in the entrypoint and the # `db-restore` service (docs/ops/disaster-recovery.md). Major 18 matches the # bundled database; a client dumps servers of its own major and older, so it -# also serves an install still on its own Postgres 16. -RUN apk add --no-cache tzdata postgresql18-client && apk upgrade --no-cache +# also serves an install still on its own Postgres 16. Debian trixie itself +# ships major 17, so the client comes from the PostgreSQL project's own apt +# repository (PGDG). Its signing key is fetched over HTTPS and must match the +# pinned SHA-256 before apt trusts it; the pin is the key with fingerprint +# B97B 0AFC AA1A 47F0 44F2 44A0 7FCC 7D46 ACCC 4CF8. If PGDG ever republishes +# the key file, check that fingerprint again before moving the pin. `node` +# fetches it, so no curl or wget lands in the image for this one download. +# `ca-certificates` is what apt needs for the HTTPS repository. +RUN set -eux; \ + export PGDG_ASC_SHA256=0144068502a1eddd2a0280ede10ef607d1ec592ce819940991203941564e8e76; \ + export DEBIAN_FRONTEND=noninteractive; \ + apt-get update; \ + apt-get upgrade -y --no-install-recommends; \ + apt-get install -y --no-install-recommends ca-certificates tzdata; \ + node -e " \ + const { createHash } = require('node:crypto'); \ + const { writeFileSync } = require('node:fs'); \ + fetch('https://www.postgresql.org/media/keys/ACCC4CF8.asc', { signal: AbortSignal.timeout(30000) }) \ + .then((r) => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); }) \ + .then((b) => { \ + const key = Buffer.from(b); \ + const sum = createHash('sha256').update(key).digest('hex'); \ + if (sum !== process.env.PGDG_ASC_SHA256) throw new Error('PGDG key sha256 ' + sum); \ + writeFileSync('/usr/share/keyrings/pgdg.asc', key); \ + }) \ + .catch((e) => { console.error(e); process.exit(1); });"; \ + printf '%s\n' \ + 'Types: deb' \ + 'URIs: https://apt.postgresql.org/pub/repos/apt' \ + 'Suites: trixie-pgdg' \ + 'Components: main' \ + 'Signed-By: /usr/share/keyrings/pgdg.asc' \ + > /etc/apt/sources.list.d/pgdg.sources; \ + apt-get update; \ + apt-get install -y --no-install-recommends postgresql-client-18; \ + apt-get clean; \ + rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* +# Debian installs each major's client under its own directory and puts a +# version-picking Perl wrapper on PATH. Name the directory, so `pg_dump` +# is the major 18 binary by construction, not by what the wrapper picks. +ENV PATH=/usr/lib/postgresql/18/bin:$PATH WORKDIR /app ENV NODE_ENV=production @@ -114,8 +163,9 @@ ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION ARG NEXT_PUBLIC_APP_BUILD_SHA ENV NEXT_PUBLIC_APP_BUILD_SHA=$NEXT_PUBLIC_APP_BUILD_SHA -RUN addgroup --system --gid 1001 nodejs && \ - adduser --system --uid 1001 nextjs +RUN groupadd --system --gid 1001 nodejs && \ + useradd --system --uid 1001 --gid nodejs --create-home --home-dir /home/nextjs \ + --shell /usr/sbin/nologin nextjs # Where the database dumps go (`DB_BACKUP_DIR`). Owned by the app user, so a # fresh named volume mounted here starts with that owner: Docker fills an @@ -163,8 +213,8 @@ RUN node -e "require.resolve('pg-boss'); require.resolve('pg')" # HOISTED to the top level there (verified: without it pdfjs logs "Cannot load # @napi-rs/canvas" → "Cannot polyfill DOMMatrix" → rasterize ReferenceError); # (2) @napi-rs/canvas's own loader then resolves its platform binary. Hoist BOTH -# the canvas package and the musl binary package to /app/node_modules/@napi-rs -# via symlink into the .pnpm store, and drop the prebuilt .node into the canvas +# the canvas package and the glibc (`-gnu`) binary package to +# /app/node_modules/@napi-rs via symlink into the .pnpm store, and drop the prebuilt .node into the canvas # dir: the loader tries that local file (`require('./skia..node')`) # before the platform package, so it decides which binary runs. # @@ -181,24 +231,24 @@ RUN set -e; \ CANVAS_VERSION="$(cat /tmp/.canvas-version)"; \ rm -f /tmp/.canvas-version; \ CANVAS_DIR="/app/node_modules/.pnpm/@napi-rs+canvas@${CANVAS_VERSION}/node_modules/@napi-rs/canvas"; \ - MUSL_DIR="$(find /app/node_modules/.pnpm -maxdepth 4 -type d -path "*@napi-rs+canvas-linux-*-musl@${CANVAS_VERSION}/node_modules/@napi-rs/canvas-linux-*-musl" 2>/dev/null | head -1)"; \ - if [ ! -d "$CANVAS_DIR" ] || [ -z "$MUSL_DIR" ]; then \ - echo "ERROR: pinned @napi-rs/canvas@${CANVAS_VERSION} or its musl binary did not reach the traced image."; \ + GNU_DIR="$(find /app/node_modules/.pnpm -maxdepth 4 -type d -path "*@napi-rs+canvas-linux-*-gnu@${CANVAS_VERSION}/node_modules/@napi-rs/canvas-linux-*-gnu" 2>/dev/null | head -1)"; \ + if [ ! -d "$CANVAS_DIR" ] || [ -z "$GNU_DIR" ]; then \ + echo "ERROR: pinned @napi-rs/canvas@${CANVAS_VERSION} or its glibc binary did not reach the traced image."; \ echo "Present instead:"; \ find /app/node_modules/.pnpm -maxdepth 1 -name '@napi-rs+canvas*' 2>/dev/null; \ exit 1; \ fi; \ - NODE_BIN="$(find "$MUSL_DIR" -maxdepth 1 -name 'skia.linux-*-musl.node' | head -1)"; \ - [ -n "$NODE_BIN" ] || { echo "ERROR: no skia binary in $MUSL_DIR"; exit 1; }; \ + NODE_BIN="$(find "$GNU_DIR" -maxdepth 1 -name 'skia.linux-*-gnu.node' | head -1)"; \ + [ -n "$NODE_BIN" ] || { echo "ERROR: no skia binary in $GNU_DIR"; exit 1; }; \ mkdir -p /app/node_modules/@napi-rs; \ ln -sfn "$CANVAS_DIR" /app/node_modules/@napi-rs/canvas; \ - ln -sfn "$MUSL_DIR" "/app/node_modules/@napi-rs/$(basename "$MUSL_DIR")"; \ + ln -sfn "$GNU_DIR" "/app/node_modules/@napi-rs/$(basename "$GNU_DIR")"; \ cp "$NODE_BIN" "$CANVAS_DIR/"; \ chown -R nextjs:nodejs /app/node_modules/@napi-rs; \ - BIN_VERSION="$(node -p "require('$MUSL_DIR/package.json').version")"; \ + BIN_VERSION="$(node -p "require('$GNU_DIR/package.json').version")"; \ [ "$BIN_VERSION" = "$CANVAS_VERSION" ] || { echo "ERROR: canvas binary is $BIN_VERSION, loader is $CANVAS_VERSION"; exit 1; }; \ cmp -s "$NODE_BIN" "$CANVAS_DIR/$(basename "$NODE_BIN")" || { echo "ERROR: the canvas dir holds a different binary"; exit 1; }; \ - echo "canvas hoisted for pdfjs: $CANVAS_DIR (binary $BIN_VERSION from $MUSL_DIR)" + echo "canvas hoisted for pdfjs: $CANVAS_DIR (binary $BIN_VERSION from $GNU_DIR)" # pdfjs-dist is `serverExternalPackages` (NOT bundled — the bundled copy's render # path breaks in the standalone image), so the server does a runtime bare @@ -340,7 +390,7 @@ RUN env -u DATABASE_URL healthlog-tsx scripts/repair-intake-anomalies.ts 2>&1 | # user so the runtime worker can WRITE it: a self-hoster who sets # MAXMIND_LICENSE_KEY on the published image has the databases fetched into # this directory at runtime (src/lib/geo/geolite2-fetch.ts) with no rebuild or -# manual mount. `tar`/`gzip` for the extraction ship in the busybox base. A +# manual mount. `tar`/`gzip` for the extraction are part of the Debian base. A # read-only bind mount at this path (the bring-your-own-database route) still # works — the runtime fetch just fails soft and the mounted files stand. RUN mkdir -p /opt/geolite2 && chown nextjs:nodejs /opt/geolite2 diff --git a/README.md b/README.md index 4c880ab87..c5f60968c 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ The App Store listing carries a small price, which exists to cover Apple's yearl ## Tech stack -Next.js 16 (App Router, React Server Components), TypeScript strict, PostgreSQL 18 with Prisma, pg-boss for jobs, Tailwind CSS 4 + shadcn/ui, Recharts, WebAuthn passkeys, Vitest + Playwright, multi-stage Alpine Docker image. The OpenAPI 3.1 contract for native clients is generated from the Zod schemas at [`docs/api/openapi.yaml`](docs/api/openapi.yaml). +Next.js 16 (App Router, React Server Components), TypeScript strict, PostgreSQL 18 with Prisma, pg-boss for jobs, Tailwind CSS 4 + shadcn/ui, Recharts, WebAuthn passkeys, Vitest + Playwright, multi-stage Debian (glibc) Docker image. The OpenAPI 3.1 contract for native clients is generated from the Zod schemas at [`docs/api/openapi.yaml`](docs/api/openapi.yaml). ## Documentation diff --git a/scripts/__tests__/release-workflows.test.ts b/scripts/__tests__/release-workflows.test.ts index c402102b8..61911c6a9 100644 --- a/scripts/__tests__/release-workflows.test.ts +++ b/scripts/__tests__/release-workflows.test.ts @@ -227,7 +227,7 @@ describe("release container inputs", () => { expect(fromLines).toHaveLength(3); expect(new Set(fromLines.map((line) => line.split(" AS ")[0]))).toEqual( new Set([ - "FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2", + "FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f", ]), ); }); diff --git a/src/__tests__/canvas-binary-hoist-guard.test.ts b/src/__tests__/canvas-binary-hoist-guard.test.ts index 45bcc3d85..56735461b 100644 --- a/src/__tests__/canvas-binary-hoist-guard.test.ts +++ b/src/__tests__/canvas-binary-hoist-guard.test.ts @@ -6,7 +6,7 @@ * * The pnpm store held two canvas versions: the one in package.json and an * older one that a second, older pdfjs-dist pulled in (gone since #1277). - * The runner stage picked the musl binary with `find | head -1`, got the + * The runner stage picked the platform binary with `find | head -1`, got the * old one, and copied it into the new loader's directory, where the loader * looks first. PDF rendering still worked, so nothing looked wrong; * `loadImage` called an `Image.decode` the old binary lacks, and every @@ -43,7 +43,7 @@ describe("canvas binary hoist", () => { ); }); - it("hoists the loader and the musl binary of that version only", () => { + it("hoists the loader and the glibc binary of that version only", () => { const runner = stage("runner"); expect(runner).toContain( "COPY --from=builder /app/.canvas-version /tmp/.canvas-version", @@ -51,12 +51,24 @@ describe("canvas binary hoist", () => { expect(runner).toContain( 'CANVAS_DIR="/app/node_modules/.pnpm/@napi-rs+canvas@${CANVAS_VERSION}/', ); - expect(runner).toContain("canvas-linux-*-musl@${CANVAS_VERSION}/"); + // The image is Debian: the glibc (`-gnu`) build is the one that runs, + // and the musl build, which needs ARMv8.2 on arm64 (#1311), is not. + expect(runner).toContain("canvas-linux-*-gnu@${CANVAS_VERSION}/"); + expect(runner).toContain("-name 'skia.linux-*-gnu.node'"); + expect(runner).not.toMatch(/-musl/); // A version-blind pick is the defect: any `canvas…@*` glob decides by // readdir order which of the store's copies wins. expect(runner).not.toMatch(/@napi-rs\+canvas[^"'\s]*@\*/); }); + it("builds every stage on the glibc Debian base", () => { + const froms = DOCKERFILE.match(/^FROM \S+/gm) ?? []; + expect(froms.length).toBeGreaterThanOrEqual(3); + for (const from of froms) { + expect(from).toMatch(/^FROM node:22-trixie-slim@sha256:[0-9a-f]{64}$/); + } + }); + it("fails the build when the copied binary is another version", () => { const runner = stage("runner"); expect(runner).toMatch(