# 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 ```bash 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 ```bash 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://:`. 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 `. 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. ```bash 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 # 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://:8080` and the printed code (AC-2.7). Check reachability first by opening `http://:8080/health` in Chrome on the phone: it shows `"status":"ok"`. ### Backup (local, no Drive upload) ```bash 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-.sqlite.gz`, `pictures-.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-.sqlite.gz > /tmp/k.sqlite && sqlite3 /tmp/k.sqlite "PRAGMA integrity_check"`. ### Resetting the dev data ```bash 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-`, 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: ```bash 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//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 ...`. 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 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/?from=` 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-` 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-.apk`, `kvitt-.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: ```bash cd /Users/jhag/projects/kvitt scp infra/pi/out/apk/* @:/opt/kvitt/apk/ ssh @ /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 @`. ### 2. Install Docker Engine on the Pi (docs.docker.com, Debian arm64 repository) On the Pi: ```bash 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 <@:kvitt-pi rm -r infra/pi/secrets # the originals stay in infra/secrets/ ``` On the Pi: ```bash cd ~/kvitt-pi ssh-keyscan -p > secrets/ha_known_hosts wc -l secrets/ha_known_hosts # at least 1 line ``` ### 6. Install and start On the Pi: ```bash 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 `. ### 7. Router forward ASUS router web UI, WAN, Virtual Server / Port Forwarding: add a rule, protocol TCP, external port 8443, internal IP ``, 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: ```bash /opt/kvitt/backup-now.sh # ends with "last-success: " 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: ```bash /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 @:/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 ` | | Backup now | `/opt/kvitt/backup-now.sh` | | Upgrade | `/opt/kvitt/deploy.sh ` | | 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 ` | | 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 | | Restore | `infra/RESTORE.md` | **APK downloads (security review L-3).** `GET /apk/files/` 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/` 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`.