Files
kvitt/infra
jacobh2 7fb86f5524 Infra security fixes: Caddy log redaction, headers and non-root container; dev mounts only the FCM key; deploy records image digests (D-117)
- Caddyfile: access and error logs replace the /i/<code> segment and every query string with REDACTED
  (regexp filter on request>uri); Authorization stays redacted by default. Deferred response headers:
  Content-Security-Policy, X-Content-Type-Options, X-Frame-Options, Referrer-Policy.
- Pi compose: caddy as uid 10002 with group 1000, read-only root, cap_drop ALL plus NET_BIND_SERVICE
  (needed to exec the image's file-capability binary), tmpfs /config and /tmp; install.sh sets /certs
  0750 1000:1000 and Caddy's /data to 10002.
- deploy.sh and install.sh print the pulled image digests and append them to /opt/kvitt/deployed-digests,
  warning when a recorded tag changed digest; the workflow prints the pushed digests; kvitt_cicd needs
  only write:package.
- Dev stack: up.sh stages only fcm-service-account.json into infra/dev/secrets/ (git-ignored), mounted
  read-only at /secrets.
- README: restricted cert-sync key required (plain root key not acceptable), remote_ip and log checks,
  APK download note, owner decisions (backup encryption, APK limit, key form, token scope), overhaul lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-18 22:24:32 +02:00
..

infra: Kvitt infrastructure

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".

Layout

Path What
dev/compose.yaml Dev stack: server (image kvitt-server:dev) and the on-demand backup job
dev/.env.example Dev settings, no secret values; copy to dev/.env (git-ignored) to override
dev/build.sh Builds kvitt-server:dev from server/Dockerfile and kvitt-backup:dev from backup/Dockerfile
dev/up.sh Starts the server, waits for /health, prints the Mac and phone URLs; --down stops the stack
dev/logs.sh Follows the logs (dev/logs.sh --tail 100 server)
dev/backup.sh Runs the backup job once and checks the snapshot with PRAGMA integrity_check
dev/copy-google-services.sh Copies secrets/google-services.json to android/app/google-services.json
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/
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

All Mac commands below run from the repository root of the main checkout (/Users/jhag/projects/kvitt).

Development stack

Choices. No Caddy and no TLS: debug builds accept http:// to private LAN addresses (AC-2.8), so the server publishes its own port (default 8080) on all of the Mac's interfaces. The server keeps the hardening of SPEC §8.9 (uid 10001, read-only root, no capabilities, no-new-privileges), the 160 MiB limit, json-file logging (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 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.

On this Mac docker is Podman (docker compose runs podman-compose); the files work with Docker Desktop as well.

Build

infra/dev/build.sh            # both images
infra/dev/build.sh server     # only kvitt-server:dev; needs server/Dockerfile
infra/dev/build.sh backup     # only kvitt-backup:dev

The server build is docker build --platform linux/arm64 -f server/Dockerfile -t kvitt-server:dev server (override the context with KVITT_SERVER_BUILD_CONTEXT). The script refuses to run while a Gradle build holds /tmp/kvitt-gradle.lock (CLAUDE.md).

Start, stop, logs

infra/dev/up.sh               # start; prints health, the Mac URL and the phone URL
infra/dev/logs.sh             # follow logs (Ctrl-C to stop following)
infra/dev/up.sh --down        # stop and remove the containers (data stays in infra/dev/data/)

KVITT_PUBLIC_URL (used in invite links) is left empty in .env.example; up.sh sets it to http://<Mac LAN address>:<port>. Pin it in infra/dev/.env if the Mac's address changes often.

Finding the Mac's LAN address

up.sh prints it. By hand: ipconfig getifaddr en0 (Wi-Fi on most Macs), or the interface of the default route: route -n get default | grep interface, then ipconfig getifaddr <interface>. It must be a private address (192.168.*, 10.*, 172.16.* to 172.31.*) for a debug build to accept http:// (AC-2.8).

The first time a phone connects, macOS may ask whether the container runtime (Podman's gvproxy, or Docker) may accept incoming network connections: answer Allow.

Bootstrap and pairing a debug build

The bootstrap and pair-device subcommands (SPEC §10.4, AC-1.1, AC-1.6) arrive with the server milestones.

C="docker compose -f infra/dev/compose.yaml --env-file infra/dev/.env.example"   # or --env-file infra/dev/.env
$C exec server kvitt-server bootstrap                      # first admin invite: code, link, expiry
$C exec server kvitt-server pair-device --user <user_id>   # extra device invite for an existing user
$C exec server kvitt-server invite-partner                 # partner invite by the admin, as E-12 (D-113)
$C run --rm server migrate                                 # when the server is stopped (entrypoint is kvitt-server)

On the phone (home Wi-Fi, debug build se.hagstedt.kvitt.debug): open the pairing screen, enter the server URL http://<Mac LAN address>:8080 and the printed code (AC-2.7). Check reachability first by opening http://<Mac LAN address>:8080/health in Chrome on the phone: it shows "status":"ok".

Backup (local, no Drive upload)

infra/dev/backup.sh

Runs infra/backup/backup.sh once in the backup image (compose profile backup, so up never starts it) with RCLONE_FOLDER_ID empty: .backup snapshot, PRAGMA integrity_check must print ok, then infra/dev/backups/kvitt-<UTC stamp>.sqlite.gz, pictures-<stamp>.tar.gz and last-success; the Drive upload is skipped with a log line and the files are kept (SPEC §10.9, D-001). The wrapper then checks the new snapshot again. The dev env file has no rclone settings (RCLONE_REMOTE is no longer in dev/.env.example): only the Pi's .env names the remote. To check by hand: gunzip -c infra/dev/backups/kvitt-<stamp>.sqlite.gz > /tmp/k.sqlite && sqlite3 /tmp/k.sqlite "PRAGMA integrity_check".

Resetting the dev data

infra/dev/up.sh --down
rm -rf infra/dev/data         # the dev database, pictures and APKs; never the phones' data
infra/dev/up.sh               # fresh database; run bootstrap again

Paired debug builds then hold a token the new database does not know: re-pair them (clear the debug app's data on the phone, or reinstall the debug build).

Worktrees

Agents work in .claude/worktrees/agent-<id>, which has no infra/secrets/ unless copied (CLAUDE.md). Either copy it from the main checkout, or set KVITT_SECRETS_DIR=/Users/jhag/projects/kvitt/infra/secrets in infra/dev/.env. infra/dev/copy-google-services.sh /Users/jhag/projects/kvitt copies the Firebase config from the main checkout into the worktree's android/app/.

Firebase (SPEC §10.7)

Project kvitt-1337 (Firebase added to the existing Google Cloud project). Free tier only; nothing billable is created by this setup.

Resource Name
APIs enabled firebase.googleapis.com, fcm.googleapis.com, cloudresourcemanager.googleapis.com, iam.googleapis.com
Android app "Kvitt", package se.hagstedt.kvitt
Android app "Kvitt debug", package se.hagstedt.kvitt.debug
Service account kvitt-fcm@kvitt-1337.iam.gserviceaccount.com ("Kvitt FCM sender"), role roles/firebasecloudmessaging.admin (the narrowest predefined role holding cloudmessaging.messages.create)
Key infra/secrets/fcm-service-account.json (git-ignored, mode 600)
Config infra/secrets/google-services.json, one file with both Android clients (git-ignored)

Put the config where Gradle reads it: infra/dev/copy-google-services.sh.

The setup used only gcloud and the Firebase Management REST API. To repeat or inspect it:

T=$(gcloud auth print-access-token)
H=(-H "Authorization: Bearer $T" -H "x-goog-user-project: kvitt-1337")
API=https://firebase.googleapis.com/v1beta1/projects/kvitt-1337
curl -sS "${H[@]}" "$API/androidApps"                                 # list the apps (ids, packages)
curl -sS "${H[@]}" "$API/androidApps/<appId>/config"                   # configFileContents is base64 google-services.json
gcloud iam service-accounts keys list --iam-account kvitt-fcm@kvitt-1337.iam.gserviceaccount.com --project kvitt-1337

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).

Pi stack (SPEC §10.1 to §10.10, D-100)

How it fits together

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")
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

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).

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
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).

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 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).

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 (2026-10-18)

  • 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.

Gitea Actions workflow (.gitea/workflows/build-images.yaml), not yet run

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). 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).

The builder's buildkitd.toml is read only when the builder is created. After changing the registry setting in the workflow, remove the builder once on the runner Pi: docker buildx rm local-http-builder (this also drops its build cache, so the next server build is a full one).

Risk: the runner is a Raspberry Pi 3 with 1 GB RAM. The server image compiles about 232 Rust crates, aws-lc-sys's C code among them. The workflow passes --build-arg CARGO_BUILD_JOBS=1; it takes effect only once server/Dockerfile declares ARG CARGO_BUILD_JOBS in its build stage (requested from the server's owner; until then buildx warns about an unused build argument and cargo uses all four cores, which may run out of memory). The Dockerfile already keeps the cargo registry and target directory in BuildKit cache mounts, so only the first build is a full one. The first build's duration on the Pi is unknown (a cold arm64 build takes 48 s on the M4 Mac). If it fails for memory, add swap on the runner Pi, or build on the Mac with infra/build-images.sh.

Mac fallback: infra/build-images.sh [tag]

Builds the same three 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 Podman (this Mac) it pushes with --tls-verify=false; with Docker it creates a buildx builder that allows plain HTTP to the registry.

Publishing an APK: infra/publish-apk.sh

Version rule (SPEC §10.6): version_code = git rev-list --count HEAD, version_name = YYYY.MM.DD-<short sha> of HEAD's commit date (Europe/Stockholm), passed as -Pkvitt.versionCode and -Pkvitt.versionName. The image tags of the Gitea workflow (v1.0.N) are separate and say nothing about the APK. The script refuses a dirty tree, builds :app:assembleRelease through tools/gradle-serial.sh (JDK 25 on PATH), refuses an unsigned APK, and writes into infra/pi/out/apk/: kvitt-<version_code>.apk, kvitt-<version_code>.mapping.txt (the R8 mapping, for reading stack traces; never served) and latest.json (temp file, then rename); it keeps the newest 5 APKs with their mappings. Then the owner, on the Mac:

cd /Users/jhag/projects/kvitt
scp infra/pi/out/apk/* <KVITT_PI_SSH_USER>@<KVITT_PI_HOST>:/opt/kvitt/apk/
ssh <KVITT_PI_SSH_USER>@<KVITT_PI_HOST> /opt/kvitt/install-apk.sh

install-apk.sh checks the SHA-256 against latest.json, copies the APKs into the data volume (/data/apk/) and latest.json last with a rename, keeps the newest 5 there and prints GET /apk/latest.

Pi: what the owner does

Fill these in first (in infra/pi/.env on the Mac; the bundle carries the file to the Pi):

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_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
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
RCLONE_REMOTE, RCLONE_FOLDER_ID Already gdrive: and 1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV

1. Provision the Kvitt Pi

Raspberry Pi Imager on the Mac: Raspberry Pi 3, Raspberry Pi OS Lite (64-bit); in its settings set the host name, your user and password, the network, time zone Europe/Stockholm, and enable SSH with your public key. Boot it, give it a static DHCP lease in the router, then from the Mac: ssh <KVITT_PI_SSH_USER>@<KVITT_PI_HOST>.

2. Install Docker Engine on the Pi (docs.docker.com, Debian arm64 repository)

On the Pi:

sudo apt update && sudo apt install -y ca-certificates curl python3
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/debian
Suites: $(. /etc/os-release && echo "$VERSION_CODENAME")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker "$USER"
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

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/

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

On the Mac:

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:

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

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.". 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>.

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).

8. First backup and bootstrap

On the Pi:

/opt/kvitt/backup-now.sh      # ends with "last-success: <stamp>" and the newest kvitt-*.sqlite.gz and pictures-*.tar.gz on Drive
/opt/kvitt/bootstrap.sh       # first install only: prints the admin invite code, link and expiry

9. Deploying a new version

After the Gitea workflow pushed v1.0.N (or infra/build-images.sh printed a tag), on the Pi:

/opt/kvitt/deploy.sh v1.0.N

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: /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.

Fallback: Caddy internal CA (SPEC §10.5.3, not deployed)

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.

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>
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
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 20 MB each, 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
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.

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, is adding write:repository acceptable?

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.
  • 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 in ~/kvitt-pi/.env and run ./install.sh again.
  • install.sh says /opt/kvitt/secrets/<file> is a directory: Docker created it because the file was missing at a start; sudo rmdir it, put the file in ~/kvitt-pi/secrets/, run ./install.sh.