623 lines
30 KiB
Markdown
623 lines
30 KiB
Markdown
# NestQuest API — deployment and restore runbook
|
||
|
||
Operator procedure to rebuild the production NestQuest API deployment end
|
||
to end from this repository, restore its database from backup, and verify
|
||
it. Every value below is the real, non-secret production value. **No
|
||
secret appears in this file** — secrets are named and their location is
|
||
given, never their content.
|
||
|
||
| Piece | Where | Real value |
|
||
| --- | --- | --- |
|
||
| 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` |
|
||
| Home Assistant box (panel client) | LAN | `10.60.1.80` |
|
||
| Local DNS | Pi-hole | `10.60.1.101` |
|
||
| Backup target | Synology NAS `CubeCraft-SRVR1`, SFTP | `10.60.1.50:/homes/Joshua/nestquest-backups` |
|
||
|
||
All `docker compose` commands below are run **on the API box, from the
|
||
repository root**. The shorthand `dc` means:
|
||
|
||
alias dc='docker compose -f deploy/compose.yaml'
|
||
|
||
Compose v2.24 or newer is required (`env_file` uses the `path`/`required`
|
||
form).
|
||
|
||
---
|
||
|
||
## 1. Image build
|
||
|
||
The image is defined by the root `Dockerfile` and is built from the
|
||
**repository root** as the build context: the API (`api/`) loads the
|
||
bundled domain core from `custom_components/nestquest/core/` by file
|
||
location, so both trees must be in the context. `.dockerignore` keeps
|
||
everything else (including `deploy/`, `.env` files and `*.db`) out of the
|
||
build.
|
||
|
||
- Multi-stage on `python:3.12-slim`: the builder installs
|
||
`api/requirements.txt`, the runtime copies only the installed packages
|
||
plus `api/` and `custom_components/nestquest/core/` into `/app`.
|
||
- Runs as the non-root system user `nestquest` (uid/gid `999`), which owns
|
||
`/var/lib/nestquest`.
|
||
- Command: `uvicorn --factory api.app:create_app --host 0.0.0.0 --port 8080`.
|
||
|
||
Get the code and build:
|
||
|
||
git clone ssh://code.cubecraftlabs.com:2288/Maniacs_Mansion/NestQuest.git
|
||
cd NestQuest
|
||
git checkout <release branch or tag being deployed>
|
||
dc build api # equivalent: docker build -t nestquest-api:latest .
|
||
|
||
Confirm the runtime user (the Litestream sidecar and the key ownership in
|
||
section 5 depend on uid 999):
|
||
|
||
docker run --rm --entrypoint id nestquest-api:latest
|
||
# uid=999(nestquest) gid=999(nestquest) groups=999(nestquest)
|
||
|
||
## 2. Compose
|
||
|
||
`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`
|
||
(fixed by compose, not a secret). `docker compose down` keeps named
|
||
volumes; **never run `down -v`** on the API box. Docker prefixes the
|
||
volume with the compose project name — with the default project
|
||
(`deploy`, the compose file's directory) it is `deploy_nestquest-db`;
|
||
confirm with `docker volume ls | grep nestquest-db`.
|
||
- **Published port**: `${NESTQUEST_BIND_ADDRESS:-127.0.0.1}:8080:8080`.
|
||
The default binds localhost only, so an unconfigured host is never
|
||
exposed. On the API box `deploy/.env` sets
|
||
`NESTQUEST_BIND_ADDRESS=10.60.1.14` so Traefik on nukapi-03 can reach
|
||
`http://10.60.1.14:8080`.
|
||
- **Secrets** come from `deploy/.env` (git-ignored, section 5) via
|
||
`env_file`.
|
||
|
||
Bring the stack up (after section 5's secrets exist):
|
||
|
||
dc up -d --build
|
||
dc ps # api "healthy", litestream "Up"
|
||
curl -s http://10.60.1.14:8080/health # {"status":"ok"}
|
||
|
||
## 3. TLS and reverse proxy (Traefik on nukapi-03)
|
||
|
||
TLS is terminated by the **shared** Traefik instance on `nukapi-03`
|
||
(`10.60.1.33`), not by this repository. Its NestQuest routing is the
|
||
owner-authored dynamic file-provider config
|
||
**`config/dynamic/nestquest-ccl.yml`** in the Traefik deployment on
|
||
nukapi-03 — that live file is authoritative; it is not stored in this
|
||
repository. What it must contain:
|
||
|
||
| Router | Rule | Middleware | Service |
|
||
| --- | --- | --- | --- |
|
||
| `nestquest-panel` (priority 200) | ``Host(`nestquest.cubecraftlabs.com`) && PathPrefix(`/api/v1/panel`)`` | `nestquest-panel-lan`: `ipAllowList`, `sourceRange: ["10.60.1.80/32"]` (HA box only) | `nestquest-api` → `http://10.60.1.14:8080` |
|
||
| `nestquest-admin` (priority 100: `/api/v1/admin`, `/health`, `/openapi.json`, `/docs`) | ``Host(`nestquest.cubecraftlabs.com`) && (PathPrefix(`/api/v1/admin`) \|\| Path(`/health`) \|\| Path(`/openapi.json`) \|\| PathPrefix(`/docs`))`` | `nestquest-headers` — no source restriction; the API itself enforces Authentik JWTs (section 4) | `nestquest-api` → `http://10.60.1.14:8080` |
|
||
| `nestquest-app` (priority 50: everything else — the admin PWA) | see section 10 | none at Traefik; nginx allow-lists the proxy | `nestquest-pwa` → `http://10.60.1.14:8081` |
|
||
|
||
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 (200) above
|
||
admin (100), and the PWA's `nestquest-app` router (50) below both. The
|
||
admin router is scoped to `/api/v1/admin`, `/health`, `/openapi.json`
|
||
and `/docs` — a bare ``Host(...)`` rule at a higher priority would
|
||
swallow `/` and every PWA route. Any other `/api/*` path matches no
|
||
router (the app router excludes `/api`), so Traefik answers `404`.
|
||
|
||
Reference shape, to rebuild the file if it is ever lost (entrypoint names
|
||
must match the shared Traefik's static config on nukapi-03):
|
||
|
||
```yaml
|
||
http:
|
||
routers:
|
||
nestquest-panel:
|
||
rule: "Host(`nestquest.cubecraftlabs.com`) && PathPrefix(`/api/v1/panel`)"
|
||
priority: 200
|
||
entryPoints: [websecure]
|
||
middlewares: [nestquest-panel-lan]
|
||
service: nestquest-api
|
||
tls:
|
||
certResolver: myresolver
|
||
nestquest-admin:
|
||
rule: "Host(`nestquest.cubecraftlabs.com`) && (PathPrefix(`/api/v1/admin`) || Path(`/health`) || Path(`/openapi.json`) || PathPrefix(`/docs`))"
|
||
priority: 100
|
||
entryPoints: [websecure]
|
||
middlewares: [nestquest-headers]
|
||
service: nestquest-api
|
||
tls:
|
||
certResolver: myresolver
|
||
nestquest-app:
|
||
rule: "Host(`nestquest.cubecraftlabs.com`) && !PathPrefix(`/api`) && !Path(`/health`) && !Path(`/openapi.json`) && !PathPrefix(`/docs`)"
|
||
priority: 50
|
||
entryPoints: [websecure]
|
||
service: nestquest-pwa
|
||
tls:
|
||
certResolver: myresolver
|
||
middlewares:
|
||
nestquest-panel-lan:
|
||
ipAllowList:
|
||
sourceRange: ["10.60.1.80/32"]
|
||
nestquest-headers:
|
||
# headers middleware; copy its definition from the live file
|
||
services:
|
||
nestquest-api:
|
||
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
|
||
Encrypt) resolver using the **Cloudflare DNS-01 challenge**; the
|
||
Cloudflare API token is held by the Traefik deployment on
|
||
nukapi-03, never in this repository. Because DNS-01 proves control
|
||
through a DNS TXT record, issuance and renewal need no inbound HTTP and
|
||
work regardless of the Cloudflare proxy setting. Certificates renew
|
||
automatically.
|
||
|
||
**Cloudflare proxy note.** Public traffic for
|
||
`nestquest.cubecraftlabs.com` resolves through Cloudflare, so a request
|
||
from the internet reaches Traefik from a Cloudflare edge address, never
|
||
from `10.60.1.80` — which is exactly why the panel allowlist refuses it
|
||
(403). The HA box does not go through Cloudflare at all: the local DNS
|
||
override in section 6 sends it straight to Traefik on the LAN, so Traefik
|
||
sees its real address. Do **not** add Cloudflare ranges to the panel
|
||
allowlist or make the allowlist trust `X-Forwarded-For`; that would let
|
||
any internet client through to the panel plane.
|
||
|
||
## 4. Authentik (admin identity)
|
||
|
||
Admin-plane requests (`/api/v1/admin/*`) carry an Authentik OIDC access
|
||
token (JWT). The API verifies issuer, audience, RS256 signature against
|
||
the JWKS, and expiry, then requires the `groups` claim to contain
|
||
`nestquest-admins` (`api/auth.py`). Configure in Authentik
|
||
(`https://auth.cubecraftlabs.com`, admin interface):
|
||
|
||
1. **Provider** — *OAuth2/OpenID Provider*:
|
||
- Client type: **Public** (no client secret; the admin PWA is a browser
|
||
SPA).
|
||
- PKCE: the PWA always uses the authorization-code flow with PKCE,
|
||
`code_challenge_method=S256`, and never sends a client secret.
|
||
- Redirect URI (strict): `https://nestquest.cubecraftlabs.com/auth/callback`.
|
||
- Signing key: an **RSA** certificate (the API only accepts `RS256`).
|
||
- Scopes: the default `openid`, `email`, `profile` mappings (the PWA
|
||
requests `openid email profile`; Authentik's default `profile`
|
||
mapping emits the `groups` claim the API checks).
|
||
- Note the generated **Client ID** — it is the audience below and the
|
||
PWA's `VITE_AUTHENTIK_CLIENT_ID`.
|
||
2. **Application** — slug **`nestquest`**, bound to the provider above.
|
||
The slug fixes the issuer URL.
|
||
3. **Group** — **`nestquest-admins`**. Add each parent's Authentik user.
|
||
Membership is the single source of truth for "who is an admin"; the
|
||
API refuses a signed-in non-member with 403.
|
||
|
||
API settings (in `deploy/.env`, section 5):
|
||
|
||
| Variable | Value |
|
||
| --- | --- |
|
||
| `NESTQUEST_OIDC_ISSUER` | `https://auth.cubecraftlabs.com/application/o/nestquest/` |
|
||
| `NESTQUEST_OIDC_AUDIENCE` | the provider's Client ID |
|
||
| `NESTQUEST_OIDC_JWKS_URL` | `https://auth.cubecraftlabs.com/application/o/nestquest/jwks/` |
|
||
|
||
Check them against Authentik's discovery document (the issuer must match
|
||
byte for byte, including the trailing slash):
|
||
|
||
curl -s https://auth.cubecraftlabs.com/application/o/nestquest/.well-known/openid-configuration \
|
||
| python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["issuer"]); print(d["jwks_uri"])'
|
||
|
||
## 5. Secrets
|
||
|
||
Nothing below is ever committed; `.gitignore` excludes `.env`,
|
||
`**/.env`, `deploy/*key*` and `deploy/litestream_key*`.
|
||
|
||
**`deploy/.env`** — created on the API box from the committed example:
|
||
|
||
cp deploy/.env.example deploy/.env
|
||
chmod 600 deploy/.env
|
||
# edit deploy/.env and fill in:
|
||
# NESTQUEST_PANEL_TOKEN = a new random token, e.g.
|
||
# python3 -c "import secrets; print(secrets.token_urlsafe(32))"
|
||
# (the same value is configured in the HA integration)
|
||
# NESTQUEST_OIDC_ISSUER = section 4
|
||
# NESTQUEST_OIDC_AUDIENCE = section 4 (Authentik Client ID)
|
||
# NESTQUEST_OIDC_JWKS_URL = section 4
|
||
# NESTQUEST_BIND_ADDRESS = 10.60.1.14
|
||
|
||
The API fails fast at startup if any of the four API
|
||
variables is unset or empty (`api/config.py`); `dc logs api` names the
|
||
missing one. `NESTQUEST_BIND_ADDRESS` is read by compose only.
|
||
|
||
**Litestream SFTP key `deploy/litestream_key`** — a dedicated keypair that
|
||
lives only on the API box. Create it once, before the first `up`:
|
||
|
||
ssh-keygen -t ed25519 -N "" -C nestquest-litestream -f deploy/litestream_key
|
||
sudo chown 999:999 deploy/litestream_key
|
||
chmod 600 deploy/litestream_key
|
||
|
||
Then append `restrict ` followed by the contents of
|
||
`deploy/litestream_key.pub` as one line to
|
||
`joshua@10.60.1.50:~/.ssh/authorized_keys` on the NAS. Compose mounts the
|
||
private key read-only at `/etc/litestream/sftp_key` in the sidecar, which
|
||
runs as uid 999 — hence the ownership. The NAS host key is pinned in the
|
||
committed `deploy/litestream.yml` (`host-key`, the NAS's ECDSA key), so no
|
||
`known_hosts` is involved.
|
||
|
||
| Secret | Supplied by | Consumed by |
|
||
| --- | --- | --- |
|
||
| `NESTQUEST_PANEL_TOKEN` | `deploy/.env` → `env_file` | `api` (panel plane); also set in the HA integration |
|
||
| `NESTQUEST_OIDC_*` (not secret, but environment-supplied) | `deploy/.env` → `env_file` | `api` (admin plane) |
|
||
| SFTP private key | `deploy/litestream_key` → read-only bind mount | `litestream` |
|
||
| Cloudflare DNS API token | Traefik deployment on nukapi-03 | Traefik ACME `myresolver` |
|
||
|
||
## 6. Network separation and firewall
|
||
|
||
- **Panel plane** (`/api/v1/panel/*`, static service token): reachable
|
||
only from the HA box. Traefik's `ipAllowList` on the panel router allows
|
||
`10.60.1.80/32` and returns **403** to every other source, before the
|
||
request reaches the API. The API additionally requires the panel token
|
||
(401 without it).
|
||
- **Admin plane** (`/api/v1/admin/*`) and `/health`, `/docs`: public
|
||
through Cloudflare and Traefik; admin routes are protected by the API's
|
||
Authentik JWT check (401 without a valid token, 403 without
|
||
`nestquest-admins` or when a panel token is presented).
|
||
- **Local DNS override** so the HA box reaches Traefik directly, keeping
|
||
its real source address: in Pi-hole (`10.60.1.101`) local DNS records,
|
||
add `nestquest.cubecraftlabs.com` → `10.60.1.33`. The HA box
|
||
must use Pi-hole as its resolver. Check from the HA box:
|
||
|
||
nslookup nestquest.cubecraftlabs.com 10.60.1.101 # Address: 10.60.1.33
|
||
|
||
- **Direct `:8080` access is firewalled.** The API box publishes `8080`
|
||
on `10.60.1.14`, which alone would let any LAN host bypass Traefik and
|
||
the panel `ipAllowList`. This is enforced on the API box in Docker's
|
||
`DOCKER-USER` chain (which Docker evaluates before its own forwarding
|
||
rules for published ports), in this order:
|
||
|
||
-A DOCKER-USER -s 10.60.1.33/32 -p tcp --dport 8080 -j RETURN # Traefik on nukapi-03
|
||
-A DOCKER-USER -s 10.60.1.14/32 -p tcp --dport 8080 -j RETURN # the API box itself
|
||
-A DOCKER-USER -p tcp --dport 8080 -j DROP # every other source
|
||
|
||
The rules are persisted by the systemd oneshot unit
|
||
**`nestquest-api-firewall.service`**, committed as
|
||
`deploy/systemd/nestquest-api-firewall.service` (ordered after
|
||
`docker.service`, it idempotently applies the three rules at every
|
||
boot). Install and enable it on the API box, from the repository
|
||
checkout, **before** exposing the API:
|
||
|
||
sudo cp deploy/systemd/nestquest-api-firewall.service /etc/systemd/system/ \
|
||
&& sudo systemctl daemon-reload \
|
||
&& sudo systemctl enable --now nestquest-api-firewall.service
|
||
|
||
The HA box and every other client must use the Traefik hostname,
|
||
never `:8080` directly. Check it:
|
||
|
||
sudo systemctl is-enabled nestquest-api-firewall.service # enabled
|
||
sudo systemctl is-active nestquest-api-firewall.service # active
|
||
sudo iptables -S DOCKER-USER # the three rules above, in that order
|
||
|
||
# on nukapi-03 (10.60.1.33) — allowed:
|
||
curl -s -o /dev/null -w '%{http_code}\n' http://10.60.1.14:8080/health # 200
|
||
# on any other LAN host, e.g. 10.60.1.101 — dropped:
|
||
curl -s -m 5 -o /dev/null -w '%{http_code}\n' http://10.60.1.14:8080/health # 000, timeout
|
||
# public path still works:
|
||
curl -s -o /dev/null -w '%{http_code}\n' https://nestquest.cubecraftlabs.com/health # 200
|
||
|
||
On a rebuilt API box, install the unit with the commands above before
|
||
exposing the API. The committed file is the authoritative copy; the
|
||
deployed unit must match it:
|
||
|
||
diff deploy/systemd/nestquest-api-firewall.service \
|
||
/etc/systemd/system/nestquest-api-firewall.service # no output
|
||
|
||
## 7. Backup (Litestream to the Synology NAS)
|
||
|
||
The `litestream` sidecar (config `deploy/litestream.yml`, mounted at
|
||
`/etc/litestream.yml`) replicates `/var/lib/nestquest/nestquest.db` to:
|
||
|
||
- SFTP `joshua@10.60.1.50`, path `/homes/Joshua/nestquest-backups` (the
|
||
NAS's SFTP view is chrooted to the shared folders; over SSH the same
|
||
directory is `/volume1/homes/Joshua/nestquest-backups`).
|
||
- Every committed change is shipped as an LTX file within about a second.
|
||
Litestream 0.5.17 compacts them into coarser levels on its built-in
|
||
schedule (level 1 every 30s, level 2 every 5m, level 3 every 1h).
|
||
- **Snapshots**: `snapshot.interval: 24h` (a full snapshot daily);
|
||
**retention**: `snapshot.retention: 168h` — a week of snapshots plus the
|
||
changes between them, i.e. a 7-day point-in-time restore window.
|
||
|
||
The sidecar runs as uid 999 with a read-only root filesystem,
|
||
`no-new-privileges` and all capabilities dropped. It needs the database
|
||
volume read-write (it keeps its `_litestream_*` tables and writes the
|
||
WAL); its local state lives beside the database in
|
||
`/var/lib/nestquest/.nestquest.db-litestream/`.
|
||
|
||
Check it:
|
||
|
||
dc ps litestream # Up
|
||
dc logs --tail 50 litestream # no "error" lines
|
||
dc exec litestream litestream status # status "ok", local txid advancing
|
||
dc exec litestream litestream ltx /var/lib/nestquest/nestquest.db | tail # newest LTX files and timestamps
|
||
|
||
A change made in the app should appear as a new level-0 LTX file within
|
||
seconds. On the NAS, the files are under
|
||
`/volume1/homes/Joshua/nestquest-backups/ltx/`.
|
||
|
||
## 8. Restore
|
||
|
||
Restores the database from the SFTP replica into the `nestquest-db`
|
||
volume at `NESTQUEST_DB_PATH` (`/var/lib/nestquest/nestquest.db`). The
|
||
same procedure serves both a damaged database on the existing box and a
|
||
rebuilt box. The restore runs in one-off `litestream` containers so it
|
||
has the same user (999), volume, config and SFTP key as the sidecar.
|
||
|
||
**0. Fresh box only.** Complete sections 1 and 5, then start **only the
|
||
API** once so the volume exists and is owned by uid 999:
|
||
|
||
dc up -d api
|
||
|
||
Do not start `litestream` yet — it would begin replicating the new,
|
||
empty database the API just created. The swap in step 4 moves that empty
|
||
file aside, and step 5 creates and starts the sidecar with
|
||
`dc up -d litestream` once the API is healthy.
|
||
|
||
**1. Stop writers** — both the API and the sidecar (Litestream must not
|
||
replicate while the file is being replaced):
|
||
|
||
dc stop api litestream
|
||
|
||
**2. Restore beside the live database** (never over it), with a full
|
||
integrity check:
|
||
|
||
dc run --rm --no-deps litestream restore \
|
||
-config /etc/litestream.yml \
|
||
-integrity-check full \
|
||
-o /var/lib/nestquest/nestquest.db.restored \
|
||
/var/lib/nestquest/nestquest.db
|
||
|
||
Expect `post-restore integrity check passed` and exit code 0. For a
|
||
point in time within the retention window, add
|
||
`-timestamp 2026-09-24T12:00:00Z` (UTC). To preview without writing, add
|
||
`-dry-run`.
|
||
|
||
**3. Inspect the restored file** before switching to it — confirm known
|
||
children and definitions are present:
|
||
|
||
dc run --rm --no-deps --entrypoint sqlite3 litestream \
|
||
/var/lib/nestquest/nestquest.db.restored \
|
||
'PRAGMA integrity_check;' \
|
||
'SELECT id, display_name FROM children;' \
|
||
'SELECT id, title FROM quest_definitions;'
|
||
|
||
Expect `ok`, then the household's children and quest definitions.
|
||
|
||
**4. Swap it into place**, keeping the old files (database, its
|
||
`-wal`/`-shm`, and Litestream's local state) in a timestamped directory
|
||
on the volume:
|
||
|
||
dc run --rm --no-deps --entrypoint sh litestream -c '
|
||
set -e
|
||
cd /var/lib/nestquest
|
||
ts=$(date -u +%Y%m%dT%H%M%SZ)
|
||
mkdir -p "pre-restore-$ts"
|
||
for f in nestquest.db nestquest.db-wal nestquest.db-shm .nestquest.db-litestream; do
|
||
if [ -e "$f" ]; then mv "$f" "pre-restore-$ts/"; fi
|
||
done
|
||
mv nestquest.db.restored nestquest.db
|
||
ls -la'
|
||
|
||
**5. Start the API, then the sidecar:**
|
||
|
||
dc start api
|
||
dc ps api # wait for "healthy"
|
||
dc up -d litestream
|
||
dc ps litestream # Up
|
||
dc logs --tail 20 litestream
|
||
|
||
Use `dc up -d litestream`, not `dc start`: on a fresh box step 0 never
|
||
created the sidecar container, and `start` cannot start a container that
|
||
does not exist. `up -d` creates it if needed (or starts the existing one),
|
||
so continuous replication is back online on both paths.
|
||
|
||
The sidecar resumes replication from the restored position (it logs
|
||
`detected database behind replica` / `fetched latest L0 file from
|
||
replica`, then continues normally).
|
||
|
||
**6. Verify through the API** — the panel snapshot lists the restored
|
||
children. On the API box against `10.60.1.14:8080` directly (other LAN
|
||
hosts, including the HA box, are dropped on `:8080` by the section 6
|
||
firewall; from the HA box use the Traefik hostname instead):
|
||
|
||
PANEL_TOKEN=$(grep '^NESTQUEST_PANEL_TOKEN=' deploy/.env | cut -d= -f2-)
|
||
curl -s -H "Authorization: Bearer $PANEL_TOKEN" \
|
||
http://10.60.1.14:8080/api/v1/panel/snapshot
|
||
|
||
Expect HTTP 200 and `children` naming the known child(ren). Then run
|
||
section 9.
|
||
|
||
Once satisfied, remove the old copy:
|
||
`dc run --rm --no-deps --entrypoint rm litestream -rf /var/lib/nestquest/pre-restore-<ts>`.
|
||
|
||
This exact sequence (stop, restore with integrity check, inspect, swap,
|
||
start, snapshot shows the restored child, replication resumes) was
|
||
rehearsed against a local file replica with the committed compose file;
|
||
the live proof against the SFTP replica is recorded separately by the
|
||
controller.
|
||
|
||
## 9. Verification checklist
|
||
|
||
Run after every deploy and every restore. `$PANEL_TOKEN` is the value from
|
||
`deploy/.env`; never paste it into shared logs.
|
||
|
||
| # | Check | Command | Expect |
|
||
| --- | --- | --- | --- |
|
||
| 1 | Health over TLS | `curl -s -o /dev/null -w '%{http_code}\n' https://nestquest.cubecraftlabs.com/health` | `200` |
|
||
| 2 | Valid certificate | `curl -sv https://nestquest.cubecraftlabs.com/health 2>&1 \| grep -E 'issuer\|expire date'` | Let's Encrypt issuer, future expiry (no `-k` needed) |
|
||
| 3 | Docs public | `curl -s -o /dev/null -w '%{http_code}\n' https://nestquest.cubecraftlabs.com/docs` | `200` |
|
||
| 4 | Panel refused externally | from any host other than `10.60.1.80` (e.g. a phone off Wi-Fi, or a LAN machine): `curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $PANEL_TOKEN" https://nestquest.cubecraftlabs.com/api/v1/panel/snapshot` | `403` (Traefik allowlist) |
|
||
| 5 | Panel allowed from HA box | on `10.60.1.80`: same command | `200`, snapshot JSON |
|
||
| 6 | Panel needs the token | on `10.60.1.80`, without the header | `401` |
|
||
| 7 | Admin JWT accepted | `curl -s -w '\n%{http_code}\n' -H "Authorization: Bearer $ADMIN_JWT" https://nestquest.cubecraftlabs.com/api/v1/admin/ping` with a token of a `nestquest-admins` member | `200`, `{"status":"ok","sub":...}` |
|
||
| 8 | Non-admin refused | same, with a token of a user **not** in `nestquest-admins` | `403`, "Admin access requires membership in the nestquest-admins group" |
|
||
| 9 | No/invalid token refused | same, without a header or with garbage | `401` |
|
||
| 10 | Panel token refused on admin | same, with `$PANEL_TOKEN` | `403` |
|
||
| 11 | Backup running | `dc exec litestream litestream status` | status `ok` |
|
||
|
||
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`)`` | `50` — below `nestquest-admin` (100) and `nestquest-panel` (200) | `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` |
|
||
|
||
## 11. Upgrades and rollback
|
||
|
||
The API applies pending schema migrations when it starts (FastAPI
|
||
lifespan, before `/health` answers), so **the first start of a new
|
||
version is the point of no return** for any migration it carries. A
|
||
build refuses a database whose schema version is newer than it
|
||
understands ("newer than this integration understands … downgrades are
|
||
not supported") and does not start.
|
||
|
||
### Migration 9 (multiple presence patterns)
|
||
|
||
Migration 9 creates `presence_patterns`, copies every row of the old
|
||
one-per-child `presence_schedules` table into it as a `home` pattern
|
||
named `Home schedule`, and **drops `presence_schedules`**. Version 0.7.1
|
||
refuses the resulting version-9 database, and could not read
|
||
`presence_patterns` anyway, so the only way back to 0.7.1 is a copy of
|
||
the database taken **before** the first start on the new version.
|
||
|
||
**Before the first start on the new version**, with the old version
|
||
still deployed:
|
||
|
||
1. Stop writers (as section 8 step 1):
|
||
|
||
dc stop api litestream
|
||
|
||
2. Copy the database file beside the live one with SQLite's online
|
||
backup (includes anything still in the WAL), then check it:
|
||
|
||
ts=$(date -u +%Y%m%dT%H%M%SZ)
|
||
dc run --rm --no-deps --entrypoint sqlite3 litestream \
|
||
/var/lib/nestquest/nestquest.db \
|
||
".backup /var/lib/nestquest/pre-v9-$ts.db"
|
||
dc run --rm --no-deps --entrypoint sqlite3 litestream \
|
||
/var/lib/nestquest/pre-v9-$ts.db \
|
||
'PRAGMA integrity_check;' \
|
||
'SELECT version FROM nestquest_schema_version;' \
|
||
'SELECT count(*) FROM presence_schedules;'
|
||
|
||
Expect `ok`, schema version `8`, and the number of presence
|
||
schedules. Write down `$ts`.
|
||
|
||
3. Also record the Litestream replica position and the UTC time, so a
|
||
point-in-time restore (section 8, `-timestamp`) can reach the
|
||
pre-upgrade state even if the local copy is lost:
|
||
|
||
dc up -d litestream
|
||
dc exec litestream litestream status # note the txid
|
||
date -u +%Y-%m-%dT%H:%M:%SZ # note the time
|
||
dc stop litestream
|
||
|
||
The replica only covers the 7-day retention window (section 7).
|
||
|
||
4. Deploy the new version (sections 1–2) and run section 9. Confirm the
|
||
live database is at version `9` (the section 8 step 3 `sqlite3`
|
||
command against `nestquest.db`, with
|
||
`'SELECT version FROM nestquest_schema_version;'`) and that the
|
||
Schedule tab lists each child's old schedule as a `Home schedule`
|
||
pattern.
|
||
|
||
**Rollback to 0.7.1:** check out and build 0.7.1 (section 1), then put
|
||
the pre-upgrade database back with section 8: either steps 1 and 4–6
|
||
with `pre-v9-<ts>.db` in place of `nestquest.db.restored`, or the full
|
||
restore with `-timestamp` set to the time noted in step 3. Presence
|
||
pattern changes made after the upgrade are not in that copy and are
|
||
lost. Once the new version has proven itself, remove the copy:
|
||
`dc run --rm --no-deps --entrypoint rm litestream -f /var/lib/nestquest/pre-v9-<ts>.db`.
|
||
|
||
This rollback has not yet been rehearsed.
|