diff --git a/frontend/goquery-ui/.env.development b/frontend/goquery-ui/.env.development deleted file mode 100644 index 4b1b3913..00000000 --- a/frontend/goquery-ui/.env.development +++ /dev/null @@ -1,3 +0,0 @@ -GQ_API_BASE_URL=http://localhost:8145 -HOST_RESOLVER_TYPES=gethosts -SSE_ON_LOAD=true diff --git a/frontend/goquery-ui/.gitignore b/frontend/goquery-ui/.gitignore index bbdd6abf..16a1305e 100644 --- a/frontend/goquery-ui/.gitignore +++ b/frontend/goquery-ui/.gitignore @@ -4,3 +4,6 @@ package-lock.json # deps and distribution node_modules/ dist/ + +# local runtime override for the prod compose (host-specific) +.env diff --git a/frontend/goquery-ui/Dockerfile b/frontend/goquery-ui/Dockerfile index c94db669..3ed8b43b 100644 --- a/frontend/goquery-ui/Dockerfile +++ b/frontend/goquery-ui/Dockerfile @@ -16,18 +16,35 @@ FROM caddy:2-alpine AS caddy-builder # Use Alpine as the base for better security and fewer vulnerabilities FROM alpine:3.19 -# Install ca-certificates for HTTPS and gettext for envsubst (env.js generation) -RUN apk add --no-cache ca-certificates gettext +# Install ca-certificates for HTTPS, gettext for envsubst (env.js generation), +# and libcap to strip file capabilities from the Caddy binary (see below). +RUN apk add --no-cache ca-certificates gettext libcap # Copy Caddy binary from the builder image COPY --from=caddy-builder /usr/bin/caddy /usr/bin/caddy +# The official Caddy binary carries cap_net_bind_service=+ep so it can bind to +# :80/:443 as non-root. BuildKit's COPY preserves that file capability. Under a +# hardened runtime (allowPrivilegeEscalation: false → no_new_privs), the kernel +# refuses to exec() a binary that carries file caps and returns EPERM +# ("caddy: Operation not permitted"). We listen on :5137 (non-privileged), so the +# capability is unnecessary — strip it to keep the binary execable when hardened. +RUN setcap -r /usr/bin/caddy + # Create a non-root user for running Caddy RUN addgroup -g 1000 caddy && \ adduser -D -u 1000 -G caddy -h /var/lib/caddy -s /sbin/nologin caddy RUN mkdir -p /var/run/env /opt/app/www /etc/caddy /data /config && \ chown -R caddy:caddy /var/run/env /opt/app/www /etc/caddy /data /config +# Point Caddy's storage at the dirs we create + back with writable volumes. +# The official caddy image sets these; our alpine base does not, so without them +# Caddy falls back to $HOME/.local/share & $HOME/.config — which are on the +# read-only rootfs in production, producing errors on startup. (The /data and +# /config volume mounts in the Helm chart / compose are inert until this is set.) +ENV XDG_DATA_HOME=/data \ + XDG_CONFIG_HOME=/config + # static files go to a read-only dir COPY --from=build --chown=caddy:caddy /app/dist /opt/app/www ## ensure protocol map is present as a static asset as well diff --git a/frontend/goquery-ui/Dockerfile.dev b/frontend/goquery-ui/Dockerfile.dev deleted file mode 100644 index 2c6b40d2..00000000 --- a/frontend/goquery-ui/Dockerfile.dev +++ /dev/null @@ -1,7 +0,0 @@ -# Dev image: pre-installs node_modules so `docker compose up` doesn't -# re-run npm ci on every start. Source is mounted at runtime. -FROM node:22-alpine -WORKDIR /app -COPY package.json package-lock.json* ./ -RUN npm ci --no-audit --no-fund -# Entrypoint is supplied by docker-compose (npm run dev) diff --git a/frontend/goquery-ui/Makefile b/frontend/goquery-ui/Makefile index 10ad9ad2..27b2e749 100644 --- a/frontend/goquery-ui/Makefile +++ b/frontend/goquery-ui/Makefile @@ -29,9 +29,9 @@ help: @echo " make generate-index - create a minimal index.html if missing" @echo " make types - fetch OpenAPI $(VERSION) and generate TS types ($(GENERATED_TYPES))" @echo " make regen - install + types + build" - @echo " make docker-dev - docker compose dev server (hot reload)" - @echo " make docker-stop - stop docker compose dev server" - @echo " make docker-logs - view docker compose dev logs" + @echo " make docker-up - run the hardened image via docker compose" + @echo " make docker-stop - stop the docker compose stack" + @echo " make docker-logs - view docker compose logs" @echo " make docker-build - build production image locally" # install: re-run if package.json or chosen lockfile changes @@ -81,7 +81,7 @@ serve: build .PHONY: dev dev: build - @echo "DEPRECATED: preferably use \"make docker-dev\"" + @echo "DEPRECATED: preferably use \"npm run dev\" (hot reload on :5173)" @echo "Run these in two terminals for live development:" \ "\n Terminal 1: make watch" \ "\n Terminal 2: make serve" \ @@ -95,10 +95,10 @@ clean: format: install $(NPM) exec -- prettier --write "src/**/*.{js,jsx,ts,tsx}" -.PHONY: docker-dev -docker-dev: - @echo "Starting Docker development server with hot reload..." - @echo "Access the application at http://localhost:5173" +.PHONY: docker-up +docker-up: + @echo "Starting the hardened Caddy image (mirrors the Helm chart securityContext)..." + @echo "Access the application at http://localhost:8080" docker compose up --build .PHONY: docker-stop diff --git a/frontend/goquery-ui/README.md b/frontend/goquery-ui/README.md index 3a435b9f..079b1555 100644 --- a/frontend/goquery-ui/README.md +++ b/frontend/goquery-ui/README.md @@ -15,25 +15,13 @@ npm ci ## Develop -Run with Docker Compose from this folder. - -### Dev (hot reload via webpack-dev-server) - -```bash -docker compose --profile dev up -``` - -Open - -### Without Docker - -Start webpack in watch mode and open `index.html` in a local static server (or your browser directly): +Hot reload via webpack-dev-server: ```bash npm run dev ``` -The bundle is emitted to `dist/` and `index.html` loads it. +Open ## Build @@ -45,8 +33,12 @@ npm run build ### Prod-like (Caddy serves built SPA with runtime env.js) +Runs the production image hardened to mirror the Helm chart's `securityContext` +(read-only rootfs, non-root, no-new-privileges, all caps dropped), so +deployment-time issues surface locally: + ```bash -docker compose --profile prod up --build +docker compose up --build ``` Open @@ -62,8 +54,7 @@ SSE_ON_LOAD=true Makefile shortcuts: ```bash -make docker-dev # same as compose dev profile -make docker-prod # same as compose prod profile +make docker-up # run the hardened image (docker compose up --build) make docker-build # build Caddy image locally ``` diff --git a/frontend/goquery-ui/docker-compose.yml b/frontend/goquery-ui/docker-compose.yml index 5bc80275..7da75e84 100644 --- a/frontend/goquery-ui/docker-compose.yml +++ b/frontend/goquery-ui/docker-compose.yml @@ -1,22 +1,61 @@ -# Local development only — hot reload with webpack dev server. -# Production builds use the self-contained Dockerfile directly. +# Production Caddy image (Dockerfile), hardened to mirror the goquery-ui Helm +# chart's securityContext so deployment-time issues (e.g. the Caddy +# file-capability / no-new-privileges EPERM) surface locally instead of in the +# cluster. Local hot-reload development is `npm run dev` (no container needed). # # Usage: -# docker compose up (or: make docker-dev) +# docker compose up --build (or: make docker-up) # docker compose down services: - dev: + goquery-ui: build: context: . - dockerfile: Dockerfile.dev - command: npm run dev + dockerfile: Dockerfile ports: - - "5173:5173" - env_file: - - .env.development + - "8080:5137" + # Runtime config — override via an optional .env next to this file. environment: - NODE_ENV: development + GQ_API_BASE_URL: "${GQ_API_BASE_URL:-}" + HOST_RESOLVER_TYPES: "${HOST_RESOLVER_TYPES:-string}" + SSE_ON_LOAD: "${SSE_ON_LOAD:-true}" + GQ_BACKEND_HOST: "${GQ_BACKEND_HOST:-}" + + # --- Hardening: 1:1 with charts/goquery-ui/values.yaml securityContext --- + # k8s runAsNonRoot/runAsUser/runAsGroup + user: "1000:1000" + # k8s readOnlyRootFilesystem: true + read_only: true + security_opt: + # k8s allowPrivilegeEscalation: false (sets no_new_privs) + - "no-new-privileges:true" + # k8s seccompProfile.type: RuntimeDefault has no compose line — Docker + # applies its default seccomp profile automatically (omitting it == default; + # only "seccomp:unconfined" would disable it). + cap_drop: + # k8s capabilities.drop: [ALL] + - ALL + # readOnlyRootFilesystem is on, so every path the entrypoint/Caddy writes + # to needs a writable mount — mirrors the chart's four emptyDir volumes. + # mode 01777 (sticky, world-writable) lets the non-root user (uid 1000) + # write: Docker mounts tmpfs over the image's pre-created dirs as root:755, + # and there is no docker equivalent of the chart's fsGroup to fix ownership. volumes: - - ./:/app:delegated - - /app/node_modules + - type: tmpfs + target: /var/run/env # generated env.js (served at /env.js) + tmpfs: { mode: 01777 } + - type: tmpfs + target: /tmp # entrypoint scratch (env.js template) + tmpfs: { mode: 01777 } + - type: tmpfs + target: /data # Caddy data dir + tmpfs: { mode: 01777 } + - type: tmpfs + target: /config # Caddy config dir + tmpfs: { mode: 01777 } + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:5137/"] + interval: 10s + timeout: 3s + retries: 5 + start_period: 5s