From 13e3796d261c6dd410c2fdee5bc98ac5263aaea3 Mon Sep 17 00:00:00 2001 From: Joshua Date: Thu, 24 Sep 2026 12:21:53 -0400 Subject: [PATCH] Feature 24: commit the admin PWA hosting artifacts and runbook section Make the live admin PWA deployment reproducible from the repository: - deploy/pwa/Dockerfile: multi-stage build (node:22-alpine builds admin/ with the VITE_* build args, failing fast if any is empty; nginx:alpine pinned to the digest running on the API box serves dist/). - deploy/pwa/Dockerfile.dockerignore: limits the repo-root context to admin/ sources and nginx.conf (the root .dockerignore excludes deploy/). - deploy/pwa/nginx.conf: SPA fallback to index.html, root-served /sw.js and /manifest.webmanifest, allow-list 10.60.1.33 and 10.60.1.14 only. - deploy/compose.yaml: pwa service on NESTQUEST_BIND_ADDRESS:8081 with the build args from deploy/.env; api and litestream unchanged. - deploy/.env.example: the three public PWA build values. - deploy/RUNBOOK.md: section 10 (PWA build/serve, nestquest-app Traefik router, verification) and the router priorities in section 3. Co-Authored-By: Claude Opus 5.5 --- deploy/.env.example | 11 +++ deploy/RUNBOOK.md | 113 ++++++++++++++++++++++++++--- deploy/compose.yaml | 19 +++++ deploy/pwa/Dockerfile | 29 ++++++++ deploy/pwa/Dockerfile.dockerignore | 11 +++ deploy/pwa/nginx.conf | 53 ++++++++++++++ 6 files changed, 226 insertions(+), 10 deletions(-) create mode 100644 deploy/pwa/Dockerfile create mode 100644 deploy/pwa/Dockerfile.dockerignore create mode 100644 deploy/pwa/nginx.conf diff --git a/deploy/.env.example b/deploy/.env.example index 6c117b5..9b521cb 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -21,3 +21,14 @@ NESTQUEST_OIDC_JWKS_URL= # unset; on the API box set the LAN address the reverse proxy # forwards to, e.g. 10.60.1.14. NESTQUEST_BIND_ADDRESS= + +# Admin PWA build args (compose interpolation into the pwa service's +# VITE_* build args; baked into the bundle, so rebuild after changing). +# PUBLIC values, not secrets: the Authentik application is a PKCE client +# with no client secret. All three are required — the pwa image build +# fails when any is empty. +# NESTQUEST_AUTHENTIK_ISSUER=https://auth.cubecraftlabs.com/application/o/nestquest/ +# NESTQUEST_API_BASE_URL=https://nestquest.cubecraftlabs.com +NESTQUEST_AUTHENTIK_CLIENT_ID= +NESTQUEST_AUTHENTIK_ISSUER= +NESTQUEST_API_BASE_URL= diff --git a/deploy/RUNBOOK.md b/deploy/RUNBOOK.md index 5a0cc65..8b03d53 100644 --- a/deploy/RUNBOOK.md +++ b/deploy/RUNBOOK.md @@ -8,7 +8,7 @@ given, never their content. | Piece | Where | Real value | | --- | --- | --- | -| API box (Docker host) | this repository's `deploy/` | `10.60.1.14`, port `8080` | +| API box (Docker host) | this repository's `deploy/` | `10.60.1.14`, port `8080` (API), `8081` (admin PWA, section 10) | | Reverse proxy / TLS | shared Traefik on `nukapi-03` | `10.60.1.33` | | Public hostname | Cloudflare DNS | `nestquest.cubecraftlabs.com` | | Identity | Authentik | `https://auth.cubecraftlabs.com` | @@ -57,12 +57,13 @@ section 5 depend on uid 999): ## 2. Compose -`deploy/compose.yaml` defines two services and one named volume: +`deploy/compose.yaml` defines three services and one named volume: | Service | Image | Role | | --- | --- | --- | | `api` | `nestquest-api:latest` (built, section 1) | FastAPI service on `8080`, healthcheck on `/health` | | `litestream` | `litestream/litestream:0.5.17` | Continuous SFTP backup of the database (section 7); starts only once `api` is healthy | +| `pwa` | `nestquest-pwa:latest` (built from `deploy/pwa`, section 10) | Admin PWA served by nginx on `8081`; independent of the other two | - **Volume `nestquest-db`** is mounted at `/var/lib/nestquest` in both services. The database is `NESTQUEST_DB_PATH=/var/lib/nestquest/nestquest.db` @@ -96,14 +97,16 @@ repository. What it must contain: | Router | Rule | Middleware | Service | | --- | --- | --- | --- | -| panel | ``Host(`nestquest.cubecraftlabs.com`) && PathPrefix(`/api/v1/panel`)`` | `ipAllowList`, `sourceRange: ["10.60.1.80/32"]` (HA box only) | `http://10.60.1.14:8080` | -| admin (everything else: `/health`, `/docs`, `/openapi.json`, `/api/v1/admin/*`) | ``Host(`nestquest.cubecraftlabs.com`)`` | none — public; the API itself enforces Authentik JWTs (section 4) | `http://10.60.1.14:8080` | +| panel (priority 300) | ``Host(`nestquest.cubecraftlabs.com`) && PathPrefix(`/api/v1/panel`)`` | `ipAllowList`, `sourceRange: ["10.60.1.80/32"]` (HA box only) | `http://10.60.1.14:8080` | +| admin (priority 200: `/api/*` other than the panel, `/health`, `/openapi.json`, `/docs`) | ``Host(`nestquest.cubecraftlabs.com`) && (PathPrefix(`/api`) \|\| Path(`/health`) \|\| Path(`/openapi.json`) \|\| PathPrefix(`/docs`))`` | none — public; the API itself enforces Authentik JWTs (section 4) | `http://10.60.1.14:8080` | +| app (priority 100: everything else — the admin PWA) | see section 10 | none at Traefik; nginx allow-lists the proxy | `http://10.60.1.14:8081` | -Both routers use the HTTPS entrypoint `websecure` with `tls.certResolver: myresolver` -(HTTP is redirected to HTTPS by the shared Traefik). The panel router's -rule is longer, so Traefik's default rule-length priority evaluates it -before the catch-all admin router; if you set explicit priorities, keep -panel higher. +All routers use the HTTPS entrypoint `websecure` with `tls.certResolver: myresolver` +(HTTP is redirected to HTTPS by the shared Traefik). Priorities are set +explicitly so the order never depends on rule length: panel above admin, +and the PWA's `nestquest-app` router below both. The admin router is +scoped to the API's paths — a bare ``Host(...)`` rule at a higher +priority would swallow `/` and every PWA route. Reference shape, to rebuild the file if it is ever lost (entrypoint names must match the shared Traefik's static config on nukapi-03): @@ -113,17 +116,26 @@ http: routers: nestquest-panel: rule: "Host(`nestquest.cubecraftlabs.com`) && PathPrefix(`/api/v1/panel`)" + priority: 300 entryPoints: [websecure] middlewares: [nestquest-panel-allowlist] service: nestquest-api tls: certResolver: myresolver nestquest-admin: - rule: "Host(`nestquest.cubecraftlabs.com`)" + rule: "Host(`nestquest.cubecraftlabs.com`) && (PathPrefix(`/api`) || Path(`/health`) || Path(`/openapi.json`) || PathPrefix(`/docs`))" + priority: 200 entryPoints: [websecure] service: nestquest-api tls: certResolver: myresolver + nestquest-app: + rule: "Host(`nestquest.cubecraftlabs.com`) && !PathPrefix(`/api`) && !Path(`/health`) && !Path(`/openapi.json`) && !PathPrefix(`/docs`)" + priority: 100 + entryPoints: [websecure] + service: nestquest-pwa + tls: + certResolver: myresolver middlewares: nestquest-panel-allowlist: ipAllowList: @@ -133,6 +145,10 @@ http: loadBalancer: servers: - url: "http://10.60.1.14:8080" + nestquest-pwa: + loadBalancer: + servers: + - url: "http://10.60.1.14:8081" ``` **Certificates.** `myresolver` is the shared Traefik's ACME (Let's @@ -453,3 +469,80 @@ To obtain `$ADMIN_JWT`: sign in to the admin PWA as the user, open the browser developer tools, and copy the session-storage value `nestquest.access_token`. Tokens are short-lived and are credentials — do not store them. + +## 10. Admin PWA hosting + +The admin PWA (`admin/`, a Vite + React single-page app) is served from +the API box by the compose service **`pwa`**: nginx on container port +`80`, published on `${NESTQUEST_BIND_ADDRESS:-127.0.0.1}:8081` (on the API +box `10.60.1.14:8081`). Traefik sends every path of +`nestquest.cubecraftlabs.com` that is not the API to it. + +**Image** — `deploy/pwa/Dockerfile`, built from the **repository root** +(it needs `admin/`); `deploy/pwa/Dockerfile.dockerignore` limits the +context to `admin/` (without `node_modules`, `dist` or `.env` files) and +`deploy/pwa/nginx.conf`. + +- Stage 1, `node:22-alpine` (pinned by digest): `npm ci`, then + `npm run build` with the three `VITE_*` build args below. Vite bakes + them into the bundle, so **changing any of them needs a rebuild**. The + build fails if any is empty. +- Stage 2, `nginx:alpine` pinned to + `sha256:1ed1b0e1d7652937d6cbdaf4018c7b6fc009a7dd6c3047351e2eddda745de43f` + (nginx 1.31.6): the built `dist/` in `/usr/share/nginx/html` and + `deploy/pwa/nginx.conf` as `conf.d/default.conf`. + +**Build args** — compose reads them from `deploy/.env` (documented in +`deploy/.env.example`). They are **public values, not secrets**: the +Authentik application is a PKCE client with no client secret. + +| `deploy/.env` variable | Build arg | Production value | +| --- | --- | --- | +| `NESTQUEST_AUTHENTIK_CLIENT_ID` | `VITE_AUTHENTIK_CLIENT_ID` | client id of the Authentik NestQuest application (Authentik admin → Applications → Providers) | +| `NESTQUEST_AUTHENTIK_ISSUER` | `VITE_AUTHENTIK_ISSUER` | `https://auth.cubecraftlabs.com/application/o/nestquest/` | +| `NESTQUEST_API_BASE_URL` | `VITE_API_BASE_URL` | `https://nestquest.cubecraftlabs.com` | + +The Authentik application's redirect URI must include +`https://nestquest.cubecraftlabs.com/auth/callback`. + +**nginx** (`deploy/pwa/nginx.conf`): + +- `allow 10.60.1.33; allow 10.60.1.14; deny all;` — only Traefik on + nukapi-03 and the API box itself get content; any other source gets + `403`, even with the port reachable on the LAN. Checks run on the API + box must use its LAN address (`10.60.1.14:8081`), not `127.0.0.1`. +- SPA fallback: unknown paths (e.g. `/auth/callback`) serve `index.html`; + `/assets/*` (content-hashed, cached for a year) returns a real `404` + when missing. +- `/sw.js` and `/manifest.webmanifest` (`application/manifest+json`) are + served from the site root with `Cache-Control: no-cache`, as is + `index.html`, so a new release's service worker is picked up. + +Build and start (the `api` and `litestream` services are unaffected): + + dc build pwa + dc up -d pwa + dc ps pwa # "Up" + +**Traefik router** — in `config/dynamic/nestquest-ccl.yml` on nukapi-03 +(reference shape in section 3): + +| Router | Rule | Priority | Service | +| --- | --- | --- | --- | +| `nestquest-app` | ``Host(`nestquest.cubecraftlabs.com`) && !PathPrefix(`/api`) && !Path(`/health`) && !Path(`/openapi.json`) && !PathPrefix(`/docs`)`` | `100` — below `nestquest-admin` (200) and `nestquest-panel` (300) | `nestquest-pwa` → `http://10.60.1.14:8081` | + +Same `websecure` entrypoint and `myresolver` certificate as the API +routers; no Traefik middleware (nginx enforces the source allow-list). +The file provider reloads the dynamic file on save. + +**Verification** (in addition to section 9): + +| # | Check | Command | Expect | +| --- | --- | --- | --- | +| 1 | PWA root over TLS | `curl -s -w '\n%{http_code}\n' https://nestquest.cubecraftlabs.com/ \| tail -3` | `200`, the `NestQuest Admin` shell | +| 2 | Client route fallback | `curl -s -o /dev/null -w '%{http_code} %{content_type}\n' https://nestquest.cubecraftlabs.com/auth/callback` | `200 text/html` | +| 3 | Manifest and worker | `curl -s -o /dev/null -w '%{http_code} %{content_type}\n' https://nestquest.cubecraftlabs.com/manifest.webmanifest` and the same for `/sw.js` | `200 application/manifest+json`, `200 application/javascript` | +| 4 | API still routed to the API | `curl -s -o /dev/null -w '%{http_code}\n' https://nestquest.cubecraftlabs.com/health` | `200` (`{"status":"ok"}`, not HTML) | +| 5 | Panel refused externally | section 9 check 4 | `403` | +| 6 | Non-Traefik source refused | from a LAN machine other than `10.60.1.33`/`10.60.1.14`: `curl -s -o /dev/null -w '%{http_code}\n' http://10.60.1.14:8081/` | `403` (nginx `deny all`) | +| 7 | Proxy source allowed | on `10.60.1.14`: `curl -s -o /dev/null -w '%{http_code}\n' http://10.60.1.14:8081/` | `200` | diff --git a/deploy/compose.yaml b/deploy/compose.yaml index 692df23..c2a1ea7 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -42,6 +42,25 @@ services: retries: 5 start_period: 15s + # Admin PWA (Feature 24): the Vite bundle from admin/ served by nginx + # (deploy/pwa/). Traefik routes every non-API path of the public host + # here on port 8081; nginx itself only accepts the proxy (10.60.1.33) + # and this host (10.60.1.14). The three build args are PUBLIC values + # (the OIDC client is a PKCE client, no secret) read from deploy/.env + # by compose interpolation; the image build fails if any is empty. + pwa: + build: + context: .. + dockerfile: deploy/pwa/Dockerfile + args: + VITE_AUTHENTIK_CLIENT_ID: ${NESTQUEST_AUTHENTIK_CLIENT_ID:-} + VITE_AUTHENTIK_ISSUER: ${NESTQUEST_AUTHENTIK_ISSUER:-} + VITE_API_BASE_URL: ${NESTQUEST_API_BASE_URL:-} + image: nestquest-pwa:latest + restart: unless-stopped + ports: + - "${NESTQUEST_BIND_ADDRESS:-127.0.0.1}:8081:80" + # Continuous backup: streams the SQLite database on the shared named # volume to the Synology NAS over SFTP (config: deploy/litestream.yml). # The SFTP private key deploy/litestream_key is git-ignored and must be diff --git a/deploy/pwa/Dockerfile b/deploy/pwa/Dockerfile new file mode 100644 index 0000000..c3afd64 --- /dev/null +++ b/deploy/pwa/Dockerfile @@ -0,0 +1,29 @@ +# Admin PWA image (Feature 24): builds the Vite bundle from admin/ and +# serves it with nginx. Build context is the REPOSITORY ROOT (it needs +# admin/); deploy/pwa/Dockerfile.dockerignore limits that context to +# admin/ and this directory's nginx.conf. +# +# docker compose -f deploy/compose.yaml build pwa +# +# The VITE_* values are baked into the bundle at build time. None is a +# secret (the OIDC client is a public PKCE client), and all three are +# required: the build fails fast rather than relying on app defaults. + +FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS build + +ARG VITE_AUTHENTIK_CLIENT_ID +ARG VITE_AUTHENTIK_ISSUER +ARG VITE_API_BASE_URL + +WORKDIR /src/admin +COPY admin/package.json admin/package-lock.json ./ +RUN npm ci +COPY admin/ ./ +RUN test -n "$VITE_AUTHENTIK_CLIENT_ID" && test -n "$VITE_AUTHENTIK_ISSUER" && test -n "$VITE_API_BASE_URL" \ + || { echo "VITE_AUTHENTIK_CLIENT_ID, VITE_AUTHENTIK_ISSUER and VITE_API_BASE_URL build args are required" >&2; exit 1; } +RUN npm run build + +FROM nginx:alpine@sha256:1ed1b0e1d7652937d6cbdaf4018c7b6fc009a7dd6c3047351e2eddda745de43f + +COPY deploy/pwa/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /src/admin/dist/ /usr/share/nginx/html/ diff --git a/deploy/pwa/Dockerfile.dockerignore b/deploy/pwa/Dockerfile.dockerignore new file mode 100644 index 0000000..ff55a9a --- /dev/null +++ b/deploy/pwa/Dockerfile.dockerignore @@ -0,0 +1,11 @@ +# Build-context filter for deploy/pwa/Dockerfile (BuildKit reads +# .dockerignore in place of the root .dockerignore, which +# excludes deploy/). Only admin/ sources and nginx.conf enter the +# build; local dependencies, build output and .env files never do. +* +!admin/ +!deploy/pwa/nginx.conf +admin/node_modules +admin/dist +**/.env +**/.env.* diff --git a/deploy/pwa/nginx.conf b/deploy/pwa/nginx.conf new file mode 100644 index 0000000..898ee60 --- /dev/null +++ b/deploy/pwa/nginx.conf @@ -0,0 +1,53 @@ +# nginx server for the NestQuest admin PWA (Feature 24). Replaces the +# image's /etc/nginx/conf.d/default.conf. The shared Traefik on +# nukapi-03 (10.60.1.33) terminates TLS for nestquest.cubecraftlabs.com +# and forwards every path that is not the API (/api, /health, +# /openapi.json, /docs) here; see deploy/RUNBOOK.md section 10. +server { + listen 80; + server_name _; + + # Only the reverse proxy and the API box itself may connect; every + # other source is refused (403) even if the port is reachable. + allow 10.60.1.33; + allow 10.60.1.14; + deny all; + + root /usr/share/nginx/html; + index index.html; + server_tokens off; + + # Content-hashed bundle files: cache forever, and a missing asset is + # a real 404 rather than the SPA shell. + location /assets/ { + try_files $uri =404; + add_header Cache-Control "public, max-age=31536000, immutable"; + } + + # The service worker must be revalidated on every load so a new + # release's worker (fresh revision, see admin/vite-sw-plugin.ts) is + # picked up. + location = /sw.js { + try_files $uri =404; + add_header Cache-Control "no-cache"; + } + + # nginx's stock mime.types has no .webmanifest entry. + location = /manifest.webmanifest { + types { } + default_type application/manifest+json; + try_files $uri =404; + add_header Cache-Control "no-cache"; + } + + # SPA fallback: client routes such as /auth/callback resolve to the + # app shell, which is never cached so it always names the current + # hashed assets. + location / { + try_files $uri $uri/ /index.html; + } + + location = /index.html { + add_header Cache-Control "no-cache"; + } +}