Compare commits

...

4 Commits

Author SHA1 Message Date
jacobh2 9231a1d755 infra README: owner decisions list after the M-1 answer (SPEC v0.20)
Build Kvitt images / build (push) Failing after 49s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-19 19:51:10 +02:00
jacobh2 62f5279daa infra README: Nginx Proxy Manager proxy host, router to NPM, NPM fallback (D-120)
Owner checklist without the SSH add-on step: KVITT_PUBLIC_HOST placeholder, NPM's Details, SSL and
Advanced tab fields, the Custom Nginx Configuration block (security headers of D-117 at server level, its
own location / so NPM's X-Served-By does not drop them, /i/ kept out of NPM's access and error logs), what
NPM logs, the HSTS caveat, the router rule external 8443 to the Home Assistant Pi's 443, the NPM-down
fallback (no LAN path for release builds), operations and troubleshooting. RESTORE checks port 8080.
Rehearsal of 2026-10-19 recorded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-19 19:50:28 +02:00
jacobh2 d05f13a080 Pi stack behind Nginx Proxy Manager: remove Caddy and cert-sync (D-120)
The server publishes 8080 on the Kvitt Pi's LAN interfaces and trusts X-Forwarded-For only from HA_HOST,
NPM's LAN address (an IPv4 literal, checked by install.sh). KVITT_PUBLIC_HOST (owner-chosen, not
icehack.asuscomm.com, which is NPM's proxy host for Home Assistant) builds KVITT_PUBLIC_URL. Only
fcm-service-account.json and rclone.conf are installed; install.sh removes the Caddyfile, the Home Assistant
SSH key, known_hosts and the certs and caddy_data volumes left by the earlier stack. Health checks: port 8080,
then through NPM. The cert-sync image leaves the workflow and build-images.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-19 19:50:28 +02:00
jacobh2 4f69ae010e Gitea workflow: registry login with the secret in the run line (env form came through empty on act_runner 0.6.1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-19 19:50:28 +02:00
14 changed files with 364 additions and 462 deletions
+5 -18
View File
@@ -2,7 +2,7 @@
# example workflow and the Gitea Actions documentation; the first run is an owner step, infra/README.md).
#
# Runner: act_runner with the label `host` on the owner's Raspberry Pi (Alpine-based host, docker builds on the
# Pi itself). Pushes three linux/arm64 images to the Gitea container registry of the same instance (gitea.lan,
# Pi itself). Pushes two linux/arm64 images to the Gitea container registry of the same instance (gitea.lan,
# plain HTTP), each tagged with the version vMAJOR.MINOR.PATCH and `latest`, then pushes the git tag of that version.
# On the Kvitt Pi: /opt/kvitt/deploy.sh <version>.
#
@@ -34,7 +34,6 @@ on:
push:
paths:
- "server/**"
- "infra/cert-sync/**"
- "infra/backup/**"
- ".gitea/workflows/**"
workflow_dispatch: {}
@@ -46,11 +45,9 @@ jobs:
- name: Install Git and Docker CLI
run: apk add --no-cache git docker-cli docker-cli-buildx
- name: Log in to Gitea Container Registry
# The token reaches the shell as an environment variable, never as text inside the script.
env:
REGISTRY_TOKEN: ${{ secrets.kvitt_cicd }}
REGISTRY_USER: ${{ github.actor }}
run: printf '%s' "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin
# The owner's proven form: the secret interpolated into the run line (act_runner masks it in the log).
# The env-based form (env: REGISTRY_TOKEN: ${{ secrets.kvitt_cicd }}) came through empty on act_runner 0.6.1.
run: printf '%s' "${{ secrets.kvitt_cicd }}" | docker login "$REGISTRY_HOST" -u "${{ github.actor }}" --password-stdin
- name: Create Custom Buildx Builder for HTTP
run: |
# Create the builder once and reuse it, so its BuildKit cache (cargo registry, target) survives runs.
@@ -84,16 +81,6 @@ jobs:
# --provenance=false on every build: one plain linux/arm64 manifest per tag, as Podman and infra/build-images.sh
# push. Without it buildx pushes an index with an attestation entry (platform unknown/unknown), which the Gitea
# package page lists as an extra platform; nothing here reads attestations.
- name: Build cert-sync image
run: |
docker buildx build \
--platform linux/arm64 \
--provenance=false \
-t "$REGISTRY/kvitt-cert-sync:$VERSION" \
-t "$REGISTRY/kvitt-cert-sync:latest" \
--metadata-file /tmp/kvitt-cert-sync.metadata.json \
./infra/cert-sync \
--push
- name: Build backup image
run: |
docker buildx build \
@@ -120,7 +107,7 @@ jobs:
- name: Print image digests
# The digests deploy.sh prints and records on the Kvitt Pi (/opt/kvitt/deployed-digests, D-117) must match.
run: |
for name in cert-sync backup server; do
for name in backup server; do
digest=$(sed -n 's/.*"containerimage.digest": *"\([^"]*\)".*/\1/p' "/tmp/kvitt-$name.metadata.json")
echo "$REGISTRY/kvitt-$name:$VERSION ${digest:-unknown}"
rm -f "/tmp/kvitt-$name.metadata.json"
+258 -153
View File
@@ -2,8 +2,9 @@
This file covers the **development stack on the owner's Mac** (`infra/dev/`, milestone M2c-dev, D-001), the
**Firebase setup** (SPEC §10.7) and the **Pi stack** (`infra/pi/`, milestone M2c-pi, D-100): a self-contained
bundle the owner copies to the Kvitt Pi and runs there. No agent and no Mac script connects to the Pis; the owner
runs every Pi command from the checklist in "Pi: what the owner does".
bundle the owner copies to the Kvitt Pi and runs there, behind the owner's Nginx Proxy Manager on the Home Assistant
Pi (D-120). No agent and no Mac script connects to the Pis; the owner runs every Pi command and every NPM setting
from the checklist in "Pi: what the owner does".
## Layout
@@ -19,13 +20,12 @@ runs every Pi command from the checklist in "Pi: what the owner does".
| `dev/lib.sh` | Helpers sourced by the scripts |
| `dev/secrets/` | Git-ignored: only `fcm-service-account.json`, copied there by `dev/up.sh` and mounted read-only at `/secrets` |
| `backup/Dockerfile`, `backup/backup.sh`, `backup/healthcheck.sh` | Backup image and job of SPEC §10.9 (shared with the Pi stack); Drive upload and D-002 deletion |
| `cert-sync/Dockerfile`, `cert-sync/sync.sh`, `cert-sync/entrypoint.sh` | Certificate sync image of SPEC §10.5.1 |
| `pi/` | **The Pi bundle** (copied to the Kvitt Pi): `compose.yaml`, `Caddyfile`, `.env.example`, `install.sh`, `deploy.sh`, `bootstrap.sh`, `backup-now.sh`, `install-apk.sh`, `lib.sh`; git-ignored `pi/.env`, `pi/secrets/`, `pi/out/` |
| `pi/` | **The Pi bundle** (copied to the Kvitt Pi): `compose.yaml`, `.env.example`, `install.sh`, `deploy.sh`, `bootstrap.sh`, `backup-now.sh`, `install-apk.sh`, `lib.sh`; git-ignored `pi/.env`, `pi/secrets/`, `pi/out/` |
| `build-images.sh` | Mac fallback for the image build: linux/arm64, push to the Gitea registry |
| `publish-apk.sh` | Mac: release APK, mapping and `latest.json` into `pi/out/apk/` for the owner to copy to the Pi |
| `RESTORE.md` | Restore from a Drive backup, step by step (SPEC §10.9) |
| `../.gitea/workflows/build-images.yaml` | Gitea Actions image build (the normal path) |
| `secrets/` | Git-ignored: `fcm-service-account.json`, `google-services.json`, `ha_ssh_key(.pub)`, `rclone.conf`, optional `registry.env`. Never committed |
| `secrets/` | Git-ignored: `fcm-service-account.json`, `google-services.json`, `rclone.conf`, optional `registry.env` (and `ha_ssh_key(.pub)`, unused since D-120). Never committed |
All Mac commands below run from the repository root of the main checkout (`/Users/jhag/projects/kvitt`).
@@ -37,8 +37,8 @@ publishes its own port (default 8080) on all of the Mac's interfaces. The server
(10 MB x 3) and `TZ=Europe/Stockholm`. Data lives in the bind-mounted folder `infra/dev/data/` (git-ignored) so it
is easy to inspect and reset. Only the FCM key reaches the container (D-117): `up.sh` copies
`${KVITT_SECRETS_DIR}/fcm-service-account.json` (default `infra/secrets/`) into `infra/dev/secrets/` (git-ignored,
nothing else in it) and that folder is mounted read-only at `/secrets`, so the server never sees the Home Assistant
SSH key, `rclone.conf`, the registry credentials or `google-services.json`. If the key is missing the folder is
nothing else in it) and that folder is mounted read-only at `/secrets`, so the server never sees `rclone.conf`,
the registry credentials or `google-services.json`. If the key is missing the folder is
empty and the server runs with push disabled (§10.4). Run `up.sh` once before the bare `docker compose` commands
below (they mount the same folder); after rotating the key, run `up.sh` again.
@@ -153,7 +153,7 @@ gcloud iam service-accounts keys list --iam-account kvitt-fcm@kvitt-1337.iam.gse
Rotating the key: create a new one with
`gcloud iam service-accounts keys create infra/secrets/fcm-service-account.json --iam-account kvitt-fcm@kvitt-1337.iam.gserviceaccount.com --project kvitt-1337`,
restart the server, then delete the old key id with `gcloud iam service-accounts keys delete <key id> ...`.
Copying the key to the Pi (§10.7 step 7, mode 0440, group 10001) is done by `pi/install.sh` ("Pi: what the owner does", step 6).
Copying the key to the Pi (§10.7 step 7, mode 0440, group 10001) is done by `pi/install.sh` ("Pi: what the owner does", step 5).
## Pi stack (SPEC §10.1 to §10.10, D-100)
@@ -161,89 +161,95 @@ Copying the key to the Pi (§10.7 step 7, mode 0440, group 10001) is done by `pi
| Where | What runs | Who runs it |
|---|---|---|
| Gitea Actions (runner label `host`, a Raspberry Pi) | `.gitea/workflows/build-images.yaml`: builds `kvitt-server`, `kvitt-cert-sync`, `kvitt-backup` for linux/arm64, pushes them to `gitea.lan/jacobh2/...` tagged `vMAJOR.MINOR.PATCH` and `latest`, pushes the git tag | Gitea, on a push to `server/**`, `infra/cert-sync/**`, `infra/backup/**`, `.gitea/workflows/**`, or by hand (Actions, "Run workflow") |
| Gitea Actions (runner label `host`, a Raspberry Pi) | `.gitea/workflows/build-images.yaml`: builds `kvitt-server` and `kvitt-backup` for linux/arm64, pushes them to `gitea.lan/jacobh2/...` tagged `vMAJOR.MINOR.PATCH` and `latest`, pushes the git tag | Gitea, on a push to `server/**`, `infra/backup/**`, `.gitea/workflows/**`, or by hand (Actions, "Run workflow") |
| Owner's Mac | `infra/build-images.sh [tag]` (fallback image build), `infra/publish-apk.sh` (release APK into `infra/pi/out/apk/`) | The owner (or an agent, for the APK build only) |
| Kvitt Pi, `/opt/kvitt/` | `compose.yaml`, `Caddyfile`, `.env`, `secrets/`, `import/`, `apk/`, and the scripts `deploy.sh`, `bootstrap.sh`, `backup-now.sh`, `install-apk.sh` (copied there by `install.sh`) | The owner, in their own SSH session |
| Home Assistant Pi | Its own certbot renews `icehack.asuscomm.com`; cert-sync reads the two PEM files over SSH with a dedicated key | Untouched apart from one `authorized_keys` entry |
| Kvitt Pi, `/opt/kvitt/` | `compose.yaml`, `.env`, `secrets/`, `import/`, `apk/`, and the scripts `deploy.sh`, `bootstrap.sh`, `backup-now.sh`, `install-apk.sh` (copied there by `install.sh`) | The owner, in their own SSH session |
| Home Assistant Pi | Nginx Proxy Manager (NPM): a proxy host for `<KVITT_PUBLIC_HOST>` with its own Let's Encrypt certificate, TLS on 443, plain HTTP to `<KVITT_PI_HOST>:8080` over the LAN (D-120) | The owner, in NPM's web UI (step 6) |
| Router (ASUS) | External TCP 8443 to the Home Assistant Pi's 443 (NPM); external 80 already reaches NPM for Let's Encrypt | The owner (step 7) |
Stack (`pi/compose.yaml`, project name `kvitt`): `caddy` (publishes 8443, the only published port), `server`,
`cert-sync`, `backup`; volumes `data`, `certs`, `caddy_data`, `backups`; network `kvitt`; every service
`restart: unless-stopped`, json-file logs 10 MB x 3, `TZ=Europe/Stockholm`. Images are
`${KVITT_REGISTRY}/kvitt-server|kvitt-cert-sync|kvitt-backup:${KVITT_TAG}` with `KVITT_REGISTRY=gitea.lan/jacobh2`
(D-104; the names the workflow pushes).
Stack (`pi/compose.yaml`, project name `kvitt`): `server` (publishes `8080:8080` on the Pi's LAN interfaces, the
only published port; `KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST}:8443`, `KVITT_TRUSTED_PROXY=${HA_HOST}`) and
`backup`; volumes `data`, `backups`; network `kvitt`; every service `restart: unless-stopped`, json-file logs
10 MB x 3, `TZ=Europe/Stockholm`. Images are `${KVITT_REGISTRY}/kvitt-server|kvitt-backup:${KVITT_TAG}` with
`KVITT_REGISTRY=gitea.lan/jacobh2` (D-104; the names the workflow pushes). Caddy and cert-sync were removed by
D-120: TLS terminates in NPM, which already runs on the Home Assistant Pi.
**Client IPs (SPEC §8.5, §8.6).** NPM's request to the server carries `X-Forwarded-For: <what the client sent>,
<client address>`; the server takes the last entry only when the TCP peer is `KVITT_TRUSTED_PROXY`, so `HA_HOST`
must be NPM's LAN IPv4 address (the server accepts an IP literal or a resolvable name, not a CIDR; `install.sh`
requires the address because the container cannot resolve an mDNS name such as `homeassistant.local`). The
router's forward keeps the internet client's source address, and Docker's published port keeps NPM's (DNAT, no
SNAT, for traffic arriving on the LAN interface), so the per-IP limits of D-032, D-115 and D-119 see real
addresses. The server does not use `X-Forwarded-Proto` or `Host`: links are built from `KVITT_PUBLIC_URL` (E-12).
Anyone on the home LAN can also reach `http://<KVITT_PI_HOST>:8080` directly; such requests count against their own
address, and only `HA_HOST` may set `X-Forwarded-For`.
### Versions (resolved 2026-10-18)
| Component | Version | Source |
|---|---|---|
| Caddy | `caddy:2.11.7@sha256:685ae3d7c34d9b5f8caeddc5d14051db7bbd88ff1f5a2fa0c16bd8ca328fb7af` (multi-arch index; has linux/arm64/v8) | Docker Hub official image; tag `2.11.7` = `2.11` = `latest`, pushed 2026-10-14 |
| Alpine (cert-sync, backup) | `alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6` | Docker Hub official image; `3.24` = `latest` |
| cert-sync packages (Alpine v3.24) | openssh-client-default 10.3_p1-r1, curl 8.22.0-r0, openssl 3.5.9-r0, supercronic 0.2.49-r1, tzdata 2026e-r0 | `apk policy` in the pinned image |
| Nginx Proxy Manager | The owner's existing install on the Home Assistant Pi; the block of step 6 was written against NPM v2.16.0 (2026-09-24) templates | github.com/NginxProxyManager/nginx-proxy-manager, `backend/templates/proxy_host.conf` |
| Alpine (backup) | `alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6` | Docker Hub official image; `3.24` = `latest` |
| backup packages (Alpine v3.24) | sqlite 3.53.4, rclone 1.74.1, tar 1.35, tzdata 2026e (M2c-dev), supercronic 0.2.49-r1 | `apk policy` in the pinned image |
| Docker Engine on the Pi | Docker's Debian apt repository, arm64 packages (`docker-ce` 5:29.8.2 current): `docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin` | docs.docker.com/engine/install/raspberry-pi-os/: that page is for 32-bit (armhf) only; 64-bit Raspberry Pi OS uses the Debian instructions (docs.docker.com/engine/install/debian/) |
**Why supercronic, not busybox crond (D-104).** Both sidecars run as non-root users (cert-sync uid 1000, backup
uid 10001). Busybox crond started as a non-root user in alpine 3.24.2 never ran a job (tested 2026-10-18: an
every-minute job produced no output in two minutes). supercronic is a cron for containers that runs jobs as the
user it runs as and honours `TZ`; the schedules are unchanged (`0 4 * * *` cert-sync, `30 3 * * *` backup).
**Why supercronic, not busybox crond (D-104).** The backup sidecar runs as a non-root user (uid 10001). Busybox
crond started as a non-root user in alpine 3.24.2 never ran a job (tested 2026-10-18: an every-minute job produced
no output in two minutes). supercronic is a cron for containers that runs jobs as the
user it runs as and honours `TZ`; the schedule is unchanged (`30 3 * * *`).
### Backup uid (D-105, SPEC §10.2 "M2c-pi reconciles the two")
`backup` runs as **uid 10001**, the server's uid, on the Pi as on the Mac: `sqlite3 .backup` on a WAL database
must open (and write) `kvitt.sqlite-shm`, which the server creates as 10001 with mode 0644, so uid 1000 could not
back up the live database without loosening the server's files. Therefore `/opt/kvitt/secrets/rclone.conf` is
mode 0600 **owned by 10001:10001** (not 1000). cert-sync stays uid 1000 with `ha_ssh_key` and `ha_known_hosts`
mode 0600 owned by 1000:1000 (ssh refuses a key not owned by its user or readable by others). rclone writes
mode 0600 **owned by 10001:10001** (not 1000). rclone writes
refreshed access tokens back to its config; the mounted file is read-only, so `backup.sh` works on a private copy
in `/tmp` each run. `install.sh` gives the named volumes their owners once (`/data`, `/data/apk`, `/backups` to
10001, `/certs` to 1000 with mode 0750, Caddy's `/data` to 10002) with one-off root containers
(`docker compose run --user 0:0 --cap-add CHOWN`, plus `FOWNER` for the `chmod`).
10001) with a one-off root container (`docker compose run --user 0:0 --cap-add CHOWN`).
**Caddy uid (D-117).** Caddy runs as **uid 10002** with the supplementary group **1000** (cert-sync's group): the
certificate files are 0640 `1000:1000` and `/certs` is 0750 `1000:1000`, so cert-sync writes and Caddy only reads.
Read-only root filesystem, `no-new-privileges`, `cap_drop: [ALL]` with `cap_add: [NET_BIND_SERVICE]`, `/config`
and `/tmp` as tmpfs, `caddy_data` writable by 10002. Port 8443 needs no capability, but the official image's
`/usr/bin/caddy` has the file capability `cap_net_bind_service=ep`, and the kernel refuses to exec it when that
capability is outside the bounding set ("exec container process /usr/bin/caddy: Operation not permitted" with
`cap_drop: [ALL]` alone, checked 2026-10-18), so it is the one capability kept.
### What was checked on the Mac
### What was checked on the Mac (2026-10-18)
2026-10-19 (D-120, Nginx Proxy Manager instead of Caddy and cert-sync):
* `shellcheck` on every `infra/**/*.sh`: clean.
* `podman-compose -f infra/pi/compose.yaml --env-file <filled sample> config`: exit 0.
* `cert-sync` and `backup` images built for linux/arm64 with Podman (not pushed).
* End-to-end rehearsal with Podman: a stand-in Home Assistant sshd with the variant A key below, the cert-sync
image as uid 1000 with `StrictHostKeyChecking=yes`, the pinned Caddy with `pi/Caddyfile` in front of a stand-in
server. The first fetch installed both files (0640); `GET /health` through Caddy answered over TLS; a renewed
certificate was installed and Caddy reloaded through its admin API without a restart; an unchanged certificate
was left alone; an older certificate and a key that does not match the certificate were refused with the old pair
kept; TLS 1.1 was refused. **Caddy logged the request headers with `"Authorization":["REDACTED"]` and the token
never appeared in its log** (SPEC §10.5.2).
* Backup rehearsal with a local rclone remote: upload, `lsf` confirmation by name and size, local deletion only
after both files were confirmed (D-002); files kept and `last-success` unchanged when the upload failed; the
leftovers uploaded by the next good run; healthcheck green on a fresh start and after a run, red at 26 h.
* Caddy hardening rehearsal (D-117, 2026-10-18, Podman, pinned Caddy image, `pi/Caddyfile`, a self-signed pair in
a `certs` volume set up as `install.sh` and cert-sync do, a stand-in upstream named `server`): Caddy runs as
`uid=10002 gid=10002 groups=1000,10002`, `NoNewPrivs: 1`, capability sets `0x400` (NET_BIND_SERVICE only), the root
filesystem refuses writes, the healthcheck command succeeds, memory about 12 MB. `GET /i/<code>?from=<name>` with a
bearer header answered 200 with `Content-Security-Policy`, `X-Content-Type-Options: nosniff`,
`X-Frame-Options: DENY` and one `Referrer-Policy: no-referrer` (the upstream sent it too). The access log showed
`"uri":"/i/REDACTED?REDACTED"` and `"/sync/pull?REDACTED"`, `"Authorization":["REDACTED"]`; with the upstream
stopped (502) Caddy's error log carried `/i/REDACTED?REDACTED` too; the code, the name, `kvt_` and the query value
occurred 0 times in either log. A reload through the admin API from another container (`POST /load`, as
cert-sync does) succeeded. TLS 1.1 refused.
* Not checked (needs the real hosts): the Home Assistant add-on's handling of `authorized_keys` options, the
Gitea workflow (not yet run), pulls from `gitea.lan`, the router forward, Drive.
* `shellcheck -S style` on every script under `infra/` and `tools/`: clean.
* `podman-compose -f infra/pi/compose.yaml --env-file <filled sample> config` and the same for `infra/dev/`: exit 0.
* `nginx -t` (nginx:alpine 1.31.7) of the step 6 block inside a minimal server block, and inside a copy of NPM
v2.16.0's `nginx.conf` with the proxy host rendered as NPM's template renders it for the step 6 settings (Block
Common Exploits, Force SSL, HTTP/2, HSTS, the block; NPM's regex `^(?:.*;)?\s*?location\s*?\/\s*?{` finds the
block's `location /`, so NPM leaves its own out): both successful.
* Rehearsal with Podman: the server image built from `server/Dockerfile` at this commit (arm64, hardened as on the
Pi) behind that nginx with a self-signed certificate, `KVITT_TRUSTED_PROXY` = the nginx container's address,
clients on a non-private test subnet (so NPM's `set_real_ip_from` does not apply, as for internet clients).
21 checks passed: `GET /health` 200 over HTTP/2 with `Content-Security-Policy`, `X-Content-Type-Options: nosniff`,
`X-Frame-Options: DENY`, `Referrer-Policy: no-referrer` and `Strict-Transport-Security` once each and no
`X-Served-By`; the landing page `/i/<code>?from=<name>` 200 with one `Referrer-Policy`; `http://` answered 301 to
`https://`; a 7 MB body refused by nginx (413), a 2 MB body answered by the server (`payload_too_large`); one
client limited after 60 requests a minute while a second client was still served, a forged `X-Forwarded-For`
did not escape the limit, and with `KVITT_TRUSTED_PROXY` set to another address both clients shared one bucket
(so the limits key on the forwarded address); after `/i/`, `//i/` and `/%69/` requests, and a 502 on `/i/` with
the server stopped, the invite code, the name and `/i/` occurred 0 times in nginx's access and error logs, while
other paths were logged with their query strings and a 502 on `/health` was in the error log.
* Not checked (needs the real hosts): NPM's own UI and Let's Encrypt for `<KVITT_PUBLIC_HOST>`, the router
forward, the Gitea workflow, pulls from `gitea.lan`, Drive.
### Gitea Actions workflow (`.gitea/workflows/build-images.yaml`), not yet run
2026-10-18 (M2c-pi, D-104 to D-106, D-117): backup rehearsal with a local rclone remote: upload, `lsf`
confirmation by name and size, local deletion only after both files were confirmed (D-002); files kept and
`last-success` unchanged when the upload failed; the leftovers uploaded by the next good run; healthcheck green on a
fresh start and after a run, red at 26 h. (The Caddy and cert-sync rehearsals of that day concern the removed stack.)
### Gitea Actions workflow (`.gitea/workflows/build-images.yaml`)
Same setup as the owner's working example: `runs-on: host`, `sh` steps, `apk add git docker-cli docker-cli-buildx`,
`docker login gitea.lan` with the repository secret `kvitt_cicd` (created by the owner) and `${{ github.actor }}`,
a buildx builder whose `buildkitd.toml` sets `http = true` for `gitea.lan` (created once and reused, so its cache
survives), `actions/checkout@v4` with `set-safe-directory` and `fetch-depth: 0`, the version from the newest
`v1.0.*` git tag plus one, and the git tag pushed after the build. That version is what `deploy.sh` takes.
The token reaches `docker login` through an environment variable, never as text in the script. `kvitt_cicd` needs
only the `write:package` scope: the tag push uses the credentials `actions/checkout` stored (the job's own token).
The token is interpolated into the login step's `run` line (`printf '%s' "${{ secrets.kvitt_cicd }}" | docker login
...`), the owner's proven form: the first run on the runner (2026-10-19) got an empty token from the env-based form
(`env: REGISTRY_TOKEN: ${{ secrets.kvitt_cicd }}`) on act_runner 0.6.1 and failed with "password is empty";
act_runner masks the secret in the log. That first run got as far as the login (container, `apk add`, checkout
all worked). `kvitt_cicd` needs only the `write:package` scope: the tag push uses the credentials `actions/checkout` stored (the job's own token).
If the "Tag and Push Version" step fails with a permission error, the job token cannot push tags on this Gitea;
then add `write:repository` to the token (owner decision below). The step "Print image digests" prints each
image's `sha256:` digest, which must match what `deploy.sh` prints on the Pi (D-117).
@@ -262,7 +268,7 @@ it fails for memory, add swap on the runner Pi, or build on the Mac with `infra/
### Mac fallback: `infra/build-images.sh [tag]`
Builds the same three images for linux/arm64 and pushes them when `infra/secrets/registry.env` exists
Builds the same two images for linux/arm64 and pushes them when `infra/secrets/registry.env` exists
(`KVITT_REGISTRY=gitea.lan/jacobh2`, optional `KVITT_REGISTRY_USER` and `KVITT_REGISTRY_TOKEN` with
write:package); otherwise it builds locally and prints the exact push commands. Tag: the short git SHA by default,
plus `latest`. Refuses a dirty tree and does not build while a Gradle build holds the gradle-serial lock. With
@@ -294,12 +300,10 @@ Fill these in first (in `infra/pi/.env` on the Mac; the bundle carries the file
| Placeholder in `infra/pi/.env` | What | Where to find it |
|---|---|---|
| `KVITT_PI_HOST` | LAN address of the Kvitt Pi (give it a static DHCP lease) | ASUS router web UI, LAN, DHCP Server, client list |
| `KVITT_PUBLIC_HOST` | Kvitt's own public host name (D-120), for example a DuckDNS name or a name in your own domain, pointing at the home's public address like `icehack.asuscomm.com` does. Not `icehack.asuscomm.com` itself: that name is NPM's proxy host for Home Assistant, and NPM chooses the proxy host by name, not by port | Your choice; tell the orchestrator the name (it goes into the app and `docs/HANDOFF.md`) |
| `KVITT_PI_HOST` | LAN IPv4 address of the Kvitt Pi (give it a static DHCP lease); also NPM's Forward Hostname / IP | ASUS router web UI, LAN, DHCP Server, client list |
| `KVITT_PI_SSH_USER` | Your user on the Kvitt Pi | The user set in Raspberry Pi Imager |
| `HA_HOST` | LAN address of the Home Assistant Pi | Router client list, or Home Assistant, Settings, System, Network |
| `HA_PORT` | SSH port of the SSH add-on (default 22) | Home Assistant, Settings, Add-ons, the SSH add-on, Configuration, Network |
| `HA_USER` | SSH user of the add-on (`root` for "Terminal & SSH"; the configured `username` for "Advanced SSH & Web Terminal"); `root` only with the restricted key of step 3, otherwise a dedicated non-root user | Same add-on, Configuration |
| `HA_CERT_DIR` | Directory holding `fullchain.pem` and `privkey.pem` on the Home Assistant Pi (`/ssl` on Home Assistant OS) | In the add-on's terminal: `ls -l /ssl` |
| `HA_HOST` | LAN IPv4 address of the Home Assistant Pi, where NPM runs (static DHCP lease); the server trusts `X-Forwarded-For` only from it. An address such as `192.168.1.20`, not `homeassistant.local` | Router client list, or Home Assistant, Settings, System, Network |
| `KVITT_TAG` | Image tag to run: the newest `v1.0.N` the Gitea workflow pushed (or the SHA from `build-images.sh`) | Gitea, the repository, Packages, `kvitt-server` |
| `KVITT_REGISTRY_USER`, `KVITT_REGISTRY_TOKEN` | Only if the Gitea packages are private: a Gitea user and a token with `read:package` | Gitea, Settings, Applications, Generate New Token |
| `KVITT_REGISTRY` | Already `gitea.lan/jacobh2` | |
@@ -337,43 +341,23 @@ exit
Log in again; `docker compose version` prints a version and `docker run --rm hello-world` prints
"Hello from Docker!".
### 3. Put the cert-sync key on the Home Assistant SSH add-on
Home Assistant, Settings, Add-ons, your SSH add-on, Configuration, `authorized_keys`: add **one** new entry, the
restricted key (variant A; replace `/ssl` if your `HA_CERT_DIR` differs):
```
restrict,command="tar -C /ssl -cf - fullchain.pem privkey.pem" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMiXj2dU8GU0Xmdj91WxLhVpPOI1zRWvBF5h2MbE4S8V kvitt-cert-sync
```
Optionally also limit it to the Kvitt Pi: start the line with `restrict,from="<KVITT_PI_HOST>",command=...`.
Save, restart the add-on; its Log tab shows no error about the key.
**A plain key for `root` (the old variant B) is not acceptable (D-117).** The Home Assistant SSH add-ons log in as
root, and without `restrict,command=` whoever holds this key (a compromised cert-sync container, or a copy of the
key on the Mac or the Pi) gets an unrestricted root shell on the Home Assistant Pi, which also owns ports 80/443
and the house's automation. If the add-on refuses the restricted line, use a dedicated non-root user instead: a
user with no `sudo` that can only read `HA_CERT_DIR` (set `HA_USER` to it), with the plain key line in that user's
`authorized_keys`. If neither is possible, stop and tell the orchestrator; the fallback is Caddy's internal CA
(SPEC §10.5.3). cert-sync works unchanged with both: it always asks for exactly that `tar` command. Tell the
orchestrator which one the add-on accepted (restricted root key, or the dedicated user), so it is recorded here.
The private key is `/Users/jhag/projects/kvitt/infra/secrets/ha_ssh_key` (git-ignored; never paste it anywhere).
### 4. Fill the `.env` and stage the secrets, on the Mac
### 3. Fill the `.env` and stage the secrets, on the Mac
```bash
cd /Users/jhag/projects/kvitt
cp infra/pi/.env.example infra/pi/.env
open -e infra/pi/.env # replace every CHANGE_ME (table above), save
mkdir -p infra/pi/secrets
cp infra/secrets/fcm-service-account.json infra/secrets/ha_ssh_key infra/secrets/rclone.conf infra/pi/secrets/
cp infra/secrets/fcm-service-account.json infra/secrets/rclone.conf infra/pi/secrets/
```
Only these two secret files go to the Pi. `infra/secrets/ha_ssh_key` and `ha_ssh_key.pub` are not used since
D-120: do not copy them (and no SSH add-on or `authorized_keys` entry on the Home Assistant Pi is needed).
`rclone.conf` is already generated from your `rclone authorize "drive"` (remote `[gdrive]`, `type = drive`,
`scope = drive`, the token); the Drive folder id is `RCLONE_FOLDER_ID` in `.env`, not in the conf.
`infra/pi/.env` and `infra/pi/secrets/` are git-ignored.
### 5. Copy the bundle to the Pi and record the Home Assistant host key
### 4. Copy the bundle to the Pi
On the Mac:
@@ -382,15 +366,7 @@ scp -r infra/pi <KVITT_PI_SSH_USER>@<KVITT_PI_HOST>:kvitt-pi
rm -r infra/pi/secrets # the originals stay in infra/secrets/
```
On the Pi:
```bash
cd ~/kvitt-pi
ssh-keyscan -p <HA_PORT> <HA_HOST> > secrets/ha_known_hosts
wc -l secrets/ha_known_hosts # at least 1 line
```
### 6. Install and start
### 5. Install and start
On the Pi:
@@ -398,20 +374,146 @@ On the Pi:
cd ~/kvitt-pi && ./install.sh
```
It creates `/opt/kvitt/{secrets,import,apk}`, installs the four secret files (`fcm-service-account.json` 0440
root:10001; `ha_ssh_key`, `ha_known_hosts` 0600 1000:1000; `rclone.conf` 0600 10001:10001), adds `gitea.lan` to
`insecure-registries` in `/etc/docker/daemon.json` (and restarts Docker if that changed), copies `compose.yaml`,
`Caddyfile`, `.env` (0600) and the scripts to `/opt/kvitt`, pulls, starts and waits. Expected end:
`server: healthy`, `cert-sync: healthy`, `caddy: healthy`, a `docker compose ps` table, the line
`GET https://icehack.asuscomm.com:8443/health (via this Pi): {"status":"ok",...}`, then "Kvitt is running.".
It checks the `.env` (`KVITT_PUBLIC_HOST` a host name, `HA_HOST` an IPv4 address), creates
`/opt/kvitt/{secrets,import,apk}`, installs the two secret files (`fcm-service-account.json` 0440 root:10001;
`rclone.conf` 0600 10001:10001), adds `gitea.lan` to `insecure-registries` in `/etc/docker/daemon.json` (and
restarts Docker if that changed), copies `compose.yaml`, `.env` (0600) and the scripts to `/opt/kvitt`, pulls,
starts and waits. If an earlier bundle with Caddy and cert-sync was installed, it also removes its leftovers
(`/opt/kvitt/Caddyfile`, `secrets/ha_ssh_key`, `secrets/ha_known_hosts`, the containers and the volumes
`kvitt_certs` and `kvitt_caddy_data`) and says so. Expected end: `server: healthy`, a `docker compose ps` table with
`server` (ports `0.0.0.0:8080->8080/tcp`) and `backup`, the line `GET http://localhost:8080/health: {"status":"ok",...}`,
then "Not reachable through Nginx Proxy Manager yet" (expected on the first install, before step 6) or
`GET https://<KVITT_PUBLIC_HOST>:443/health (via NPM at <HA_HOST>): {"status":"ok",...}`, then "Kvitt is running.".
Then `rm -r ~/kvitt-pi/secrets` (the script reminds you). Running `install.sh` again is safe. Re-running
`install.sh` keeps the `KVITT_TAG` that `deploy.sh` set; change the tag only with `deploy.sh <tag>`.
### 6. Nginx Proxy Manager on the Home Assistant Pi
NPM terminates TLS for Kvitt with its own Let's Encrypt certificate for `<KVITT_PUBLIC_HOST>` and forwards plain
HTTP over the home LAN to the Kvitt Pi's port 8080 (plain HTTP between the two Pis is accepted, D-120). First make
sure `<KVITT_PUBLIC_HOST>` points at the home's public address (your DNS or DuckDNS page shows the same address as
`icehack.asuscomm.com`). Let's Encrypt's HTTP-01 challenge reaches NPM through external port 80, which already
works: it is how NPM got the certificate for Home Assistant. Serving Kvitt under a path of `icehack.asuscomm.com`
(for example `/kvitt/`) is not supported.
Open NPM's web UI (Home Assistant, Settings, Add-ons, Nginx Proxy Manager, Open Web UI), then Hosts, Proxy Hosts,
Add Proxy Host.
Details tab:
| Field | Value |
|---|---|
| Domain Names | `<KVITT_PUBLIC_HOST>` |
| Scheme | `http` |
| Forward Hostname / IP | `<KVITT_PI_HOST>` (the IPv4 address) |
| Forward Port | `8080` |
| Cache Assets | off |
| Block Common Exploits | on |
| Websockets Support | off |
| Access List | Publicly Accessible |
Custom Locations tab: leave empty.
SSL tab:
| Field | Value |
|---|---|
| SSL Certificate | Request a new Certificate (Let's Encrypt, HTTP challenge: Use DNS Challenge off); Email address: yours; if a Let's Encrypt Terms of Service checkbox is shown, tick it |
| Force SSL | on |
| HTTP/2 Support | on |
| HSTS Enabled | on |
| HSTS Sub-domains | off |
| Trust Upstream Forwarded Proto Headers | off |
Advanced tab, Custom Nginx Configuration: paste this block exactly (all of it, comments included):
```nginx
# Kvitt (SPEC §10.5.2, D-120). NPM, Proxy Host <KVITT_PUBLIC_HOST>, Advanced tab, Custom Nginx Configuration.
# Server body: 1 MiB, PUT /me/picture 5 MiB (SPEC §8.6); the server answers anything larger with its own 413.
client_max_body_size 6m;
# No long-poll in the API (SPEC §14.3); 60 s between two reads from the server.
proxy_read_timeout 60s;
# Response headers (D-117). They live at server level, so no location below may use add_header (nginx would then
# drop all of these in it); NPM's HSTS line (SSL tab) sits at the same level. The server's own copy is hidden, so
# each header appears once (the landing page sends Referrer-Policy itself).
proxy_hide_header Content-Security-Policy;
proxy_hide_header X-Content-Type-Options;
proxy_hide_header X-Frame-Options;
proxy_hide_header Referrer-Policy;
add_header Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy no-referrer always;
# Replaces NPM's own "location /" (NPM leaves it out when this field has one), whose add_header X-Served-By would
# drop the headers above. $forward_scheme, $server and $port are NPM's Details tab values.
location / {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_pass $forward_scheme://$server:$port$request_uri;
}
# Invite links /i/<code>?from=<name>: neither the code nor the inviter's name may reach a log (SPEC §8.7). NPM's
# access log format writes the whole $request_uri, query string included, so this path is not logged at all, and
# its error log (which would carry the request line on a 502) goes nowhere.
location ^~ /i/ {
access_log off;
error_log /dev/null crit;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_pass $forward_scheme://$server:$port$request_uri;
}
```
Save. The new row under Proxy Hosts shows the status Online and an SSL entry for Let's Encrypt (yes/no). Then on
the Kvitt Pi:
```bash
curl -fsS --resolve <KVITT_PUBLIC_HOST>:443:<HA_HOST> https://<KVITT_PUBLIC_HOST>/health
```
It prints `{"status":"ok",...}` (no `-k`: this also checks the certificate). `deploy.sh` and `install.sh` run the
same check.
**Why the block has its own `location /`.** nginx drops every `add_header` inherited from the server level in a
location that has an `add_header` of its own, and NPM's default `location /` has one (`X-Served-By`), which would
silently drop the four security headers (D-117). NPM leaves its default location out when the Custom Nginx
Configuration contains `location /`, so the block brings its own without `add_header`; NPM's HSTS line from the SSL
tab stays at server level and still applies. The block's `proxy_pass` uses NPM's Forward Hostname / IP and Forward
Port, so change those on the Details tab as usual. If a later NPM release changes its proxy host template, check
the headers again (Operations, "Headers through NPM").
**What NPM logs (SPEC §8.7).** NPM writes one access log line per request to `/data/logs/proxy-host-<id>_access.log`
inside NPM, in its `proxy` format: time, cache and upstream status, status, method, scheme, host,
`"$request_uri"` (the path **with** its query string), `[Client <address>]`, length, gzip ratio,
`[Sent-to <KVITT_PI_HOST>]`, user agent and referer. It logs no request headers, so the `Authorization` token is never
logged. Query strings are logged for every path except `/i/`; the other query parameters of the API are `since` and
`limit` (`GET /sync`) and `size` and `v` (pictures), which hold no personal content. `/i/<code>?from=<name>` is not
logged at all: the block turns the access log off for `/i/` and sends its error log to `/dev/null`, so neither the
invite code nor the inviter's name reaches a log. Errors on other paths (for example a 502 while the server is down)
go to `proxy-host-<id>_error.log` with the request line.
**HSTS.** HSTS tells a browser to use only HTTPS for `<KVITT_PUBLIC_HOST>`, on every port, for two years
(`max-age=63072000; preload`, NPM's value), and to refuse certificate warnings for it. Because the name is Kvitt's
own, nothing else is affected; but if you ever serve anything else under `<KVITT_PUBLIC_HOST>` (any port), it must be
HTTPS with a valid certificate too, and turning HSTS off later does not clear browsers that already stored it. The
app itself does not rely on HSTS (release builds allow no cleartext, SPEC §8.5); it protects the landing page in a
browser.
**Accepted: NPM trusts `X-Real-IP` from the LAN.** NPM's `nginx.conf` takes the client address from `X-Real-IP` when
the connection comes from 10/8, 172.16/12, 192.168/16 (and the CDN ranges it downloads), so a device on the home
LAN can choose its own client address for the rate limits. Internet clients arrive with their public address (the
router's forward keeps the source address), so their `X-Real-IP` is ignored.
### 7. Router forward
ASUS router web UI, WAN, Virtual Server / Port Forwarding: add a rule, protocol TCP, external port 8443, internal
IP `<KVITT_PI_HOST>`, internal port 8443; Apply. Then on the phone with Wi-Fi off, open
`https://icehack.asuscomm.com:8443/health` in Chrome: it shows `"status":"ok"` without a certificate warning (yes/no).
ASUS router web UI, WAN, Virtual Server / Port Forwarding: add a rule: Service Name `Kvitt`, Protocol TCP, External
Port `8443`, Internal Port `443` (NPM's HTTPS port; if the add-on's Network settings map `443/tcp` to another host
port, use that one), Internal IP Address `<HA_HOST>`; Apply. No rule forwards to the Kvitt Pi (remove one to
`<KVITT_PI_HOST>` if you made it for the Caddy version). Then on the phone with Wi-Fi off, open
`https://<KVITT_PUBLIC_HOST>:8443/health` in Chrome: it shows `"status":"ok"` without a certificate warning (yes/no).
### 8. First backup and bootstrap
@@ -422,6 +524,10 @@ On the Pi:
/opt/kvitt/bootstrap.sh # first install only: prints the admin invite code, link and expiry
```
The invite link starts with `https://<KVITT_PUBLIC_HOST>:8443/i/`. The release app opens such links and connects to
that host only once an Android build carries the new host name (SPEC §8.5; until then the app knows
`icehack.asuscomm.com`): tell the orchestrator before pairing a release build.
### 9. Deploying a new version
After the Gitea workflow pushed `v1.0.N` (or `infra/build-images.sh` printed a tag), on the Pi:
@@ -432,72 +538,71 @@ After the Gitea workflow pushed `v1.0.N` (or `infra/build-images.sh` printed a t
It backs up first (Drive confirmed) on every deploy, because any tag may carry a migration (SPEC §10.10), sets
`KVITT_TAG`, pulls, prints the `sha256:` digest of every image and appends it to `/opt/kvitt/deployed-digests`,
starts, waits for `server: healthy` and prints `/health`. The images come over plain HTTP from `gitea.lan`
(D-001), so compare the printed digests with the workflow's "Print image digests" step for that run; a line
starting `WARNING:` means a tag that was deployed before now points at a different image: do not continue, roll
back and find out why (D-117). If the server does not come up:
starts, waits for `server: healthy` and prints `/health` on port 8080 and through NPM (a failure through NPM is a
WARNING: the deploy succeeded, but Kvitt is not reachable from outside). The images come over plain HTTP from
`gitea.lan` (D-001), so compare the printed digests with the workflow's "Print image digests" step for that run; a
line starting `WARNING:` about a digest means a tag that was deployed before now points at a different image: do not
continue, roll back and find out why (D-117). If the server does not come up:
`/opt/kvitt/deploy.sh "$(cat /opt/kvitt/previous-tag)" --skip-backup`; if a migration had partially applied,
restore the pre-deploy backup (`infra/RESTORE.md`). A newer bundle with a changed `compose.yaml` or `Caddyfile` is
installed by copying the bundle again (step 5, without the secrets) and running `./install.sh`.
restore the pre-deploy backup (`infra/RESTORE.md`). A newer bundle with a changed `compose.yaml` is installed by
copying the bundle again (step 4, without the secrets) and running `./install.sh`.
### Fallback: Caddy internal CA (SPEC §10.5.3, not deployed)
### Fallback (SPEC §10.5.3): when NPM is down
Only if cert-sync cannot be made to work. In `/opt/kvitt/Caddyfile` replace the line
`tls /certs/fullchain.pem /certs/privkey.pem` with `tls internal`, then `docker compose restart caddy` in
`/opt/kvitt`. Copy Caddy's root to the Mac once: on the Pi
`docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt /tmp/kvitt_root_ca.crt`, then on the Mac
`scp <KVITT_PI_SSH_USER>@<KVITT_PI_HOST>:/tmp/kvitt_root_ca.crt android/app/src/release/res/raw/kvitt_root_ca.crt`
(a public certificate, committed by an Android agent) and set `kvitt.tls.pinnedRoot=true` in
`android/gradle.properties` (D-082). The root is valid 10 years. Caddy still waits for cert-sync to be healthy,
which needs the two files in the `certs` volume: keep the last fetched pair there, or remove the `cert-sync`
condition from `depends_on` in `/opt/kvitt/compose.yaml`.
* NPM or the Home Assistant Pi down means Kvitt is unreachable from outside, and also from home over
`https://<KVITT_PUBLIC_HOST>:8443` (that path goes through NPM too). The server on the Kvitt Pi keeps running and
nothing there needs to change. The phones keep working on their full local copy and sync when NPM is back.
* On the home Wi-Fi only **debug builds** can reach the server directly, at `http://<KVITT_PI_HOST>:8080` (AC-2.8
allows `http://` to private LAN addresses in debug builds only). **Release builds require HTTPS with a publicly
trusted certificate (SPEC §8.5) and cannot use that address: there is no LAN fallback for the release app.** A
debug build is a separate app (`se.hagstedt.kvitt.debug`) with its own pairing and data, not a stand-in for the
release app.
* If NPM cannot renew the Let's Encrypt certificate: fix it in NPM (SSL Certificates, the certificate, Renew Now;
port 80 must reach NPM). The pinned-root switch of the app (`kvitt.tls.pinnedRoot`, D-082) still exists for a
self-made root whose certificate would be uploaded to NPM as a Custom certificate; it is not prepared: tell the
orchestrator.
### Operations (SPEC §10.10), on the Pi in `/opt/kvitt`
| Task | Command |
|---|---|
| Status | `docker compose ps` |
| Logs | `docker compose logs --tail 100 <caddy, server, cert-sync or backup>` |
| Logs | `docker compose logs --tail 100 <server or backup>`; NPM's own log files are inside NPM on the Home Assistant Pi |
| Backup now | `/opt/kvitt/backup-now.sh` |
| Upgrade | `/opt/kvitt/deploy.sh <tag>` |
| Roll back | `/opt/kvitt/deploy.sh "$(cat /opt/kvitt/previous-tag)" --skip-backup` |
| Certificate now | `docker compose exec cert-sync /usr/local/bin/sync.sh` |
| Certificate expiry and issuer | `docker compose exec cert-sync openssl x509 -in /certs/fullchain.pem -noout -enddate -issuer` |
| Health through NPM | `curl -fsS --resolve <KVITT_PUBLIC_HOST>:443:<HA_HOST> https://<KVITT_PUBLIC_HOST>/health` |
| Headers through NPM | `curl -sS -D - -o /dev/null --resolve <KVITT_PUBLIC_HOST>:443:<HA_HOST> https://<KVITT_PUBLIC_HOST>/health` lists `content-security-policy`, `x-content-type-options`, `x-frame-options`, `referrer-policy`, `strict-transport-security` once each and no `x-served-by` |
| Certificate expiry and issuer | `echo \| openssl s_client -connect <HA_HOST>:443 -servername <KVITT_PUBLIC_HOST> 2>/dev/null \| openssl x509 -noout -enddate -issuer` (NPM renews it; NPM, SSL Certificates shows the same) |
| Extra device invite | `docker compose exec server kvitt-server pair-device --user <user_id>` |
| Memory | `docker stats --no-stream` (server RSS under 100 MB with both phones syncing) |
| Disk | `df -h /` and `docker system df`; SQLite a few MB, pictures under 1 MB, APKs about 10 MB each since R8, 5 kept |
| No token in Caddy's log | `docker compose logs caddy \| grep -c 'kvt_'` prints `0` |
| No invite code or name in Caddy's log | `docker compose logs caddy \| grep '"uri":"/i/' \| grep -vc '/i/REDACTED'` prints `0` |
| Image digests deployed | `tail -n 8 /opt/kvitt/deployed-digests` |
| Client IPs are real (once, after the router forward) | open `https://icehack.asuscomm.com:8443/health` on the phone with Wi-Fi off, then `docker compose logs --tail 20 caddy \| grep -o '"remote_ip":"[^"]*"'`: the address is your mobile network's public address, not `172.*` or `10.*`. If every request shows the Docker bridge gateway, all internet clients share one per-IP rate limit (SPEC §8.6): tell the orchestrator |
| Client IPs are real (once, after the router forward) | Open `https://<KVITT_PUBLIC_HOST>:8443/health` on the phone with Wi-Fi off, then within a minute on the Kvitt Pi `sudo apt install -y conntrack` (once) and `sudo conntrack -L -p tcp --orig-port-dst 8080`: in every line the first `src=` is `<HA_HOST>` and the second half (from the second `src=`) has `dst=<HA_HOST>`, no `172.*` there, so the server sees NPM's address and trusts its `X-Forwarded-For`. If the second half shows a `172.*` address instead, every internet client shares one per-IP limit (SPEC §8.6): tell the orchestrator |
| Restore | `infra/RESTORE.md` |
**APK downloads (security review L-3).** `GET /apk/files/<name>` is public (the landing page links it) and about
11 MB per response; the only limit is the general 60 requests per minute per IP of SPEC §8.6, so one stranger can
pull several hundred MB a minute and fill the home uplink. A lower per-IP limit for this path is a server change
and a SPEC number that is not decided yet (owner decision below). Until then, if `docker compose logs caddy`
shows one address fetching `/apk/files/` over and over, remove the router forward (step 7) until it stops.
**APK downloads (security review L-3, D-119).** `GET /apk/files/<name>` is public (the landing page links it) and
about 11 MB per response; the server allows 10 per minute per client IP. If one address still fetches
`/apk/files/` over and over (NPM's access log), remove the router forward (step 7) until it stops.
### Owner decisions (open)
1. **Backup encryption (security review M-1).** The Drive backups (`kvitt-*.sqlite.gz`, `pictures-*.tar.gz`) are
not encrypted (SPEC §8.7), and `rclone.conf` holds a token with the full `drive` scope (read and write to your
whole Drive). Should the backups go through an rclone `crypt` remote (two passwords kept in `rclone.conf`,
restore through the same remote) and should rclone be re-authorised with the narrower `drive.file` scope? Both
change SPEC §8.7 and §10.9. Until you decide, the backup scripts stay as they are.
2. **APK download limit (security review L-3).** May `/apk/files/*` get its own lower per-IP limit (for example a
few downloads per minute per IP)? The number goes into SPEC §8.6 and the server; it is not set here.
3. **cert-sync key (security review M-2, step 3).** Which form did the Home Assistant add-on accept: the restricted
root key, or a dedicated non-root user?
4. **Gitea token scope.** Does the workflow's tag push work with `kvitt_cicd` limited to `write:package`? If not,
Backup encryption (security review M-1) is decided: no rclone `crypt`, the `drive` scope stays (SPEC §8.7, 2026-10-19).
1. **Gitea token scope.** Does the workflow's tag push work with `kvitt_cicd` limited to `write:package`? If not,
is adding `write:repository` acceptable?
2. **Kvitt's host name (D-120).** Which name is `<KVITT_PUBLIC_HOST>`? It goes into the release app and
`docs/HANDOFF.md`.
### Troubleshooting
* `cert-sync` unhealthy, so `caddy` does not start: `docker compose logs cert-sync`. "Host key verification
failed": redo `ssh-keyscan` (step 5) and run `./install.sh` again. "Permission denied (publickey)": the add-on
does not have the key (step 3). "did not hold fullchain.pem and privkey.pem": wrong `HA_CERT_DIR`, or `HA_USER`
cannot read `privkey.pem`.
* Step 6 check or `deploy.sh` says /health through NPM failed: "Could not resolve host" or "SSL certificate problem":
the proxy host has no certificate for `<KVITT_PUBLIC_HOST>` yet (SSL tab) or a different name; a `502 Bad Gateway`:
NPM cannot reach `<KVITT_PI_HOST>:8080` (wrong Forward Hostname / IP or Forward Port, or the server is down:
`docker compose ps`); the Home Assistant page instead of `{"status":"ok"}`: Domain Names does not match the name
you requested (NPM fell back to another host).
* "Request a new Certificate" fails in NPM: `<KVITT_PUBLIC_HOST>` does not point at the home's public address yet,
or external port 80 does not reach NPM.
* Pull fails with "http: server gave HTTP response to HTTPS client": `gitea.lan` is missing from
`/etc/docker/daemon.json` (`install.sh` adds it; check with `docker info | grep -A3 'Insecure Registries'`).
* Pull fails with "unauthorized": the packages are private; set `KVITT_REGISTRY_USER` and `KVITT_REGISTRY_TOKEN`
+3 -2
View File
@@ -71,10 +71,11 @@ It prints the new epoch. The phones see it and re-push their data (§14.4.8).
```bash
docker compose start server
docker compose ps server # healthy within a minute
curl -fsS --resolve icehack.asuscomm.com:8443:127.0.0.1 https://icehack.asuscomm.com:8443/health
curl -fsS http://localhost:8080/health
```
The last line prints `{"status":"ok",...}`.
The last line prints `{"status":"ok",...}` (the server's own port; the path through Nginx Proxy Manager is
unchanged by a restore).
## 7. The phones
+3 -5
View File
@@ -1,11 +1,10 @@
#!/usr/bin/env bash
# Build the three Kvitt images for linux/arm64 on the Mac and push them to the Gitea registry (SPEC §10.3).
# Build the two Kvitt images for linux/arm64 on the Mac and push them to the Gitea registry (SPEC §10.3).
# The Mac fallback for the Gitea Actions workflow .gitea/workflows/build-images.yaml (which is the normal path).
# Usage: infra/build-images.sh [tag] tag defaults to the short git SHA of HEAD; `latest` is always tagged too.
#
# Images (D-104): ${KVITT_REGISTRY}/kvitt-server (context server/), kvitt-cert-sync (infra/cert-sync),
# kvitt-backup (infra/backup). KVITT_REGISTRY and the optional credentials come from infra/secrets/registry.env
# (git-ignored):
# Images (D-104, D-120): ${KVITT_REGISTRY}/kvitt-server (context server/) and kvitt-backup (infra/backup).
# KVITT_REGISTRY and the optional credentials come from infra/secrets/registry.env (git-ignored):
# KVITT_REGISTRY=gitea.lan/jacobh2
# KVITT_REGISTRY_USER=<gitea user> optional
# KVITT_REGISTRY_TOKEN=<token, write:package> optional
@@ -41,7 +40,6 @@ registry_host="${registry%%/*}"
# name|context|dockerfile
images=(
"kvitt-server|server|server/Dockerfile"
"kvitt-cert-sync|infra/cert-sync|infra/cert-sync/Dockerfile"
"kvitt-backup|infra/backup|infra/backup/Dockerfile"
)
-26
View File
@@ -1,26 +0,0 @@
# Kvitt cert-sync image (SPEC §10.3, §10.5.1): alpine with openssh-client, curl and sync.sh on a daily schedule.
# Versions resolved 2026-10-18: alpine 3.24.2 (Docker Hub official image, multi-arch index digest below); packages
# from Alpine v3.24 main/community: openssh-client-default 10.3_p1, curl 8.22.0, openssl 3.5.9 (PEM checks),
# supercronic 0.2.49, tzdata 2026e.
# The scheduler is supercronic, not busybox crond: the container runs as uid 1000 (SPEC §10.2, D-023) and busybox
# crond cannot run jobs as a non-root user (checked 2026-10-18 in alpine 3.24.2: no job ever ran). See D-104.
FROM docker.io/library/alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
RUN apk add --no-cache \
"openssh-client-default~10.3" \
"curl~8.22" \
"openssl~3.5" \
"supercronic~0.2" \
tzdata \
&& adduser -D -u 1000 -g "Kvitt cert-sync" kvitt \
&& mkdir -p /certs \
&& chown 1000:1000 /certs
COPY sync.sh entrypoint.sh /usr/local/bin/
RUN chmod 0755 /usr/local/bin/sync.sh /usr/local/bin/entrypoint.sh \
&& echo '0 4 * * * /usr/local/bin/sync.sh' > /etc/kvitt-cert-sync.crontab
ENV TZ=Europe/Stockholm
USER 1000:1000
# sync.sh once at start, then daily at 04:00 Europe/Stockholm.
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
-5
View File
@@ -1,5 +0,0 @@
#!/bin/sh
# cert-sync container start (SPEC §10.5.1): one sync at start, then the daily schedule. A failed first sync keeps
# the container running (the healthcheck reports whether a certificate exists).
/usr/local/bin/sync.sh || true
exec supercronic -quiet /etc/kvitt-cert-sync.crontab
-95
View File
@@ -1,95 +0,0 @@
#!/bin/sh
# Kvitt certificate sync (SPEC §10.5.1). Fetches fullchain.pem and privkey.pem from the Home Assistant Pi over SSH
# and installs them into /certs when they are valid, newer and changed; then reloads Caddy through its admin API.
# Any failure logs one line and keeps the existing certificate.
#
# The remote command is always `tar -C <cert dir> -cf - fullchain.pem privkey.pem`, read as a tar stream. That
# works with both authorized_keys variants of the README: with `restrict,command="tar ..."` the forced command
# runs whatever is requested, and with a plain key the requested command is the same tar line.
#
# Environment (from /opt/kvitt/.env): HA_HOST, HA_PORT, HA_USER, HA_CERT_DIR.
# Files: /secrets/ha_ssh_key, /secrets/known_hosts (read-only), /certs (writable), /etc/caddy/Caddyfile (read-only).
set -eu
CERTS=${CERTS_DIR:-/certs}
KEY=${HA_SSH_KEY:-/secrets/ha_ssh_key}
KNOWN_HOSTS=${HA_KNOWN_HOSTS:-/secrets/known_hosts}
CADDYFILE=${CADDYFILE:-/etc/caddy/Caddyfile}
CADDY_ADMIN=${CADDY_ADMIN:-http://caddy:2019}
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) cert-sync: $*"; }
tmp=""
cleanup() { [ -n "$tmp" ] && rm -rf "$tmp"; return 0; }
trap cleanup EXIT INT TERM
fail() { log "FAILED: $*; existing certificate kept"; exit 1; }
for v in HA_HOST HA_PORT HA_USER HA_CERT_DIR; do
eval "val=\${$v:-}"
# shellcheck disable=SC2154 # val is set by the eval above
[ -n "$val" ] || fail "$v is not set"
done
[ -r "$KEY" ] || fail "no readable key at $KEY"
[ -r "$KNOWN_HOSTS" ] || fail "no readable known_hosts at $KNOWN_HOSTS"
tmp=$(mktemp -d /tmp/cert-sync.XXXXXX)
# 1. Fetch as a tar stream.
if ! ssh -i "$KEY" -p "$HA_PORT" \
-o BatchMode=yes -o IdentitiesOnly=yes -o ConnectTimeout=20 \
-o StrictHostKeyChecking=yes -o UserKnownHostsFile="$KNOWN_HOSTS" -o GlobalKnownHostsFile=/dev/null \
-o LogLevel=ERROR \
"$HA_USER@$HA_HOST" "tar -C '$HA_CERT_DIR' -cf - fullchain.pem privkey.pem" > "$tmp/certs.tar" 2> "$tmp/ssh.err"; then
fail "ssh to $HA_USER@$HA_HOST:$HA_PORT failed: $(head -c 300 "$tmp/ssh.err" | tr '\n' ' ')"
fi
mkdir "$tmp/new"
tar -xf "$tmp/certs.tar" -C "$tmp/new" fullchain.pem privkey.pem 2>/dev/null || fail "the tar stream did not hold fullchain.pem and privkey.pem"
new_chain="$tmp/new/fullchain.pem"
new_key="$tmp/new/privkey.pem"
# 2. Both parse as PEM, the certificate has not expired, and the key belongs to the certificate.
openssl x509 -in "$new_chain" -noout 2>/dev/null || fail "fullchain.pem does not parse as a PEM certificate"
openssl pkey -in "$new_key" -noout 2>/dev/null || fail "privkey.pem does not parse as a PEM private key"
openssl x509 -in "$new_chain" -noout -checkend 0 >/dev/null 2>&1 || fail "fullchain.pem has expired"
cert_pub=$(openssl x509 -in "$new_chain" -noout -pubkey | openssl pkey -pubin -outform DER 2>/dev/null | sha256sum | cut -d' ' -f1)
key_pub=$(openssl pkey -in "$new_key" -pubout -outform DER 2>/dev/null | sha256sum | cut -d' ' -f1)
[ "$cert_pub" = "$key_pub" ] || fail "privkey.pem does not belong to fullchain.pem"
new_end_iso=$(openssl x509 -in "$new_chain" -noout -enddate -dateopt iso_8601 | cut -d= -f2)
# 3. Unchanged? Older than the installed one?
cur_chain="$CERTS/fullchain.pem"
cur_key="$CERTS/privkey.pem"
if [ -s "$cur_chain" ] && [ -s "$cur_key" ]; then
if [ "$(sha256sum < "$new_chain")" = "$(sha256sum < "$cur_chain")" ] && \
[ "$(sha256sum < "$new_key")" = "$(sha256sum < "$cur_key")" ]; then
log "unchanged (not after $new_end_iso)"
exit 0
fi
# ISO 8601 not-after dates compare as strings.
cur_end_iso=$(openssl x509 -in "$cur_chain" -noout -enddate -dateopt iso_8601 2>/dev/null | cut -d= -f2 || true)
if [ -n "$cur_end_iso" ] && openssl x509 -in "$cur_chain" -noout -checkend 0 >/dev/null 2>&1 && \
[ "$new_end_iso" != "$cur_end_iso" ] && \
[ "$(printf '%s\n%s\n' "$new_end_iso" "$cur_end_iso" | sort | head -n 1)" = "$new_end_iso" ]; then
fail "fetched certificate (not after $new_end_iso) expires before the installed one ($cur_end_iso)"
fi
fi
# 4. Install: mode 0640, rename into place (key first, so a reload never pairs a new chain with an old key for long).
install -m 0640 "$new_key" "$CERTS/.privkey.pem.new" || fail "cannot write $CERTS"
install -m 0640 "$new_chain" "$CERTS/.fullchain.pem.new" || fail "cannot write $CERTS"
mv -f "$CERTS/.privkey.pem.new" "$cur_key"
mv -f "$CERTS/.fullchain.pem.new" "$cur_chain"
log "installed new certificate (not after $new_end_iso)"
# 5. Reload Caddy. At first start Caddy is not up yet (it waits for this container to be healthy) and loads the
# files when it starts, so an unreachable admin API is not a failure.
if curl -fsS -o /dev/null --max-time 20 -X POST "$CADDY_ADMIN/load" \
-H 'Content-Type: text/caddyfile' -H 'Cache-Control: must-revalidate' --data-binary @"$CADDYFILE" 2> "$tmp/curl.err"; then
log "caddy reloaded"
elif curl -fsS -o /dev/null --max-time 5 "$CADDY_ADMIN/config/" 2>/dev/null; then
log "FAILED: caddy reload: $(head -c 300 "$tmp/curl.err" | tr '\n' ' '); the new files are in place and load when Caddy restarts"
exit 1
else
log "caddy not reachable; it loads the certificate when it starts"
fi
+12 -11
View File
@@ -4,20 +4,21 @@
# --- Read by compose.yaml -----------------------------------------------------------------------------------------
# Registry and owner path of the Kvitt images; images are ${KVITT_REGISTRY}/kvitt-server|kvitt-cert-sync|kvitt-backup.
# Registry and owner path of the Kvitt images; images are ${KVITT_REGISTRY}/kvitt-server|kvitt-backup.
KVITT_REGISTRY=gitea.lan/jacobh2
# Image tag to run. deploy.sh sets it; first install: the newest version tag the Gitea workflow pushed (vMAJOR.MINOR.PATCH),
# or the short git SHA that infra/build-images.sh printed.
KVITT_TAG=CHANGE_ME_TAG
# LAN address (or host name) of the Home Assistant Pi that holds the Let's Encrypt certificate (OQ-3).
# Kvitt's own public host name (D-120), owner-chosen: a domain or DuckDNS name pointing at the home's public address.
# NPM holds its Let's Encrypt certificate; the router forwards external 8443 to NPM's 443. Invite links and the
# landing page use https://<KVITT_PUBLIC_HOST>:8443 (KVITT_PUBLIC_URL). Not icehack.asuscomm.com: that name is
# NPM's proxy host for Home Assistant.
KVITT_PUBLIC_HOST=CHANGE_ME_KVITT_PUBLIC_HOST
# LAN IPv4 address of the Home Assistant Pi, where Nginx Proxy Manager terminates TLS and forwards to this Pi's port
# 8080 (D-120). The server trusts X-Forwarded-For only from this address (KVITT_TRUSTED_PROXY), so it must be the
# address NPM's connections come from: an IP literal such as 192.168.1.20, not a host name (install.sh checks it).
# Kept the name HA_HOST (the proxy host); it no longer has the SSH settings HA_PORT, HA_USER, HA_CERT_DIR of cert-sync.
HA_HOST=CHANGE_ME_HA_HOST
# SSH port of the Home Assistant SSH add-on (OQ-3; the "Advanced SSH & Web Terminal" and "Terminal & SSH" add-ons default to 22).
HA_PORT=22
# SSH user on the Home Assistant Pi (OQ-3). root only with the restricted key line of infra/README.md step 3; otherwise a
# dedicated non-root user that can only read HA_CERT_DIR (D-117). A plain, unrestricted root key is not acceptable.
HA_USER=CHANGE_ME_HA_USER
# Directory holding fullchain.pem and privkey.pem on the Home Assistant Pi (OQ-3; /ssl on Home Assistant OS).
HA_CERT_DIR=CHANGE_ME_HA_CERT_DIR
# rclone remote of the Drive backups, as named in rclone.conf.
RCLONE_REMOTE=gdrive:
# Google Drive folder id the backups go to (OQ-4, answered).
@@ -30,9 +31,9 @@ KVITT_REGISTRY_USER=
# Gitea access token of that user with read:package scope; leave empty for anonymous pull. Never commit a filled .env.
KVITT_REGISTRY_TOKEN=
# --- Informational: used in the owner's commands on the Mac (scp), read by nothing on the Pi --------------------
# --- Informational: used in the owner's commands on the Mac (scp) and in NPM, read by nothing on the Pi ---------
# LAN address of the Kvitt Pi itself (OQ-3).
# LAN address of the Kvitt Pi itself (OQ-3); also NPM's Forward Hostname / IP (infra/README.md, NPM section).
KVITT_PI_HOST=CHANGE_ME_KVITT_PI_HOST
# SSH user on the Kvitt Pi (OQ-3).
KVITT_PI_SSH_USER=CHANGE_ME_KVITT_PI_SSH_USER
-38
View File
@@ -1,38 +0,0 @@
# Kvitt Caddyfile (SPEC §10.5.2, D-117). Installed as /opt/kvitt/Caddyfile; cert-sync reloads it through the admin API.
{
admin 0.0.0.0:2019
auto_https off
# Caddy's own error log (for example a 502 when the server is down) carries the request too: same URI filter.
log default {
output stderr
format filter {
request>uri regexp `^(/i/)[^?]+|([?]).+` `${1}${2}REDACTED`
wrap json
}
}
}
icehack.asuscomm.com:8443 {
tls /certs/fullchain.pem /certs/privkey.pem
request_body {
max_size 6MB
}
# Set after the server's response headers (defer), so a header the server also sends appears once.
header {
defer
Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy no-referrer
}
reverse_proxy server:8080
# Access log: the invite code in /i/<code> and every query string (the inviter's name in ?from=) become
# REDACTED (SPEC §8.7); the Authorization header is redacted by Caddy's default.
log {
output stdout
format filter {
request>uri regexp `^(/i/)[^?]+|([?]).+` `${1}${2}REDACTED`
wrap json
}
}
}
+12 -78
View File
@@ -1,7 +1,8 @@
# Kvitt production stack on the Kvitt Pi (SPEC §10.2). Deployed by install.sh to /opt/kvitt/compose.yaml together
# with /opt/kvitt/Caddyfile and /opt/kvitt/.env (from .env.example). Run every command from /opt/kvitt.
# Only caddy publishes a port (8443); the server, Caddy's admin API and the sidecars are reachable only on the
# internal network `kvitt` (SPEC §8.9).
# with /opt/kvitt/.env (from .env.example). Run every command from /opt/kvitt.
# TLS terminates in Nginx Proxy Manager on the Home Assistant Pi (SPEC §10.5, D-120), which forwards plain HTTP over
# the LAN to the server's published port 8080. Only the server publishes a port; backup is reachable only on the
# internal network `kvitt` (SPEC §8.9). The router forwards the internet only to NPM, never to this Pi.
# Image names: ${KVITT_REGISTRY}/kvitt-<name>, the names the Gitea Actions workflow pushes (D-104).
name: kvitt
@@ -13,49 +14,6 @@ x-logging: &logging
max-file: "3"
services:
caddy:
# Official image, pinned by version and multi-arch index digest (resolved 2026-10-18, infra/README.md).
image: docker.io/library/caddy:2.11.7@sha256:685ae3d7c34d9b5f8caeddc5d14051db7bbd88ff1f5a2fa0c16bd8ca328fb7af
restart: unless-stopped
# Non-root (D-117): uid 10002, plus group 1000 (cert-sync's group) to read the 0640 files in the 0750 /certs.
# 8443 needs no capability, but the image's /usr/bin/caddy carries the file capability cap_net_bind_service=ep,
# and the kernel refuses to exec it when that capability is not in the bounding set (checked 2026-10-18:
# "exec container process /usr/bin/caddy: Operation not permitted" with cap_drop ALL alone). So NET_BIND_SERVICE
# is the one capability kept. caddy_data (/data) is owned by 10002 (install.sh); /config (autosave of the loaded
# config) and /tmp are tmpfs, the rest of the root filesystem is read-only.
user: "10002:10002"
group_add: ["1000"]
read_only: true
cap_drop: [ALL]
cap_add: [NET_BIND_SERVICE]
security_opt:
- no-new-privileges:true
tmpfs:
- /config
- /tmp
mem_limit: 64m
ports:
- "8443:8443"
environment:
TZ: Europe/Stockholm
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- certs:/certs:ro
- caddy_data:/data
depends_on:
cert-sync:
condition: service_healthy
server:
condition: service_started
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:2019/config/ >/dev/null"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
logging: *logging
networks: [kvitt]
server:
image: ${KVITT_REGISTRY:?set KVITT_REGISTRY in .env}/kvitt-server:${KVITT_TAG:?set KVITT_TAG in .env}
command: ["serve"]
@@ -66,11 +24,17 @@ services:
security_opt:
- no-new-privileges:true
mem_limit: 160m
# On all interfaces of the Pi: NPM on the Home Assistant Pi connects to <KVITT_PI_HOST>:8080 over the LAN.
ports:
- "8080:8080"
environment:
TZ: Europe/Stockholm
# The remaining server settings keep their SPEC §10.4 defaults (KVITT_PUBLIC_URL
# https://icehack.asuscomm.com:8443, KVITT_TRUSTED_PROXY caddy, KVITT_FCM_PROJECT_ID kvitt-1337, ...).
KVITT_DATA_DIR: /data
KVITT_PUBLIC_URL: https://${KVITT_PUBLIC_HOST:?set KVITT_PUBLIC_HOST in .env}:8443
# NPM's LAN address: only a request whose TCP peer is this address may set X-Forwarded-For, whose last entry
# is then the client IP of the rate limits (SPEC §8.5, §8.6, D-120). An IP literal (install.sh checks it).
KVITT_TRUSTED_PROXY: ${HA_HOST:?set HA_HOST in .env}
# The remaining server settings keep their SPEC §10.4 defaults (KVITT_FCM_PROJECT_ID kvitt-1337, ...).
volumes:
- data:/data
- /opt/kvitt/secrets/fcm-service-account.json:/secrets/fcm-service-account.json:ro
@@ -85,34 +49,6 @@ services:
logging: *logging
networks: [kvitt]
cert-sync:
image: ${KVITT_REGISTRY}/kvitt-cert-sync:${KVITT_TAG}
restart: unless-stopped
user: "1000:1000"
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
mem_limit: 32m
environment:
TZ: Europe/Stockholm
HA_HOST: ${HA_HOST:?set HA_HOST in .env}
HA_PORT: ${HA_PORT:-22}
HA_USER: ${HA_USER:?set HA_USER in .env}
HA_CERT_DIR: ${HA_CERT_DIR:?set HA_CERT_DIR in .env}
volumes:
- certs:/certs
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- /opt/kvitt/secrets/ha_ssh_key:/secrets/ha_ssh_key:ro
- /opt/kvitt/secrets/ha_known_hosts:/secrets/known_hosts:ro
healthcheck:
test: ["CMD-SHELL", "test -s /certs/fullchain.pem && test -s /certs/privkey.pem"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
logging: *logging
networks: [kvitt]
backup:
image: ${KVITT_REGISTRY}/kvitt-backup:${KVITT_TAG}
restart: unless-stopped
@@ -142,8 +78,6 @@ services:
volumes:
data:
certs:
caddy_data:
backups:
networks:
+4 -2
View File
@@ -5,7 +5,8 @@
# to every deploy); skipped when no database exists yet, or with --skip-backup (rollback while Drive is down)
# 2. sets KVITT_TAG in /opt/kvitt/.env (the previous tag is kept in /opt/kvitt/previous-tag)
# 3. docker compose pull, prints the pulled image digests and appends them to /opt/kvitt/deployed-digests (D-117),
# docker compose up -d, waits for server to be healthy, prints GET /health
# docker compose up -d, waits for server to be healthy, prints GET /health on port 8080 and through Nginx
# Proxy Manager (D-120; a failure through NPM is a warning, the deploy itself succeeded)
# Rollback after a failed migration: /opt/kvitt/deploy.sh "$(cat /opt/kvitt/previous-tag)"; if a migration had
# partially applied, restore the pre-deploy backup (infra/RESTORE.md).
set -euo pipefail
@@ -55,5 +56,6 @@ if ! wait_healthy server 180; then
die "server is not healthy on $tag. Roll back: $KVITT_DIR/deploy.sh ${current:-<previous tag>} --skip-backup"
fi
compose ps
print_health || die "server is healthy but /health through Caddy failed (docker compose logs caddy cert-sync)"
print_health || die "server is healthy but GET /health on port 8080 failed (docker compose logs server)"
print_proxy_health || say "WARNING: /health through Nginx Proxy Manager failed: Kvitt is not reachable from outside (README, Troubleshooting)"
say "Deployed $tag."
+2 -3
View File
@@ -37,7 +37,6 @@ compose run --rm --no-deps -T -v "$src:/incoming:ro" --entrypoint /bin/sh backup
rm -f "/data/apk/kvitt-$v.apk"; echo "removed kvitt-$v.apk"
done
echo "latest.json installed"'
say "GET https://$PUBLIC_HOST:$PUBLIC_PORT/apk/latest (via this Pi):"
curl -fsS --max-time 15 --resolve "$PUBLIC_HOST:$PUBLIC_PORT:127.0.0.1" "https://$PUBLIC_HOST:$PUBLIC_PORT/apk/latest" \
|| die "GET /apk/latest failed"
say "GET $SERVER_URL/apk/latest:"
curl -fsS --max-time 15 "$SERVER_URL/apk/latest" || die "GET /apk/latest failed"
echo
+42 -20
View File
@@ -1,15 +1,17 @@
#!/usr/bin/env bash
# Kvitt Pi install (infra/README.md "Pi: what the owner does"). Run ON the Kvitt Pi, as the owner's user, from the
# bundle folder copied from the Mac (infra/pi/): cd ~/kvitt-pi && ./install.sh
# Idempotent: safe to run again after changing .env, the secrets, compose.yaml or the Caddyfile.
# Idempotent: safe to run again after changing .env, the secrets or compose.yaml.
# 1. checks Docker Engine and the compose plugin
# 2. checks .env (no CHANGE_ME left in the keys the stack reads)
# 3. creates /opt/kvitt/{secrets,import,apk} and installs the secret files from ./secrets/ with their modes
# 2. checks .env (no CHANGE_ME left in the keys the stack reads; KVITT_PUBLIC_HOST a host name, HA_HOST an IPv4
# address)
# 3. creates /opt/kvitt/{secrets,import,apk}, installs the secret files from ./secrets/ with their modes and
# removes what the Caddy and cert-sync stack of before D-120 left in /opt/kvitt
# 4. trusts the plain-HTTP registry in /etc/docker/daemon.json (insecure-registries) and logs in when credentials are set
# 5. copies compose.yaml, Caddyfile, .env and the operation scripts to /opt/kvitt; a KVITT_TAG that deploy.sh set
# 5. copies compose.yaml, .env and the operation scripts to /opt/kvitt; a KVITT_TAG that deploy.sh set
# in /opt/kvitt/.env is kept, so re-running an older bundle never rolls the server back to the bundle's tag
# 6. pulls the images (digests recorded in /opt/kvitt/deployed-digests), gives the named volumes their owners,
# starts the stack, waits for /health
# starts the stack, waits for /health on port 8080, then tries /health through Nginx Proxy Manager
# Uses sudo for /opt/kvitt, the secret files and the Docker daemon configuration.
set -euo pipefail
bundle="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -26,26 +28,33 @@ command -v python3 >/dev/null || die "python3 is missing (step 4 edits daemon.js
# 2. .env
env_src="$bundle/.env"
[[ -f "$env_src" ]] || die "no .env next to install.sh: cp .env.example .env and fill it (README step 4)"
for key in KVITT_REGISTRY KVITT_TAG HA_HOST HA_PORT HA_USER HA_CERT_DIR RCLONE_REMOTE RCLONE_FOLDER_ID; do
for key in KVITT_REGISTRY KVITT_TAG KVITT_PUBLIC_HOST HA_HOST RCLONE_REMOTE RCLONE_FOLDER_ID; do
value=$(env_value "$key" "$env_src")
[[ -n "$value" ]] || die ".env: $key is empty"
[[ "$value" != CHANGE_ME* ]] || die ".env: $key is still $value"
done
# The server compares the TCP peer with KVITT_TRUSTED_PROXY (= HA_HOST). A host name would have to resolve inside
# the container to exactly NPM's LAN address (mDNS names such as homeassistant.local do not), so require the address.
ha_host=$(env_value HA_HOST "$env_src")
[[ "$ha_host" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] \
|| die ".env: HA_HOST must be the Home Assistant Pi's LAN IPv4 address (for example 192.168.1.20), not $ha_host"
public_host=$(env_value KVITT_PUBLIC_HOST "$env_src")
[[ "$public_host" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?\.[A-Za-z]{2,}$ ]] \
|| die ".env: KVITT_PUBLIC_HOST must be a host name such as kvitt.example.org (no scheme, no port), not $public_host"
[[ "$public_host" != icehack.asuscomm.com ]] \
|| die ".env: KVITT_PUBLIC_HOST cannot be icehack.asuscomm.com, NPM's proxy host for Home Assistant (D-120)"
registry=$(env_value KVITT_REGISTRY "$env_src")
registry_host=${registry%%/*}
registry_user=$(env_value KVITT_REGISTRY_USER "$env_src")
registry_token=$(env_value KVITT_REGISTRY_TOKEN "$env_src")
# 3. Directories and secrets. uid 1000 = cert-sync, uid 10001 = server and backup, uid 10002 = caddy (SPEC §10.2,
# §10.7, D-023, D-105, D-117).
# 3. Directories and secrets. uid 10001 = server and backup (SPEC §10.2, §10.7, D-105).
say "Creating $KVITT_DIR"
sudo install -d -m 0755 -o "$(id -u)" -g "$(id -g)" "$KVITT_DIR" "$KVITT_DIR/import" "$KVITT_DIR/apk"
sudo install -d -m 0700 -o root -g root "$KVITT_DIR/secrets"
# name : owner uid : group : mode
secret_specs=(
"fcm-service-account.json:0:10001:0440"
"ha_ssh_key:1000:1000:0600"
"ha_known_hosts:1000:1000:0600"
"rclone.conf:10001:10001:0600"
)
missing=()
@@ -71,6 +80,13 @@ done
if [[ -d "$bundle/secrets" ]]; then
say " The copies in $bundle/secrets/ are no longer needed: rm -rf $bundle/secrets"
fi
# Left by the Caddy and cert-sync stack of before D-120: the Home Assistant SSH key must not stay on this Pi.
for old in "$KVITT_DIR/secrets/ha_ssh_key" "$KVITT_DIR/secrets/ha_known_hosts" "$KVITT_DIR/Caddyfile"; do
if sudo test -e "$old"; then
sudo rm -rf "$old"
say " removed $old (not used since D-120)"
fi
done
# 4. Registry trust (plain HTTP on the home Wi-Fi, SPEC §10.3) and optional login.
daemon_json=/etc/docker/daemon.json
@@ -113,7 +129,6 @@ installed_tag=""
[[ -f "$KVITT_DIR/.env" ]] && installed_tag=$(env_value KVITT_TAG)
bundle_tag=$(env_value KVITT_TAG "$env_src")
install -m 0644 "$bundle/compose.yaml" "$KVITT_DIR/compose.yaml"
install -m 0644 "$bundle/Caddyfile" "$KVITT_DIR/Caddyfile"
install -m 0600 "$env_src" "$KVITT_DIR/.env"
if [[ -n "$installed_tag" && "$installed_tag" != "$bundle_tag" ]]; then
env_set KVITT_TAG "$installed_tag"
@@ -122,7 +137,7 @@ fi
for script in lib.sh deploy.sh bootstrap.sh backup-now.sh install-apk.sh; do
install -m 0755 "$bundle/$script" "$KVITT_DIR/$script"
done
say "Installed compose.yaml, Caddyfile, .env and the scripts in $KVITT_DIR"
say "Installed compose.yaml, .env and the scripts in $KVITT_DIR"
# 6. Pull, volume owners, start.
compose config --quiet
@@ -133,17 +148,24 @@ compose create
# backup image, which mounts the same data volume, sets /data). Runs as root once per install, then exits.
compose run --rm --no-deps -T --user 0:0 --cap-add CHOWN --entrypoint /bin/sh backup \
-c 'chown 10001:10001 /data /backups && mkdir -p /data/apk && chown 10001:10001 /data/apk'
# /certs: cert-sync (1000) writes, Caddy (10002, supplementary group 1000) reads; /data of Caddy: 10002 (D-117).
compose run --rm --no-deps -T --user 0:0 --cap-add CHOWN --cap-add FOWNER --entrypoint /bin/sh cert-sync \
-c 'chown 1000:1000 /certs && chmod 0750 /certs'
compose run --rm --no-deps -T --user 0:0 --cap-add CHOWN --entrypoint /bin/sh caddy \
-c 'chown -R 10002:10002 /data'
# --remove-orphans also removes the caddy and cert-sync containers of a stack installed before D-120.
compose up -d --remove-orphans
# Their volumes: certs held a copy of the certificate's private key.
for volume in kvitt_certs kvitt_caddy_data; do
if docker volume inspect "$volume" >/dev/null 2>&1; then
if docker volume rm "$volume" >/dev/null; then
say "Removed the volume $volume (not used since D-120)"
else
say "Could not remove the volume $volume (still in use?): docker volume rm $volume"
fi
fi
done
ok=1
wait_healthy server 180 || ok=0
wait_healthy cert-sync 120 || { ok=0; say "cert-sync has no certificate yet: docker compose logs cert-sync (README, Troubleshooting)"; }
wait_healthy caddy 120 || ok=0
compose ps
print_health || ok=0
(( ok == 1 )) || die "the stack is not fully up yet; see the lines above"
say "Kvitt is running. Next: $KVITT_DIR/bootstrap.sh (first install only)."
if ! print_proxy_health; then
say "Not reachable through Nginx Proxy Manager yet: expected until its proxy host is set up (README step 7)."
fi
say "Kvitt is running. Next: README step 7 (NPM proxy host), then $KVITT_DIR/bootstrap.sh (first install only)."
+23 -6
View File
@@ -4,8 +4,10 @@
# shellcheck disable=SC2034 # variables are used by the scripts that source this file
KVITT_DIR=${KVITT_DIR:-/opt/kvitt}
PUBLIC_HOST=icehack.asuscomm.com
PUBLIC_PORT=8443
# The server's port, published on the Pi's LAN interfaces; Nginx Proxy Manager forwards to it (D-120).
SERVER_URL=http://localhost:8080
# Nginx Proxy Manager's HTTPS port on the Home Assistant Pi (the router forwards external 8443 to it, D-120).
NPM_HTTPS_PORT=443
say() { printf '%s\n' "$*"; }
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
@@ -56,14 +58,29 @@ wait_healthy() {
done
}
# GET /health through Caddy on this Pi, with the real certificate check (no -k): proves TLS and the proxy path.
# GET /health from the server's published port on this Pi.
print_health() {
local out
if out=$(curl -fsS --max-time 15 --resolve "$PUBLIC_HOST:$PUBLIC_PORT:127.0.0.1" "https://$PUBLIC_HOST:$PUBLIC_PORT/health" 2>&1); then
say "GET https://$PUBLIC_HOST:$PUBLIC_PORT/health (via this Pi): $out"
if out=$(curl -fsS --max-time 15 "$SERVER_URL/health" 2>&1); then
say "GET $SERVER_URL/health: $out"
return 0
fi
say "GET https://$PUBLIC_HOST:$PUBLIC_PORT/health (via this Pi) failed: $out"
say "GET $SERVER_URL/health failed: $out"
return 1
}
# GET /health through Nginx Proxy Manager on the Home Assistant Pi (HA_HOST in .env) for KVITT_PUBLIC_HOST, with
# the real certificate check (no -k): proves NPM's proxy host, its certificate and the LAN path to this Pi's port
# 8080. The router forward is not on this path.
print_proxy_health() {
local out ha_host host
ha_host=$(env_value HA_HOST)
host=$(env_value KVITT_PUBLIC_HOST)
if out=$(curl -fsS --max-time 15 --resolve "$host:$NPM_HTTPS_PORT:$ha_host" "https://$host:$NPM_HTTPS_PORT/health" 2>&1); then
say "GET https://$host:$NPM_HTTPS_PORT/health (via NPM at $ha_host): $out"
return 0
fi
say "GET https://$host:$NPM_HTTPS_PORT/health (via NPM at $ha_host) failed: $out"
return 1
}