mirror of
https://github.com/netbirdio/docs.git
synced 2026-10-02 11:49:05 +02:00
harden: non-root docs container with tini and a healthcheck (#844)
* harden: non-root container with tini, healthcheck, and a robust entrypoint entrypoint.sh: escape sed metacharacters (\, &, #) so any future DocSearch value substitutes literally instead of crashing the pass or corrupting the bundle; rewrite only files that still contain the placeholder, making restarts no-ops; on substitution failure, log a warning and start the server anyway rather than crash-looping under restart: unless-stopped. Dockerfile: run as the built-in non-root node user (artifacts chowned so the runtime sed keeps write access); add tini as PID 1 so SIGTERM actually reaches node and docker stop takes ~1s instead of the 10s kill grace; add a HEALTHCHECK so a dead server shows as unhealthy instead of silently Up. Container-tested: boots 202ms, all routes/assets/redirects OK, metacharacter-laden values injected literally, uid 1000, health reaches healthy, restart idempotent (no rewrites, value intact), docker stop 0s. * harden: substitute DocSearch placeholders independently; strip CR/LF from values Review findings: the three substitutions were &&-chained, so one failing value skipped the remaining valid ones; and a raw newline in a value breaks the single-line sed command. Run each substitution independently, accumulating failures into one warning, and strip CR/LF in escape() (no legitimate DocSearch token contains them). Container-tested: newline-laden appId is sanitized and all three placeholders still apply with no warning; forced full failure logs all three sed errors plus one warning and the server still starts healthy. --------- Co-authored-by: Brandon Hopkins <brandon@techhut.tv>
This commit is contained in:
co-authored by
Brandon Hopkins
parent
d77631a5ed
commit
8528b632ad
+25
-5
@@ -1,5 +1,12 @@
|
|||||||
FROM node:20-slim
|
FROM node:20-slim
|
||||||
|
|
||||||
|
# tini runs as PID 1 and forwards signals to node (which doesn't install its
|
||||||
|
# own SIGTERM handler, and as PID 1 would otherwise ignore it) — so
|
||||||
|
# `docker stop` terminates in ~1s instead of waiting out the 10s kill grace.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends tini \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /usr/app
|
WORKDIR /usr/app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
@@ -13,18 +20,31 @@ ENV PORT=3000
|
|||||||
# install here. The output is traced against the runner (Ubuntu/glibc), so this
|
# install here. The output is traced against the runner (Ubuntu/glibc), so this
|
||||||
# runtime image must also be glibc (node:20-slim, NOT alpine/musl) or the traced
|
# runtime image must also be glibc (node:20-slim, NOT alpine/musl) or the traced
|
||||||
# native binaries won't load.
|
# native binaries won't load.
|
||||||
COPY .next/standalone ./
|
#
|
||||||
|
# Files are chowned to the non-root `node` user because entrypoint.sh rewrites
|
||||||
|
# the DocSearch placeholders in .next with `sed -i` at runtime — that needs
|
||||||
|
# write access under the runtime user.
|
||||||
|
COPY --chown=node:node .next/standalone ./
|
||||||
# standalone does not include static assets or the public dir — copy them in.
|
# standalone does not include static assets or the public dir — copy them in.
|
||||||
COPY .next/static ./.next/static
|
COPY --chown=node:node .next/static ./.next/static
|
||||||
COPY public ./public
|
COPY --chown=node:node public ./public
|
||||||
|
|
||||||
COPY docker/entrypoint.sh ./entrypoint.sh
|
COPY --chown=node:node docker/entrypoint.sh ./entrypoint.sh
|
||||||
RUN chmod +x ./entrypoint.sh
|
RUN chmod +x ./entrypoint.sh
|
||||||
|
|
||||||
|
# Run as the base image's built-in non-root user (UID 1000). Port 3000 is
|
||||||
|
# unprivileged, so no extra capability is needed.
|
||||||
|
USER node
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Surfaces crash-loops and dead servers in `docker ps` / compose --wait /
|
||||||
|
# Watchtower instead of them sitting silently "Up".
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||||
|
CMD node -e "fetch('http://127.0.0.1:3000/').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
|
||||||
|
|
||||||
# entrypoint.sh substitutes the APP_NEXT_PUBLIC_DOCSEARCH_* placeholders baked
|
# entrypoint.sh substitutes the APP_NEXT_PUBLIC_DOCSEARCH_* placeholders baked
|
||||||
# into .next with real values from the container env, then execs the CMD.
|
# into .next with real values from the container env, then execs the CMD.
|
||||||
ENTRYPOINT ["/usr/app/entrypoint.sh"]
|
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/app/entrypoint.sh"]
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
+38
-8
@@ -1,17 +1,47 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
# this script will check for the following NEXT_* environment variables passed via Docker environment (-e) and apply them
|
# Substitutes the APP_NEXT_PUBLIC_DOCSEARCH_* placeholders baked into the
|
||||||
# to the Nextjs.
|
# client bundle at build time (from the committed .env) with the real values
|
||||||
# The properties that will be replaced and have to start with APP_ prefix in the .env file
|
# passed via the container environment, then starts the server.
|
||||||
|
#
|
||||||
|
# NEXT_PUBLIC_* values are compiled into the client bundle, so this rewrite is
|
||||||
|
# what lets one image serve any environment's DocSearch credentials.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
set -ex
|
|
||||||
NEXT_PUBLIC_DOCSEARCH_APP_ID=${NEXT_PUBLIC_DOCSEARCH_APP_ID:-"none"}
|
NEXT_PUBLIC_DOCSEARCH_APP_ID=${NEXT_PUBLIC_DOCSEARCH_APP_ID:-"none"}
|
||||||
NEXT_PUBLIC_DOCSEARCH_API_KEY=${NEXT_PUBLIC_DOCSEARCH_API_KEY:-"none"}
|
NEXT_PUBLIC_DOCSEARCH_API_KEY=${NEXT_PUBLIC_DOCSEARCH_API_KEY:-"none"}
|
||||||
NEXT_PUBLIC_DOCSEARCH_INDEX_NAME=${NEXT_PUBLIC_DOCSEARCH_INDEX_NAME:-"none"}
|
NEXT_PUBLIC_DOCSEARCH_INDEX_NAME=${NEXT_PUBLIC_DOCSEARCH_INDEX_NAME:-"none"}
|
||||||
|
|
||||||
find /usr/app/.next \( -type d -name .git -prune \) -o -type f -print0 | xargs -0 sed -i "s#APP_NEXT_PUBLIC_DOCSEARCH_APP_ID#${NEXT_PUBLIC_DOCSEARCH_APP_ID}#g"
|
# Escape the characters that are special in a sed replacement (\ and &) and
|
||||||
find /usr/app/.next \( -type d -name .git -prune \) -o -type f -print0 | xargs -0 sed -i "s#APP_NEXT_PUBLIC_DOCSEARCH_API_KEY#${NEXT_PUBLIC_DOCSEARCH_API_KEY}#g"
|
# our s### delimiter (#), so values containing them substitute literally.
|
||||||
find /usr/app/.next \( -type d -name .git -prune \) -o -type f -print0 | xargs -0 sed -i "s#APP_NEXT_PUBLIC_DOCSEARCH_INDEX_NAME#${NEXT_PUBLIC_DOCSEARCH_INDEX_NAME}#g"
|
# CR/LF are stripped first: a one-line s### command cannot carry a raw
|
||||||
|
# newline, and no legitimate DocSearch token contains one.
|
||||||
|
escape() {
|
||||||
|
printf '%s' "$1" | tr -d '\r\n' | sed -e 's/[\\&#]/\\&/g'
|
||||||
|
}
|
||||||
|
|
||||||
echo "starting Nextjs"
|
# Rewrite only the files that still contain the placeholder — after the first
|
||||||
|
# boot substituted everything, restarts touch nothing. grep exiting 1 on zero
|
||||||
|
# matches is fine: xargs -r then runs nothing and the pipeline succeeds.
|
||||||
|
substitute() {
|
||||||
|
grep -rlZ "$1" /usr/app/.next | xargs -0 -r sed -i "s#$1#$2#g"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Each substitution runs independently: one failing value must not stop the
|
||||||
|
# remaining placeholders from being applied.
|
||||||
|
ok=1
|
||||||
|
substitute APP_NEXT_PUBLIC_DOCSEARCH_APP_ID "$(escape "$NEXT_PUBLIC_DOCSEARCH_APP_ID")" || ok=0
|
||||||
|
substitute APP_NEXT_PUBLIC_DOCSEARCH_API_KEY "$(escape "$NEXT_PUBLIC_DOCSEARCH_API_KEY")" || ok=0
|
||||||
|
substitute APP_NEXT_PUBLIC_DOCSEARCH_INDEX_NAME "$(escape "$NEXT_PUBLIC_DOCSEARCH_INDEX_NAME")" || ok=0
|
||||||
|
|
||||||
|
if [ "$ok" = 1 ]; then
|
||||||
|
echo "DocSearch configuration applied"
|
||||||
|
else
|
||||||
|
# Serve the docs even if search wiring failed — a docs site with broken
|
||||||
|
# search beats a crash-looping container. The warning makes it visible.
|
||||||
|
echo "WARNING: DocSearch placeholder substitution failed; search may be broken" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "starting Next.js"
|
||||||
exec "$@"
|
exec "$@"
|
||||||
Reference in New Issue
Block a user