From e46297dcd4b8db69dddc6a9641431a741453ac7a Mon Sep 17 00:00:00 2001 From: David Boreham Date: Sun, 23 Aug 2026 16:32:26 -0600 Subject: [PATCH] Add support for simple auth --- Containerfile | 15 ++++++++ README.md | 41 ++++++++++++++++++++ nginx/default.conf | 23 +++++++++++ scripts/40-stack-auth.sh | 82 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 161 insertions(+) create mode 100644 nginx/default.conf create mode 100755 scripts/40-stack-auth.sh diff --git a/Containerfile b/Containerfile index a0a23a4..31438c8 100644 --- a/Containerfile +++ b/Containerfile @@ -2,3 +2,18 @@ FROM nginx:latest RUN rm -rf /usr/share/nginx/html/* + +# htpasswd, used at container start to hash credentials supplied in the +# environment. See scripts/40-stack-auth.sh. +RUN apt-get update \ + && apt-get install -y --no-install-recommends apache2-utils \ + && rm -rf /var/lib/apt/lists/* + +# Replaces the stock config, which serves the same content but has no place to +# put an authentication directive. +COPY nginx/default.conf /etc/nginx/conf.d/default.conf + +# nginx's entrypoint runs this before starting the server. +COPY scripts/40-stack-auth.sh /docker-entrypoint.d/40-stack-auth.sh +RUN chmod +x /docker-entrypoint.d/40-stack-auth.sh \ + && mkdir -p /etc/nginx/stack-auth diff --git a/README.md b/README.md index adf6d88..bfede09 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,50 @@ $ stack fetch repo bozemanpass/stack-wrapper-static-content $ stack webapp build --wrapper static-content --source-repo ~/my-static-site ``` +## Authentication + +The content is served to anyone by default. Setting a username and password in the +environment puts it behind HTTP basic authentication instead: + +``` +$ docker run -p 3000:80 -e STACK_AUTH_USER=alice -e STACK_AUTH_PASSWORD=secret +``` + +or, for a deployment made with the stack tool: + +``` +$ stack init --stack my-site --output spec.yml \ + --config STACK_AUTH_USER=alice --config STACK_AUTH_PASSWORD=secret +``` + +| Variable | Meaning | +|----------|---------| +| `STACK_AUTH_USER`, `STACK_AUTH_PASSWORD` | One credential. The password is hashed when the container starts; the two must be set together or the container refuses to start. | +| `STACK_AUTH_HTPASSWD` | The content of an htpasswd file, already hashed — several users, without a plaintext password in the environment. Additive with the pair above. | +| `STACK_AUTH_REALM` | The name the browser's prompt shows. Defaults to `Restricted`. | +| `STACK_AUTH_EXCLUDE` | Space-separated path prefixes served without credentials. | + +Two things worth knowing: + +- **This is configurable after deployment.** The variables are read at container start, + not baked into the image, so a site that was deployed without authentication is gated by + adding them to the deployment's `config.env` and running `stack manage --dir + update`, which applies environment changes on every target. Removing them again ungates + it. The image is the same either way. +- **A composefile healthcheck needs `STACK_AUTH_EXCLUDE`.** On Kubernetes a healthcheck + becomes the container's liveness probe, and a probe answered with a 401 restarts the pod + for as long as authentication is configured. Point the healthcheck at a path named in + `STACK_AUTH_EXCLUDE`. + +Basic authentication sends the password with every request, protected by nothing but +base64, so it is only worth having over HTTPS. See the stack tool's `docs/ingress.md` for +serving a deployment over TLS. + ## Contents - `wrapper.yml` — the wrapper manifest (see the stack tool's `docs/wrappers.md`) - `Containerfile` — build for the `bozemanpass/static-content-base` base image - `Containerfile.app` — wraps the app source into a servable image (build context is the app repository) - `build.sh` — build script invoked by the stack tool +- `nginx/default.conf` — the served site's nginx configuration +- `scripts/40-stack-auth.sh` — run by nginx's entrypoint, configures authentication from the environment diff --git a/nginx/default.conf b/nginx/default.conf new file mode 100644 index 0000000..56a080b --- /dev/null +++ b/nginx/default.conf @@ -0,0 +1,23 @@ +server { + listen 80; + server_name localhost; + + # At the server level rather than in `location /` (where the stock config puts + # it) so that every location has it, including the ones written for + # STACK_AUTH_EXCLUDE, which would otherwise have no document root and answer + # 404 for content that is really there. + root /usr/share/nginx/html; + + # Authentication, when it has been configured. The file this picks up is + # written at container start by /docker-entrypoint.d/40-stack-auth.sh from the + # STACK_AUTH_* environment; when no credentials are configured nothing is + # written, the glob matches nothing, and the content is served to anyone -- + # which is what this image did before authentication existed. + include /etc/nginx/stack-auth/*.conf; + + location / { + index index.html index.htm; + } + + error_page 500 502 503 504 /50x.html; +} diff --git a/scripts/40-stack-auth.sh b/scripts/40-stack-auth.sh new file mode 100755 index 0000000..2a1f2e4 --- /dev/null +++ b/scripts/40-stack-auth.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Put the served content behind HTTP basic authentication, when the environment +# asks for it. +# +# Run by nginx's own entrypoint before the server starts, so the gate is a +# property of the image rather than of how the container was invoked: a wrapper +# contributes nothing to a deployment but an image, and the composefile that +# names the wrapped image belongs to whoever wrote the stack. Reading the +# environment at start is also what makes this configurable after the fact -- +# adding the variables to a deployment's config.env and running `stack manage +# update` is a change to the container's environment, which is exactly what +# `update` applies. +# +# STACK_AUTH_USER, STACK_AUTH_PASSWORD one credential, hashed here +# STACK_AUTH_HTPASSWD htpasswd file content, already hashed +# STACK_AUTH_REALM the name the browser prompt shows +# STACK_AUTH_EXCLUDE paths to leave open, space separated +# +# With none of them set the content is served to anyone, as it always was. +set -e +if [ -n "$STACK_SCRIPT_DEBUG" ]; then + set -x +fi + +auth_dir=/etc/nginx/stack-auth +conf_file=$auth_dir/auth.conf +htpasswd_file=$auth_dir/htpasswd + +mkdir -p "$auth_dir" +# Written from scratch on every start. A container that is restarted rather than +# recreated still has what the last start wrote, and leaving that in place would +# go on demanding credentials the current environment no longer configures. +rm -f "$conf_file" "$htpasswd_file" + +if [ -z "$STACK_AUTH_USER" ] && [ -z "$STACK_AUTH_PASSWORD" ] && [ -z "$STACK_AUTH_HTPASSWD" ]; then + echo "static-content: no authentication configured, the content is served to anyone." + echo "static-content: set STACK_AUTH_USER and STACK_AUTH_PASSWORD to require credentials." + exit 0 +fi + +# Half a credential is a misconfiguration either way, and both guesses are bad: +# taking the user alone serves the content unauthenticated, and taking the +# password alone locks it away behind a name nobody knows. Refuse to start. +if [ -n "$STACK_AUTH_USER" ] && [ -z "$STACK_AUTH_PASSWORD" ]; then + echo "static-content: STACK_AUTH_USER is set but STACK_AUTH_PASSWORD is not" >&2 + exit 1 +fi +if [ -n "$STACK_AUTH_PASSWORD" ] && [ -z "$STACK_AUTH_USER" ]; then + echo "static-content: STACK_AUTH_PASSWORD is set but STACK_AUTH_USER is not" >&2 + exit 1 +fi + +touch "$htpasswd_file" +if [ -n "$STACK_AUTH_HTPASSWD" ]; then + printf '%s\n' "$STACK_AUTH_HTPASSWD" >> "$htpasswd_file" +fi +if [ -n "$STACK_AUTH_USER" ]; then + # Appending, so that a single credential and a supplied file are additive + # rather than one of them silently winning. + htpasswd -bB "$htpasswd_file" "$STACK_AUTH_USER" "$STACK_AUTH_PASSWORD" > /dev/null 2>&1 +fi + +# The workers read this file, and they do not run as root. +chown root:nginx "$htpasswd_file" +chmod 640 "$htpasswd_file" + +{ + echo "auth_basic \"${STACK_AUTH_REALM:-Restricted}\";" + echo "auth_basic_user_file ${htpasswd_file};" + # An excluded path is a prefix, and is served without credentials. A + # composefile healthcheck is the usual reason to want one: on Kubernetes it + # becomes the container's liveness probe, and a probe that is answered with a + # 401 restarts the pod for as long as authentication is configured. + for path in $STACK_AUTH_EXCLUDE; do + echo "location ${path} { auth_basic off; }" + done +} > "$conf_file" + +echo "static-content: authentication enabled, $(grep -c . "$htpasswd_file") credential(s), realm \"${STACK_AUTH_REALM:-Restricted}\"." +for path in $STACK_AUTH_EXCLUDE; do + echo "static-content: ${path} is served without credentials." +done