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
29 changes: 20 additions & 9 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions api/test/nginx-config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 18 additions & 3 deletions test/repo/image-hardening.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']) {
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion test/repo/readme-dependencies.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
6 changes: 4 additions & 2 deletions test/repo/supervisord-fatal.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
});
Expand Down
Loading