Compare commits

...
Author SHA1 Message Date
overseerandClaude Opus 5.5 21a8365c6a Feature 17 review remediation: commit firewall unit, pin image and deps
F17-001: commit the API box's nestquest-api-firewall.service verbatim
as deploy/systemd/nestquest-api-firewall.service (DOCKER-USER RETURN
for 10.60.1.33 and 10.60.1.14, then DROP tcp/8080) and make the
runbook install it from the repository before exposing the API.

F17-002: pin the python:3.12-slim base image by digest in both build
stages, pin api/requirements.txt to the exact versions installed in
the deployed image (direct deps plus transitive closure), and pin the
litestream image by digest in deploy/compose.yaml.

Task: e4a7b18b-5dad-4387-90ac-33588c280824

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 09:44:12 -04:00
overseer 71a9ae8dd1 Merge dev into feature/911b11a7 (align history before promotion) 2026-09-24 09:36:11 -04:00
dev_agent 0fc8764455 Task 6e203451: Tested restore and deployment runbook (#216) 2026-09-24 08:54:45 -04:00
dev_agent 97b7201f7c Task 47b27ccb: Continuous backup of the SQLite file (#215) 2026-09-24 08:34:18 -04:00
dev_agent fe44740450 Feature 19: Admin PWA (#187-#210) (#211)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-24 00:08:58 -04:00
dev_agent b8532bacde Feature 23: Admin API Completion - preview, presence, live (#199-#202) (#203)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 22:16:47 -04:00
dev_agent 4b39af4b67 Feature 22 follow-up: Editable definition assignees (#196) (#197)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 21:10:39 -04:00
dev_agent 42ea5710d7 Feature 22: Admin Definitions Read and skip_on_away (#193, #194) (#195)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 20:52:06 -04:00
dev_agent a808f89993 Feature 21: Admin Today Snapshot API (#190) (#191)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 20:01:25 -04:00
orchestrator-agent 22f1a6861d Merge pull request 'Reconcile dev from main (release 0.6.0 commits)' (#186) from main into dev 2026-09-23 16:16:23 -04:00
overseer 939556a38a Merge pull request 'Release: Re-imagined architecture - Features 15, 16, 18, 20 (2026-09-23)' (#185) from release/2026-09-23-reimagining into main
Reviewed-on: #185
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-09-23 14:55:38 -04:00
overseer 8676bdc6ad release: correct the 0.6.0 notes - no containerization artifacts ship in this release 2026-09-23 14:35:01 -04:00
overseer 0ed35ab85a release: advance version to 0.6.0 and add the re-imagined architecture release notes 2026-09-23 14:29:12 -04:00
overseer c57d950101 merge: origin/main into 0.6.0 release (bring the architecture doc; keep dev versions and notes) 2026-09-23 14:27:56 -04:00
dev_agent 70ee4ec111 Make the missed-sweep watermark claim safe across processes (#184)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 13:06:40 -04:00
dev_agent 415ababc8e Automatically prepare releases for completed features (#182)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 13:02:44 -04:00
overseer 0972761994 Merge pull request 'Add the re-imagined architecture document' (#140) from docs/architecture-reimagining into main
Reviewed-on: #140
2026-09-21 16:09:26 -04:00
overseer 10f365ef9f Add the re-imagined architecture document 2026-09-21 13:10:57 -04:00
8 changed files with 616 additions and 10 deletions
+4
View File
@@ -14,3 +14,7 @@ frontend/node_modules/
*.db
*.db-wal
*.db-shm
# Dedicated Litestream SFTP backup keypair — lives only on the API box.
deploy/*key*
deploy/litestream_key*
+6 -2
View File
@@ -14,12 +14,16 @@
# the SQLite database lives on the mounted volume at
# /var/lib/nestquest (compose supplies NESTQUEST_DB_PATH pointing
# there — configuration is environment-supplied, never baked in).
FROM python:3.12-slim AS builder
#
# Both stages use the same base image pinned by digest, and
# api/requirements.txt pins exact versions, so a rebuild reproduces
# the deployed image instead of floating to whatever is newest.
FROM python:3.12-slim@sha256:2f17fc044b579bab302c2e8054d3a686e2cb9a83de48e70534b94cd8ebbe06a9 AS builder
COPY api/requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir --prefix=/install -r /tmp/requirements.txt
FROM python:3.12-slim
FROM python:3.12-slim@sha256:2f17fc044b579bab302c2e8054d3a686e2cb9a83de48e70534b94cd8ebbe06a9
# The staged dependency install from the builder (no pip, no wheels).
COPY --from=builder /install /usr/local
+45 -2
View File
@@ -36,5 +36,48 @@ which serves ``https://nestquest.cubecraftlabs.com`` (Let's Encrypt,
auto-renewed, HTTP redirected to HTTPS) and forwards to the API box
on port 8080 — so on that box set ``NESTQUEST_BIND_ADDRESS`` to its
LAN address (``10.60.1.14``). Traefik only allows
``/api/v1/panel`` from the Home Assistant host. Network separation,
backup and restore are later Feature 17 tasks.
``/api/v1/panel`` from the Home Assistant host. The full deployment,
restore and verification procedure is in ``deploy/RUNBOOK.md``.
## Continuous backup (Litestream)
Compose also runs a ``litestream`` sidecar
(``litestream/litestream:0.5.17``, config ``deploy/litestream.yml``).
It mounts the same ``nestquest-db`` volume and continuously replicates
``/var/lib/nestquest/nestquest.db`` over SFTP to the Synology NAS
``CubeCraft-SRVR1`` (``joshua@10.60.1.50``), directory
``/volume1/homes/Joshua/nestquest-backups`` (``/homes/Joshua/...`` in
the NAS's chrooted SFTP view) — so the backup is outside the Docker
host and its volume. A snapshot is taken daily and a week of
snapshots plus incremental changes is retained; the NAS host key is
pinned in the config.
The SFTP login uses a dedicated keypair that lives only on the API
box and is git-ignored. Create it once, before ``up``:
ssh-keygen -t ed25519 -N "" -C nestquest-litestream -f deploy/litestream_key
then append ``restrict `` + the contents of ``deploy/litestream_key.pub``
to ``joshua@10.60.1.50:~/.ssh/authorized_keys``. Compose mounts the
private key read-only at ``/etc/litestream/sftp_key``. The sidecar runs
as uid/gid 999 (the API's ``nestquest`` user), so give it the key:
sudo chown 999:999 deploy/litestream_key && chmod 600 deploy/litestream_key
The sidecar needs WRITE access to the database volume: Litestream adds
its own ``_litestream_seq``/``_litestream_lock`` tables to the database,
writes the ``-wal``/``-shm`` files and checkpoints the WAL, and fails
with "unable to open database file" on a read-only mount. That access
is contained rather than removed — the sidecar runs as the non-root
volume owner with a read-only root filesystem, ``no-new-privileges``
and all Linux capabilities dropped.
Check replication with
``docker compose -f deploy/compose.yaml logs litestream`` or
``docker compose -f deploy/compose.yaml exec litestream litestream ltx /var/lib/nestquest/nestquest.db``.
To restore (high level): stop the ``api`` service, run
``litestream restore -config /etc/litestream.yml -o <new path>
/var/lib/nestquest/nestquest.db`` from a litestream container with the
same mounts, verify the restored file, move it into place on the
volume (removing any stale ``-wal``/``-shm`` files), and start ``api``
again.
+29 -6
View File
@@ -7,9 +7,32 @@
# runtime dependencies HERE — this file is what the container image
# installs (see the Dockerfile at the repository root).
#
# Compatible ranges mirroring the dev group's floors; the API is a
# server, so these are the only packages its runtime needs.
fastapi>=0.115
uvicorn>=0.30
pyjwt[crypto]>=2.9
httpx>=0.28
# EXACT pins: the versions installed in the deployed image
# (nestquest-api:latest on the API box, read with importlib.metadata),
# so a rebuild resolves the same packages. The four direct
# dependencies come first; the rest is their resolved transitive
# closure, pinned too so nothing floats. The dev group's floors in
# pyproject.toml are unchanged. Bump deliberately, together with the
# Dockerfile's digest-pinned base image.
fastapi==0.141.1
uvicorn==0.53.0
pyjwt[crypto]==2.15.0
httpx==0.28.1
# Transitive dependencies of the above.
annotated-doc==0.0.5
annotated-types==0.8.0
anyio==4.15.1
certifi==2026.7.22
cffi==2.1.1
click==8.5.0
cryptography==50.0.1
h11==0.16.0
httpcore==1.0.9
idna==3.20
pycparser==3.0
pydantic==2.13.5
pydantic_core==2.46.5
starlette==1.7.0
typing-inspection==0.4.4
typing_extensions==4.16.0
+455
View File
@@ -0,0 +1,455 @@
# 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` |
| 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 two 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 |
- **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 |
| --- | --- | --- | --- |
| 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` |
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.
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`)"
entryPoints: [websecure]
middlewares: [nestquest-panel-allowlist]
service: nestquest-api
tls:
certResolver: myresolver
nestquest-admin:
rule: "Host(`nestquest.cubecraftlabs.com`)"
entryPoints: [websecure]
service: nestquest-api
tls:
certResolver: myresolver
middlewares:
nestquest-panel-allowlist:
ipAllowList:
sourceRange: ["10.60.1.80/32"]
services:
nestquest-api:
loadBalancer:
servers:
- url: "http://10.60.1.14:8080"
```
**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.
+33
View File
@@ -42,5 +42,38 @@ services:
retries: 5
start_period: 15s
# 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
# created on the API box before starting (see api/README.md).
#
# The database volume is mounted READ-WRITE on purpose: Litestream
# cannot run against a read-only mount ("unable to open database file
# (14)", even with its metadata moved elsewhere) because it writes its
# _litestream_seq/_litestream_lock tables into the database, opens the
# -wal/-shm files for writing, and checkpoints the WAL. The write
# access is contained instead: it runs as the API's own non-root user
# (uid/gid 999, owner of the volume) with a read-only root filesystem,
# no privilege escalation and no Linux capabilities.
litestream:
image: litestream/litestream:0.5.17@sha256:4b02b9859a6b6b4087d8b8944e15f7e984bd7957cba322bbeee38b0e27b9656a
restart: unless-stopped
command: ["replicate", "-config", "/etc/litestream.yml"]
user: "999:999"
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
depends_on:
api:
condition: service_healthy
volumes:
- nestquest-db:/var/lib/nestquest
- ./litestream.yml:/etc/litestream.yml:ro
- ./litestream_key:/etc/litestream/sftp_key:ro
volumes:
nestquest-db:
+32
View File
@@ -0,0 +1,32 @@
# Litestream continuous backup of the NestQuest SQLite database (Feature
# 17). The ``litestream`` compose service mounts the same named volume
# as the API and streams every committed change to the Synology NAS
# over SFTP, so the backup lives OUTSIDE the container's volume.
#
# Nothing here is secret. The SFTP private key is a dedicated,
# git-ignored keypair (deploy/litestream_key) mounted read-only by
# compose at /etc/litestream/sftp_key; its public half is in
# joshua@10.60.1.50:~/.ssh/authorized_keys. ``host-key`` pins the
# NAS's host key so the connection is verified; it must be the ECDSA
# key, because that is the algorithm Litestream's SSH client negotiates
# with this NAS (pinning the ed25519 key fails with "host key mismatch").
#
# The Synology SFTP server is chrooted to the shared folders, so the
# directory seen over SSH as /volume1/homes/Joshua/nestquest-backups is
# /homes/Joshua/nestquest-backups over SFTP.
# Take a full snapshot daily and keep a week of snapshots plus the
# incremental changes between them (point-in-time restore window).
snapshot:
interval: 24h
retention: 168h
dbs:
- path: /var/lib/nestquest/nestquest.db
replicas:
- type: sftp
host: 10.60.1.50
user: joshua
path: /homes/Joshua/nestquest-backups
key-path: /etc/litestream/sftp_key
host-key: ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBNujay9nYEiXuRQf4wDRDU1gp+CDvpeMdhuhC2XypIihzKGEtB654kwrUGfA2kDWAoSPGlmojeS3OIRtHPkj6GY=
@@ -0,0 +1,12 @@
[Unit]
Description=Restrict NestQuest API port 8080 to Traefik (DOCKER-USER)
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/sh -c "iptables -C DOCKER-USER -s 10.60.1.33/32 -p tcp --dport 8080 -j RETURN 2>/dev/null || iptables -I DOCKER-USER 1 -s 10.60.1.33 -p tcp --dport 8080 -j RETURN; iptables -C DOCKER-USER -s 10.60.1.14/32 -p tcp --dport 8080 -j RETURN 2>/dev/null || iptables -I DOCKER-USER 2 -s 10.60.1.14 -p tcp --dport 8080 -j RETURN; iptables -C DOCKER-USER -p tcp --dport 8080 -j DROP 2>/dev/null || iptables -A DOCKER-USER -p tcp --dport 8080 -j DROP"
[Install]
WantedBy=multi-user.target