diff --git a/Dockerfile b/Dockerfile index 7ed8ceef..bf365157 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,24 +8,33 @@ COPY nginx/ ./nginx/ COPY scripts/bump-cache-busting.js ./scripts/bump-cache-busting.js RUN node scripts/bump-cache-busting.js -FROM node:24-alpine@sha256:44fce696f38ba8c987c4db89954051000bbd05ffd8d8659ba15ed4a8602e937f +FROM node:24-alpine@sha256:44fce696f38ba8c987c4db89954051000bbd05ffd8d8659ba15ed4a8602e937f AS base -LABEL org.opencontainers.image.title="Stackyard" \ - org.opencontainers.image.description="Self-hosted homelab dashboard" \ - org.opencontainers.image.source="https://github.com/SandObserver/stackyard" \ - org.opencontainers.image.licenses="Apache-2.0" - -# No package manager in the runtime image. The check fails the build if a base -# image change makes a path stop matching. +# No package manager and no C headers in the runtime image. The check fails the +# build if a base image change makes a path stop matching. RUN rm -rf /usr/local/lib/node_modules/npm \ /usr/local/lib/node_modules/corepack \ + /usr/local/include/node \ /opt/yarn-* && \ rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack \ /usr/local/bin/yarn /usr/local/bin/yarnpkg && \ if command -v npm || command -v npx || command -v yarn || command -v corepack; then \ echo "a package manager survived removal; check the base image layout" >&2; exit 1; \ fi && \ - node -e "process.exit(0)" + if [ -n "$(find / -xdev -name node.h)" ]; then \ + echo "the C headers survived removal; check the base image layout" >&2; exit 1; \ + fi && \ + node -e "process.exit(0)" && \ + find / -xdev \( -path /proc -o -path /sys -o -path /dev \) -prune -o -type d -exec touch -r /usr/local/bin/node {} + + +FROM scratch +COPY --from=base / / +ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +LABEL org.opencontainers.image.title="Stackyard" \ + org.opencontainers.image.description="Self-hosted homelab dashboard" \ + org.opencontainers.image.source="https://github.com/SandObserver/stackyard" \ + org.opencontainers.image.licenses="Apache-2.0" # Keep `apk upgrade` first. The base image is pinned by digest and gets no # security updates without it. @@ -41,6 +50,8 @@ RUN echo "packages upgraded for ${APP_VERSION}" && \ apk upgrade --no-cache && \ apk add --no-cache nginx supervisor && \ rm -f /etc/nginx/conf.d/default.conf /etc/nginx/http.d/default.conf && \ + sed -i 's/^worker_processes auto;$/worker_processes 1;/' /etc/nginx/nginx.conf && \ + grep -qx 'worker_processes 1;' /etc/nginx/nginx.conf && \ mkdir -p /var/log/nginx /var/log/supervisor /var/lib/nginx /run/nginx && \ # Users mount volumes here. Owned by node so the API can write config and # icons without running as root. diff --git a/api/test/nginx-config.test.js b/api/test/nginx-config.test.js index dc821a2f..1b9e3d8d 100644 --- a/api/test/nginx-config.test.js +++ b/api/test/nginx-config.test.js @@ -480,6 +480,12 @@ test('the image stamps assets itself, so a locally built image is not pinned to ); }); +test('the image runs one nginx worker, not one per host core', () => { + const dockerfile = fs.readFileSync(path.join(__dirname, '../../Dockerfile'), 'utf8'); + assert.match(dockerfile, /sed -i 's\/\^worker_processes auto;\$\/worker_processes 1;\/' \/etc\/nginx\/nginx\.conf/); + assert.match(dockerfile, /grep -qx 'worker_processes 1;' \/etc\/nginx\/nginx\.conf/); +}); + /* ── the API policy ───────────────────────────────────────────────────────── */ /* /api/ sets its own headers, so the server-level policy does not reach it. The diff --git a/test/repo/image-hardening.test.js b/test/repo/image-hardening.test.js index 28d229b8..41f9ce08 100644 --- a/test/repo/image-hardening.test.js +++ b/test/repo/image-hardening.test.js @@ -6,8 +6,13 @@ const path = require('node:path'); const root = path.join(__dirname, '..', '..'); const dockerfile = fs.readFileSync(path.join(root, 'Dockerfile'), 'utf8'); -test('the Dockerfile removes npm, corepack and yarn', () => { - for (const target of ['/usr/local/lib/node_modules/npm', '/usr/local/lib/node_modules/corepack', '/opt/yarn-*']) { +test('the Dockerfile removes npm, corepack, yarn and the C headers', () => { + for (const target of [ + '/usr/local/lib/node_modules/npm', + '/usr/local/lib/node_modules/corepack', + '/opt/yarn-*', + '/usr/local/include/node', + ]) { assert.ok(dockerfile.includes(target), `${target} is no longer removed`); } for (const bin of ['/usr/local/bin/npm', '/usr/local/bin/npx', '/usr/local/bin/corepack', '/usr/local/bin/yarn']) { @@ -18,6 +23,12 @@ test('the Dockerfile removes npm, corepack and yarn', () => { test('the removal is verified inside the build', () => { assert.match(dockerfile, /if command -v npm \|\| command -v npx \|\| command -v yarn \|\| command -v corepack; then/); assert.match(dockerfile, /a package manager survived removal/); + assert.match(dockerfile, /if \[ -n "\$\(find \/ -xdev -name node\.h\)" \]; then/); + assert.match(dockerfile, /the C headers survived removal/); +}); + +test('the copied base layer is the same for one base digest', () => { + assert.match(dockerfile, /-type d -exec touch -r \/usr\/local\/bin\/node \{\} \+$/m); }); test('node itself is still checked to work afterwards', () => { @@ -123,7 +134,11 @@ test('the upgrade cannot be served from a cache across releases', () => { /* The API runs as node. Code it owns, it can rewrite, and the change survives a restart. */ test('the API cannot rewrite its own code', () => { - const runtime = dockerfile.split(/^FROM /m).pop(); + const runtime = dockerfile + .split(/^FROM /m) + .slice(1) + .filter(stage => !/^\S+ AS assets$/m.test(stage.split('\n')[0])) + .join('\n'); const copies = runtime.split('\n').filter(l => /^COPY .*\/app\//.test(l)); assert.ok(copies.length > 0); for (const line of copies) assert.doesNotMatch(line, /--chown/, line); diff --git a/test/repo/readme-dependencies.test.js b/test/repo/readme-dependencies.test.js index a9f674d3..1827dc4c 100644 --- a/test/repo/readme-dependencies.test.js +++ b/test/repo/readme-dependencies.test.js @@ -24,5 +24,6 @@ test('the API declares no npm dependencies', () => { test('the image installs nginx and supervisor and no other package', () => { const adds = [...read('Dockerfile').matchAll(/apk add\s+((?:--\S+\s+)*)([^&\\\n]+)/g)].map(m => m[2].trim()); assert.deepEqual(adds, ['nginx supervisor']); - assert.match(read('Dockerfile'), /^FROM node:\d+-alpine@sha256:[0-9a-f]{64}$/m); + assert.match(read('Dockerfile'), /^FROM node:\d+-alpine@sha256:[0-9a-f]{64} AS base$/m); + assert.match(read('Dockerfile'), /^COPY --from=base \/ \/$/m); }); diff --git a/test/repo/supervisord-fatal.test.js b/test/repo/supervisord-fatal.test.js index 248341e1..132bbfd5 100644 --- a/test/repo/supervisord-fatal.test.js +++ b/test/repo/supervisord-fatal.test.js @@ -177,8 +177,10 @@ test('the rest of scripts/ is still kept out of the build context', () => { image would ship build tooling to every user. */ test('only the supervisor listener reaches the runtime image', () => { const runtime = read('Dockerfile') - .split(/^FROM node:24-alpine(?:@sha256:[0-9a-f]{64})?$/m) - .pop(); + .split(/^FROM /m) + .slice(1) + .filter(stage => !/^\S+ AS assets$/m.test(stage.split('\n')[0])) + .join('\n'); const copied = [...runtime.matchAll(/^COPY (?:--\S+ )*(scripts\/\S+)/gm)].map(m => m[1]); assert.deepEqual(copied, ['scripts/exit-on-fatal.py']); });