add CSP-clean static build, hardened nginx config, and container image

This commit is contained in:
2026-07-27 11:10:20 +10:00
parent e45f5729fa
commit a3c6d33ecf
6 changed files with 165 additions and 1 deletions
+1
View File
@@ -1,3 +1,4 @@
node_modules/
*.log
assets/work/
dist/
+22
View File
@@ -0,0 +1,22 @@
# syntax=docker/dockerfile:1
# Static teaching site: bash assembles dist/ from the split source, then a pinned
# nginx-unprivileged serves it. No Node build — build.sh is plain bash + coreutils.
# ---- build stage ----------------------------------------------------------
# Reusing the digest the site image already pins: it's a bookworm-slim base that is
# verified and cached in the zot pull-through mirror, so this layer costs nothing
# extra. Only bash/sed/find/install are used here — node itself is incidental.
FROM node:22-bookworm-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS build
WORKDIR /app
COPY . .
RUN bash build.sh
# ---- runtime stage --------------------------------------------------------
# Same vetted digest as the site. Renovate keeps it current.
FROM ghcr.io/nginx/nginx-unprivileged:1.28.0-alpine@sha256:c97ff0bf7cbae369953c6da1232ec14ad9f971d66360c5698db0856a4cd657a0
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
COPY nginx/security-headers.conf /etc/nginx/security-headers.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 8080
+2 -1
View File
@@ -4,7 +4,8 @@ Interactive scroll-driven teardown of Kubernetes / OpenShift / Docker, in the es
Bat-Computer visual world (void `#070b14`, cyan `#3fbaf5` → magenta `#e879f9`).
- **v2** (tag `v2`) — CSS `preserve-3d` diagrams. Parked: eyeballed px positions, misaligned at some widths.
- **v3** (in progress) — Nano Banana 2 exploded hero shots, hand-sliced into `clip-path` parts and
- **v3** (in progress) — ships PUBLIC at **learn.bztmon.com**.
- **v3 build** — Nano Banana 2 exploded hero shots, hand-sliced into `clip-path` parts and
scrubbed collapsed → hero with GSAP. Spec: `homelab/specs/SPEC-exploded-cluster-v3.md`.
## Layout
Executable
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Assemble dist/ — the CSP-clean static build served at learn.bztmon.com.
#
# The artifact build inlines GSAP and the app JS because the artifact CSP blocks every
# external request. nginx has no such constraint, so here they stay SEPARATE files:
# `script-src 'self'` holds with no 'unsafe-inline' exception, and the two GSAP bundles
# (116KB, unchanged between deploys) become independently cacheable.
set -euo pipefail
IFS=$'\n\t'
trap 'printf "build failed at line %s\n" "$LINENO" >&2' ERR
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
cd -- "$here"
dist="$here/dist"
src_markup="ec2-part1.html"
src_app="ec2-part2.html"
for f in "$src_markup" "$src_app" gsap.min.js st.min.js; do
[[ -f "$f" ]] || { printf 'missing source: %s\n' "$f" >&2; exit 1; }
done
rm -rf -- "$dist"
mkdir -p -- "$dist"
# --- app.js: strip the single <script> wrapper part2 carries -----------------
# part2 is exactly one <script>…</script> block; served standalone it must be raw JS.
sed -e '1{/^[[:space:]]*<script>[[:space:]]*$/d}' \
-e '${/^[[:space:]]*<\/script>[[:space:]]*$/d}' \
-- "$src_app" > "$dist/app.js"
if grep -qiE '</?script' -- "$dist/app.js"; then
printf 'app.js still contains a script tag — the wrapper strip did not match\n' >&2
exit 1
fi
# --- index.html: markup + external script refs -------------------------------
{
cat -- "$src_markup"
cat <<'HTML'
<script src="/gsap.min.js"></script>
<script src="/st.min.js"></script>
<script src="/app.js"></script>
</body>
</html>
HTML
} > "$dist/index.html"
# No inline JS may survive into the served HTML, or the CSP silently kills the page.
# Every <script> opening tag must carry a src=. (grep -E has no lookahead — list the
# tags, then assert none of them lacks src.)
if grep -oiE '<script[^>]*>' -- "$dist/index.html" | grep -qivE 'src='; then
printf 'index.html contains an inline <script> — CSP script-src self would block it\n' >&2
exit 1
fi
install -m 0644 -- gsap.min.js st.min.js "$dist/"
# --- scene assets ------------------------------------------------------------
if [[ -d assets/dist ]]; then
mkdir -p -- "$dist/assets"
find assets/dist -type f -print0 | while IFS= read -r -d '' f; do
install -m 0644 -- "$f" "$dist/assets/$(basename -- "$f")"
done
fi
printf 'built %s\n' "$dist"
find "$dist" -type f -printf ' %-22f %8s bytes\n' | sort
+51
View File
@@ -0,0 +1,51 @@
# nginx config for the static teaching site, baked into the image.
# Base: nginxinc/nginx-unprivileged (uid 101, listens on 8080).
# Read-only rootfs in k8s: /tmp and /var/cache/nginx are emptyDir mounts.
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
server_tokens off;
# Behind the Cloudflare Tunnel on :443 while nginx listens on :8080 — without these,
# nginx emits ABSOLUTE redirects to http://host:8080/... and breaks every link.
absolute_redirect off;
port_in_redirect off;
include /etc/nginx/security-headers.conf;
# ---- Caching -------------------------------------------------------------
# Scene images are content-hashed at build time → safe to cache hard.
location /assets/ {
include /etc/nginx/security-headers.conf;
expires 1y;
add_header Cache-Control "public, immutable" always;
}
# Scripts are NOT fingerprinted yet — revalidate so a deploy actually propagates.
location ~* \.js$ {
include /etc/nginx/security-headers.conf;
add_header Cache-Control "no-cache" always;
}
location ~* \.html$ {
include /etc/nginx/security-headers.conf;
add_header Cache-Control "no-cache" always;
}
# ---- Routing -------------------------------------------------------------
location / {
try_files $uri $uri/ $uri.html =404;
}
# ---- Compression ---------------------------------------------------------
# WebP/PNG are already compressed — gzipping them burns CPU for nothing.
gzip on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_vary on;
}
+19
View File
@@ -0,0 +1,19 @@
# Shared security headers. `include`d in the server block AND in every location that
# sets its own add_header — a location-level add_header REPLACES inherited headers
# rather than merging them (the classic nginx footgun).
#
# HSTS is host-scoped: NO includeSubDomains / preload, because the *.bztmon.com
# wildcard points elsewhere and forcing HTTPS estate-wide from here would be reckless.
# HTTP->HTTPS upgrade is Cloudflare's job.
add_header Strict-Transport-Security "max-age=31536000" always;
# script-src 'self' with NO 'unsafe-inline'. build.sh externalises every script and
# hard-fails if an inline <script> reaches dist/, so this holds without an exception.
# style-src needs 'unsafe-inline': GSAP animates by writing inline style attributes.
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;