diff --git a/Dockerfile b/Dockerfile index bec18f3..8fa6777 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/api/requirements.txt b/api/requirements.txt index fed0749..9c58349 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -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 diff --git a/deploy/RUNBOOK.md b/deploy/RUNBOOK.md index 516204f..5a0cc65 100644 --- a/deploy/RUNBOOK.md +++ b/deploy/RUNBOOK.md @@ -266,8 +266,16 @@ committed `deploy/litestream.yml` (`host-key`, the NAS's ECDSA key), so no -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`** on the API box (enabled; ordered - after `docker.service`, it applies the three rules at every boot). + **`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: @@ -282,10 +290,12 @@ committed `deploy/litestream.yml` (`host-key`, the NAS's ECDSA key), so no # public path still works: curl -s -o /dev/null -w '%{http_code}\n' https://nestquest.cubecraftlabs.com/health # 200 - On a rebuilt API box, recreate and enable the unit before exposing the - API (`sudo systemctl enable --now nestquest-api-firewall.service`); the - deployed unit is the authoritative copy - (`systemctl cat nestquest-api-firewall.service`). + 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) diff --git a/deploy/compose.yaml b/deploy/compose.yaml index 742ba23..692df23 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -56,7 +56,7 @@ services: # (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 + image: litestream/litestream:0.5.17@sha256:4b02b9859a6b6b4087d8b8944e15f7e984bd7957cba322bbeee38b0e27b9656a restart: unless-stopped command: ["replicate", "-config", "/etc/litestream.yml"] user: "999:999" diff --git a/deploy/systemd/nestquest-api-firewall.service b/deploy/systemd/nestquest-api-firewall.service new file mode 100644 index 0000000..080e0e5 --- /dev/null +++ b/deploy/systemd/nestquest-api-firewall.service @@ -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