diff --git a/.gitea/workflows/build-images.yaml b/.gitea/workflows/build-images.yaml new file mode 100644 index 0000000..bb707be --- /dev/null +++ b/.gitea/workflows/build-images.yaml @@ -0,0 +1,104 @@ +# Kvitt image build on Gitea Actions (SPEC §10.3, D-104). NOT YET RUN (written 2026-10-18 from the owner's working +# 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, +# 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 . +# +# Secret: `kvitt_cicd`, a repository secret holding a Gitea access token (scopes write:package, and +# write:repository for the tag push) of the user that pushes (owner step, infra/README.md). +# +# RISK: the runner is a Raspberry Pi 3 with 1 GB RAM and the server image compiles about 232 Rust crates +# (aws-lc-sys's C code included). The build passes CARGO_BUILD_JOBS=1 to keep memory down; server/Dockerfile must +# declare `ARG CARGO_BUILD_JOBS` in its build stage for it to take effect (contract request in the M2c-pi report; +# until then buildx warns that the argument is unused and cargo uses every core). The Dockerfile already keeps +# the cargo registry and the target directory in BuildKit cache mounts, which persist in the reused builder +# `local-http-builder`, so later builds are incremental. First-build duration on the Pi: unknown. +# Fallback: infra/build-images.sh on the Mac. +name: Build Kvitt images +env: + MAJOR: 1 + MINOR: 0 + +defaults: + run: + shell: sh + +on: + push: + paths: + - "server/**" + - "infra/cert-sync/**" + - "infra/backup/**" + - ".gitea/workflows/**" + workflow_dispatch: {} + +jobs: + build: + runs-on: host + steps: + - name: Install Git and Docker CLI + run: apk add --no-cache git docker-cli docker-cli-buildx + - name: Log in to Gitea Container Registry + run: echo "${{ secrets.kvitt_cicd }}" | docker login gitea.lan -u ${{ github.actor }} --password-stdin + - name: Create Custom Buildx Builder for HTTP + run: | + # 1. Write a configuration rule forcing plain HTTP for the local registry + echo '[registry."gitea.lan"]' > buildkitd.toml + echo ' http = true' >> buildkitd.toml + # 2. Create the builder once and reuse it, so its BuildKit cache (cargo registry, target) survives runs + if docker buildx inspect local-http-builder >/dev/null 2>&1; then + docker buildx use local-http-builder + else + docker buildx create --use --name local-http-builder --config buildkitd.toml + fi + - name: Checkout + uses: actions/checkout@v4 + with: + # Tells the action to use the local 'git' binary installed above + set-safe-directory: 'true' + fetch-depth: 0 + - name: Determine Version + run: | + LATEST_TAG=$(git tag -l "v${{ env.MAJOR }}.${{ env.MINOR }}.*" | sort -V | tail -n 1) + if [ -z "$LATEST_TAG" ]; then + VERSION="v${{ env.MAJOR }}.${{ env.MINOR }}.0" + else + # Strip the 'v' and get the patch version + PATCH=$(echo "$LATEST_TAG" | sed 's/v//' | cut -d'.' -f3) + NEW_PATCH=$((PATCH + 1)) + VERSION="v${{ env.MAJOR }}.${{ env.MINOR }}.$NEW_PATCH" + fi + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + - name: Build cert-sync image + run: | + docker buildx build \ + --platform linux/arm64 \ + -t gitea.lan/jacobh2/kvitt-cert-sync:${{ env.VERSION }} \ + -t gitea.lan/jacobh2/kvitt-cert-sync:latest \ + ./infra/cert-sync \ + --push + - name: Build backup image + run: | + docker buildx build \ + --platform linux/arm64 \ + -t gitea.lan/jacobh2/kvitt-backup:${{ env.VERSION }} \ + -t gitea.lan/jacobh2/kvitt-backup:latest \ + ./infra/backup \ + --push + - name: Build server image + run: | + # Context server/: server/Dockerfile copies the Cargo workspace from its own directory. + docker buildx build \ + --platform linux/arm64 \ + --build-arg CARGO_BUILD_JOBS=1 \ + -f ./server/Dockerfile \ + -t gitea.lan/jacobh2/kvitt-server:${{ env.VERSION }} \ + -t gitea.lan/jacobh2/kvitt-server:latest \ + ./server \ + --push + - name: Tag and Push Version + run: | + git tag "$VERSION" + git push origin "$VERSION" diff --git a/infra/README.md b/infra/README.md index 8687867..73da1d5 100644 --- a/infra/README.md +++ b/infra/README.md @@ -1,9 +1,9 @@ # infra: Kvitt infrastructure -This file covers the **development stack on the owner's Mac** (`infra/dev/`, milestone M2c-dev, D-001) and the -**Firebase setup** (SPEC §10.7). The Pi stack (`infra/compose.yaml`, Caddy with the Let's Encrypt certificate, -cert-sync, the Gitea registry, Drive backups, deploy and APK publishing) is built by M2c-pi, which adds its own -sections here. +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 @@ -17,10 +17,16 @@ sections here. | `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 | -| `backup/Dockerfile`, `backup/backup.sh` | Backup image and job of SPEC §10.9 (shared with the Pi stack) | -| `secrets/` | Git-ignored: `fcm-service-account.json`, `google-services.json`. Never committed | +| `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 commands below run from the repository root. +All Mac commands below run from the repository root of the main checkout (`/Users/jhag/projects/kvitt`). ## Development stack @@ -140,4 +146,301 @@ 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 ...`. -Copying the key to the Pi is M2c-pi (§10.7 step 7). +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 a one-off root container (`docker compose run --user 0:0 --cap-add CHOWN`). Caddy +runs as root (official image) and reads the 0640 certificate files. + +### 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. +* 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. + +**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") | 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. + +### 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, starts, waits for `server: healthy` and prints `/health`. 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 20 MB each, 5 kept | +| No token in Caddy's log | `docker compose logs caddy \| grep -c 'kvt_'` prints `0` | +| Restore | `infra/RESTORE.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`. +* 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`. diff --git a/infra/RESTORE.md b/infra/RESTORE.md new file mode 100644 index 0000000..6943877 --- /dev/null +++ b/infra/RESTORE.md @@ -0,0 +1,88 @@ +# Restore Kvitt from a Drive backup (SPEC §10.9) + +Run on the Kvitt Pi, in `/opt/kvitt`, as the owner's user. Every command uses the running `backup` container, +which has `sqlite3`, `rclone`, the Drive credentials and the `data` and `backups` volumes, and runs as uid 10001, +the owner of the server's files. Nothing is deleted: the current database and pictures are moved aside. +Do not run it around 03:30, when the nightly backup runs. + +```bash +cd /opt/kvitt +B="docker compose exec -T backup sh -c" +FOLDER=$(sed -n 's/^RCLONE_FOLDER_ID=//p' .env); REMOTE=$(sed -n 's/^RCLONE_REMOTE=//p' .env) +``` + +## 1. Stop the server + +```bash +docker compose stop server +``` + +## 2. Download the chosen backup from Drive to the Pi + +List what is on Drive (newest last) and pick a stamp: + +```bash +$B 'c=$(mktemp); cp /config/rclone/rclone.conf "$c"; RCLONE_CONFIG=$c rclone lsf "$1" --drive-root-folder-id "$2" --files-only; rm -f "$c"' sh "$REMOTE" "$FOLDER" | sort +STAMP=20261102T013000Z # the stamp of the pair you chose +``` + +Download both files into the `backups` volume: + +```bash +$B 'set -e; mkdir -p /backups/restore; c=$(mktemp); cp /config/rclone/rclone.conf "$c" + for f in "kvitt-$3.sqlite.gz" "pictures-$3.tar.gz"; do + RCLONE_CONFIG=$c rclone copy "$1$f" /backups/restore/ --drive-root-folder-id "$2" + done; rm -f "$c"; ls -l /backups/restore' sh "$REMOTE" "$FOLDER" "$STAMP" +``` + +(A file downloaded in the browser instead: copy it to the Pi with `scp`, then +`docker compose cp backup:/backups/restore/`.) + +## 3. Unpack and check + +```bash +$B 'set -e; cd /backups/restore; gunzip -f "kvitt-$1.sqlite.gz"; sqlite3 "kvitt-$1.sqlite" "PRAGMA integrity_check"' sh "$STAMP" +``` + +It must print `ok`. Anything else: stop here and pick another backup. + +## 4. Move the current data aside and put the backup in place + +```bash +$B 'set -e; aside="/data/pre-restore-$(date -u +%Y%m%dT%H%M%SZ)"; mkdir "$aside" + for f in /data/kvitt.sqlite /data/kvitt.sqlite-wal /data/kvitt.sqlite-shm /data/pictures; do + [ -e "$f" ] && mv "$f" "$aside/" + done + cp "/backups/restore/kvitt-$1.sqlite" /data/kvitt.sqlite + tar -xzf "/backups/restore/pictures-$1.tar.gz" -C /data + echo "previous data kept in $aside"; ls -l /data' sh "$STAMP" +``` + +## 5. New sync epoch + +```bash +docker compose run --rm server rotate-epoch +``` + +It prints the new epoch. The phones see it and re-push their data (§14.4.8). + +## 6. Start the server and check health + +```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 +``` + +The last line prints `{"status":"ok",...}`. + +## 7. The phones + +Open Kvitt on both phones (owner's and partner's) and leave it open for a minute; the next sync detects the new +epoch and re-pushes the phone's data (if nothing seems to happen, close the app and open it again). Check that both phones show the same balances on Översikt (yes/no). + +## Afterwards + +When the phones agree, the downloaded files can go: +`$B 'rm -r /backups/restore'`. The `/data/pre-restore-/` folder stays until you decide otherwise +(`$B 'ls /data'` shows it). Run `/opt/kvitt/backup-now.sh` once to put the restored state on Drive. diff --git a/infra/backup/Dockerfile b/infra/backup/Dockerfile index e4720ef..05f85eb 100644 --- a/infra/backup/Dockerfile +++ b/infra/backup/Dockerfile @@ -1,20 +1,27 @@ -# Kvitt backup job image (SPEC §10.3, §10.9): alpine with sqlite, gzip, tar, rclone and backup.sh run by crond. +# Kvitt backup job image (SPEC §10.3, §10.9): alpine with sqlite, gzip, tar, rclone and backup.sh on a daily schedule. # Versions resolved 2026-10-17: alpine 3.24.2 (Docker Hub official image, multi-arch index digest below); -# packages from Alpine v3.24 main/community: sqlite 3.53.4, rclone 1.74.1, tar 1.35, tzdata 2026e. +# packages from Alpine v3.24 main/community: sqlite 3.53.4, rclone 1.74.1, tar 1.35, tzdata 2026e; +# 2026-10-18: supercronic 0.2.49. +# The scheduler is supercronic, not busybox crond: on the Pi the container runs as uid 10001, not root (README, +# "Backup uid"), and busybox crond cannot run jobs as a non-root user (checked 2026-10-18). See D-104. FROM docker.io/library/alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 RUN apk add --no-cache \ "sqlite~3.53" \ "rclone~1.74" \ "tar~1.35" \ + "supercronic~0.2" \ gzip \ - tzdata + tzdata \ + && adduser -D -u 10001 -g "Kvitt backup" kvitt \ + && mkdir -p /backups /data \ + && chown 10001:10001 /backups /data -COPY backup.sh /backup.sh -RUN chmod 0755 /backup.sh \ - && echo '30 3 * * * /backup.sh' > /etc/crontabs/root +COPY backup.sh healthcheck.sh / +RUN chmod 0755 /backup.sh /healthcheck.sh \ + && echo '30 3 * * * /backup.sh' > /etc/kvitt-backup.crontab ENV TZ=Europe/Stockholm -# Daily at 03:30 Europe/Stockholm. The development stack runs it on demand instead -# (`docker compose run --rm backup /backup.sh`, see infra/README.md). -CMD ["crond", "-f", "-l", "8"] +# Daily at 03:30 Europe/Stockholm; /tmp/kvitt-started feeds the healthcheck's "no run yet since start". +# The development stack runs it on demand instead (`docker compose run --rm backup /backup.sh`, see infra/README.md). +CMD ["sh", "-c", "date +%s > /tmp/kvitt-started && exec supercronic -quiet /etc/kvitt-backup.crontab"] diff --git a/infra/backup/backup.sh b/infra/backup/backup.sh index 9469c3b..c7f70b6 100755 --- a/infra/backup/backup.sh +++ b/infra/backup/backup.sh @@ -3,9 +3,11 @@ # 1. online, consistent snapshot of /data/kvitt.sqlite with sqlite3 .backup # 2. PRAGMA integrity_check on the snapshot must print ok, else abort # 3. gzip -9 the snapshot and tar the pictures, both with the same UTC stamp -# 4. Drive upload: skipped when RCLONE_FOLDER_ID is empty (the development stack, D-001); -# the upload itself and the D-002 local deletion are built by M2c-pi -# 5. write /backups/last-success with the stamp +# 4. Drive upload when RCLONE_FOLDER_ID is set (the Pi): rclone copy --immutable of every kvitt-*.sqlite.gz and +# pictures-*.tar.gz in /backups (this run's pair and any left by an earlier failed upload), never sync, never +# delete on Drive; once every file is confirmed on Drive with rclone lsf (name and size) the local copies are +# deleted (D-002). Skipped with a log line when RCLONE_FOLDER_ID is empty (the development stack, D-001): files kept. +# 5. write /backups/last-success with the stamp (only after this run's pair is confirmed on Drive, when uploading) # Logs carry file names and sizes only, never database content (SPEC §8.7). set -eu @@ -44,13 +46,36 @@ fi mv "$pics_out.tmp" "$pics_out" log "wrote $(basename "$db_out") ($(wc -c < "$db_out") bytes) and $(basename "$pics_out") ($(wc -c < "$pics_out") bytes)" -# 4. Drive upload +# 4. Drive upload (D-002) if [ -z "${RCLONE_FOLDER_ID:-}" ]; then log "RCLONE_FOLDER_ID is empty: Drive upload skipped, local files kept" else - # TODO(M2c-pi): rclone copy --immutable to "${RCLONE_REMOTE:-gdrive:}" --drive-root-folder-id, confirm with - # rclone lsf, then delete the local files (D-002). Not part of the development stack (D-001). - fail "RCLONE_FOLDER_ID is set but the Drive upload is not built yet (M2c-pi); local files kept" + remote=${RCLONE_REMOTE:-gdrive:} + conf_src=${RCLONE_CONFIG_SOURCE:-/config/rclone/rclone.conf} + [ -r "$conf_src" ] || fail "no readable rclone config at $conf_src; local files kept" + # rclone writes refreshed access tokens back to its config; the mounted one is read-only, so use a private copy. + conf=$(mktemp) + cp "$conf_src" "$conf" + export RCLONE_CONFIG="$conf" + trap 'rm -f "$conf"' EXIT + confirmed="" + for f in "$BACKUP_DIR"/kvitt-*.sqlite.gz "$BACKUP_DIR"/pictures-*.tar.gz; do + [ -f "$f" ] || continue + name=$(basename "$f") + size=$(wc -c < "$f" | tr -d ' ') + rclone copy "$f" "$remote" --drive-root-folder-id "$RCLONE_FOLDER_ID" --immutable --retries 3 --stats 0 --log-level ERROR \ + || fail "rclone copy of $name failed; local files kept" + listed=$(rclone lsf "$remote" --drive-root-folder-id "$RCLONE_FOLDER_ID" --files-only --format sp --separator ' ' \ + --include "/$name" --log-level ERROR) || fail "rclone lsf could not confirm $name; local files kept" + [ "$listed" = "$size $name" ] || fail "$name is not on Drive with $size bytes; local file kept" + log "uploaded and confirmed on Drive: $name ($size bytes)" + confirmed="$confirmed $f" + done + # Every file is confirmed on Drive: delete the local copies (D-002). Nothing on Drive is ever deleted. + for f in $confirmed; do rm -f "$f"; done + log "local copies deleted after Drive confirmation" + rm -f "$conf" + trap - EXIT fi # 5. Success marker diff --git a/infra/backup/healthcheck.sh b/infra/backup/healthcheck.sh new file mode 100755 index 0000000..b145f52 --- /dev/null +++ b/infra/backup/healthcheck.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# backup healthcheck (SPEC §10.2): healthy when /backups/last-success is newer than 26 hours, or when no run is due +# yet since the container started (started less than 26 hours ago). +limit=$((26 * 3600)) +now=$(date +%s) +marker=${BACKUP_DIR:-/backups}/last-success +if [ -f "$marker" ] && [ $((now - $(stat -c %Y "$marker"))) -lt "$limit" ]; then + exit 0 +fi +if [ -f /tmp/kvitt-started ] && [ $((now - $(cat /tmp/kvitt-started))) -lt "$limit" ]; then + exit 0 +fi +echo "no successful backup in the last 26 hours" +exit 1 diff --git a/infra/build-images.sh b/infra/build-images.sh new file mode 100755 index 0000000..ed4bbe8 --- /dev/null +++ b/infra/build-images.sh @@ -0,0 +1,99 @@ +#!/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). +# 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): +# KVITT_REGISTRY=gitea.lan/jacobh2 +# KVITT_REGISTRY_USER= optional +# KVITT_REGISTRY_TOKEN= optional +# Without that file the images are built locally and the exact push commands are printed instead. +# Refuses a dirty working tree, and refuses to run while a Gradle build holds the gradle-serial lock (CLAUDE.md). +# Works with Docker (buildx) and with Podman (this Mac's `docker`); the registry is plain HTTP on the home Wi-Fi. +set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +[[ -z "$(git status --porcelain)" ]] || { echo "build-images.sh: the working tree is dirty; commit or stash first." >&2; exit 1; } +lock=/tmp/kvitt-gradle.lock +if [[ -d "$lock" ]] && pid="$(cat "$lock/pid" 2>/dev/null)" && [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then + echo "build-images.sh: a Gradle build is running (pid $pid); try again when it is done." >&2 + exit 1 +fi + +tag="${1:-$(git rev-parse --short HEAD)}" +[[ "$tag" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]] || { echo "build-images.sh: not a valid tag: $tag" >&2; exit 2; } + +reg_file="$repo_root/infra/secrets/registry.env" +registry="gitea.lan/jacobh2"; user=""; token=""; push=0 +if [[ -f "$reg_file" ]]; then + value() { sed -n "s/^$1=//p" "$reg_file" | tail -n 1; } + registry="$(value KVITT_REGISTRY)"; registry="${registry:-gitea.lan/jacobh2}" + user="$(value KVITT_REGISTRY_USER)"; token="$(value KVITT_REGISTRY_TOKEN)" + push=1 +fi +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" +) + +if docker version 2>/dev/null | grep -qi podman; then engine=podman; else engine=docker; fi +echo "Engine: $engine; registry: $registry; tag: $tag (and latest); push: $([[ $push == 1 ]] && echo yes || echo no)" + +if [[ $push == 1 && -n "$user" && -n "$token" ]]; then + if [[ $engine == podman ]]; then + printf '%s' "$token" | podman login --tls-verify=false "$registry_host" -u "$user" --password-stdin + else + printf '%s' "$token" | docker login "$registry_host" -u "$user" --password-stdin + fi +fi + +if [[ $engine == docker && $push == 1 ]]; then + # A buildx builder that may push to the plain-HTTP registry (same rule as the Gitea workflow). + if ! docker buildx inspect kvitt-http-builder >/dev/null 2>&1; then + cfg="$(mktemp)" + printf '[registry."%s"]\n http = true\n' "$registry_host" > "$cfg" + docker buildx create --name kvitt-http-builder --config "$cfg" >/dev/null + rm -f "$cfg" + fi +fi + +for spec in "${images[@]}"; do + IFS='|' read -r name context dockerfile <<< "$spec" + ref="$registry/$name" + echo "== $ref:$tag" + if [[ $engine == podman ]]; then + podman build --platform linux/arm64 -f "$dockerfile" -t "$ref:$tag" -t "$ref:latest" "$context" + if [[ $push == 1 ]]; then + podman push --tls-verify=false "$ref:$tag" + podman push --tls-verify=false "$ref:latest" + fi + elif [[ $push == 1 ]]; then + docker buildx build --builder kvitt-http-builder --platform linux/arm64 -f "$dockerfile" \ + -t "$ref:$tag" -t "$ref:latest" --push "$context" + else + docker buildx build --platform linux/arm64 -f "$dockerfile" -t "$ref:$tag" -t "$ref:latest" --load "$context" + fi +done + +if [[ $push == 1 ]]; then + echo "Pushed. On the Pi: /opt/kvitt/deploy.sh $tag" +else + echo + echo "Built locally, not pushed: $reg_file does not exist. To push, create it (KVITT_REGISTRY=$registry, plus" + echo "KVITT_REGISTRY_USER and KVITT_REGISTRY_TOKEN if Gitea needs them) and run this script again, or push by hand:" + for spec in "${images[@]}"; do + IFS='|' read -r name _ _ <<< "$spec" + if [[ $engine == podman ]]; then + echo " podman push --tls-verify=false $registry/$name:$tag && podman push --tls-verify=false $registry/$name:latest" + else + echo " docker push $registry/$name:$tag && docker push $registry/$name:latest # needs $registry_host in insecure-registries" + fi + done +fi diff --git a/infra/cert-sync/Dockerfile b/infra/cert-sync/Dockerfile new file mode 100644 index 0000000..a9f5e19 --- /dev/null +++ b/infra/cert-sync/Dockerfile @@ -0,0 +1,26 @@ +# 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"] diff --git a/infra/cert-sync/entrypoint.sh b/infra/cert-sync/entrypoint.sh new file mode 100755 index 0000000..2566714 --- /dev/null +++ b/infra/cert-sync/entrypoint.sh @@ -0,0 +1,5 @@ +#!/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 diff --git a/infra/cert-sync/sync.sh b/infra/cert-sync/sync.sh new file mode 100755 index 0000000..0bc08f0 --- /dev/null +++ b/infra/cert-sync/sync.sh @@ -0,0 +1,95 @@ +#!/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 -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 diff --git a/infra/pi/.env.example b/infra/pi/.env.example new file mode 100644 index 0000000..01745b4 --- /dev/null +++ b/infra/pi/.env.example @@ -0,0 +1,37 @@ +# Kvitt Pi settings (SPEC §10.2). Copy to .env in this folder, replace every CHANGE_ME, then run ./install.sh, +# which copies it to /opt/kvitt/.env (mode 0600). install.sh refuses a .env that still contains CHANGE_ME. +# No secret files here: those go to /opt/kvitt/secrets/ (infra/README.md "Pi: what the owner does"). + +# --- Read by compose.yaml ----------------------------------------------------------------------------------------- + +# Registry and owner path of the Kvitt images; images are ${KVITT_REGISTRY}/kvitt-server|kvitt-cert-sync|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). +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; the Home Assistant SSH add-ons log in as root, or the user set in the add-on). +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). +RCLONE_FOLDER_ID=1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV + +# --- Read by install.sh only (never by the containers) ------------------------------------------------------------ + +# Gitea user that may pull the images; leave empty when the Gitea packages are public (anonymous pull). +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 -------------------- + +# LAN address of the Kvitt Pi itself (OQ-3). +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 diff --git a/infra/pi/.gitignore b/infra/pi/.gitignore new file mode 100644 index 0000000..ddfe206 --- /dev/null +++ b/infra/pi/.gitignore @@ -0,0 +1,5 @@ +# Filled on the Mac or the Pi, never committed: host addresses and registry credentials. +.env +# publish-apk.sh output (APKs, latest.json, R8 mappings) and secrets staged for the copy to the Pi. +out/ +secrets/ diff --git a/infra/pi/Caddyfile b/infra/pi/Caddyfile new file mode 100644 index 0000000..b6c7944 --- /dev/null +++ b/infra/pi/Caddyfile @@ -0,0 +1,16 @@ +{ + admin 0.0.0.0:2019 + auto_https off +} + +icehack.asuscomm.com:8443 { + tls /certs/fullchain.pem /certs/privkey.pem + request_body { + max_size 6MB + } + reverse_proxy server:8080 + log { + output stdout + format json + } +} diff --git a/infra/pi/backup-now.sh b/infra/pi/backup-now.sh new file mode 100755 index 0000000..e4c7b26 --- /dev/null +++ b/infra/pi/backup-now.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Run the backup once on the Kvitt Pi (SPEC §10.9) and wait for it: /opt/kvitt/backup-now.sh +# Succeeds only when backup.sh succeeded and /backups/last-success changed: the snapshot passed integrity_check, +# both files are confirmed on Drive and their local copies are deleted (D-002). Lists the newest files on Drive. +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +. "$here/lib.sh" + +before=$(compose exec -T backup cat /backups/last-success 2>/dev/null || true) +compose exec -T backup /backup.sh || die "backup.sh failed (lines above)" +after=$(compose exec -T backup cat /backups/last-success 2>/dev/null || true) +[[ -n "$after" && "$after" != "$before" ]] || die "last-success did not change (was '${before:-none}', is '${after:-none}')" +say "last-success: $after" +remote=$(env_value RCLONE_REMOTE); folder=$(env_value RCLONE_FOLDER_ID) +say "Newest files in the Drive folder:" +# shellcheck disable=SC2016 # the script runs inside the backup container, with its own \$1 and \$2 +compose exec -T backup sh -c 'c=$(mktemp); cp /config/rclone/rclone.conf "$c"; RCLONE_CONFIG=$c rclone lsf "$1" --drive-root-folder-id "$2" --files-only --log-level ERROR; s=$?; rm -f "$c"; exit $s' \ + sh "${remote:-gdrive:}" "$folder" | sort | tail -n 4 diff --git a/infra/pi/bootstrap.sh b/infra/pi/bootstrap.sh new file mode 100755 index 0000000..3a3f7c7 --- /dev/null +++ b/infra/pi/bootstrap.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# First admin invite on the Kvitt Pi (SPEC FR-1, §10.4). Run ON the Pi once, after install.sh: /opt/kvitt/bootstrap.sh +# Prints the invite code, link and expiry. Exit 2 from the server means the database is already initialised. +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +. "$here/lib.sh" + +set +e +compose exec -T server kvitt-server bootstrap +status=$? +set -e +case "$status" in + 0) say "Open the link on the owner's phone (or enter the code on the pairing screen)." ;; + 2) say "Already initialised: no new invite. Extra devices: docker compose exec server kvitt-server pair-device --user " ;; + *) die "bootstrap exited $status (docker compose logs server)" ;; +esac diff --git a/infra/pi/compose.yaml b/infra/pi/compose.yaml new file mode 100644 index 0000000..fe39947 --- /dev/null +++ b/infra/pi/compose.yaml @@ -0,0 +1,137 @@ +# 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). +# Image names: ${KVITT_REGISTRY}/kvitt-, the names the Gitea Actions workflow pushes (D-104). + +name: kvitt + +x-logging: &logging + driver: json-file + options: + max-size: "10m" + 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 + security_opt: + - no-new-privileges:true + 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"] + restart: unless-stopped + user: "10001:10001" + read_only: true + cap_drop: [ALL] + security_opt: + - no-new-privileges:true + mem_limit: 160m + 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 + volumes: + - data:/data + - /opt/kvitt/secrets/fcm-service-account.json:/secrets/fcm-service-account.json:ro + # Empty except during the one-off Splitwise import (SPEC §10.8, D-025). + - /opt/kvitt/import:/import:ro + healthcheck: + test: ["CMD", "kvitt-server", "healthcheck"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + 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 + # uid 10001 like the server, so it can open the WAL database files the server owns; rclone.conf on the Pi is + # therefore owned by 10001 (infra/README.md "Backup uid", D-105). + user: "10001:10001" + cap_drop: [ALL] + security_opt: + - no-new-privileges:true + mem_limit: 96m + environment: + TZ: Europe/Stockholm + RCLONE_REMOTE: ${RCLONE_REMOTE:-gdrive:} + RCLONE_FOLDER_ID: ${RCLONE_FOLDER_ID:?set RCLONE_FOLDER_ID in .env} + volumes: + - data:/data + - backups:/backups + - /opt/kvitt/secrets/rclone.conf:/config/rclone/rclone.conf:ro + healthcheck: + test: ["CMD", "/healthcheck.sh"] + interval: 5m + timeout: 10s + retries: 1 + start_period: 30s + logging: *logging + networks: [kvitt] + +volumes: + data: + certs: + caddy_data: + backups: + +networks: + kvitt: + driver: bridge diff --git a/infra/pi/deploy.sh b/infra/pi/deploy.sh new file mode 100755 index 0000000..3618e68 --- /dev/null +++ b/infra/pi/deploy.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Deploy an image tag on the Kvitt Pi (SPEC §10.3, §10.10). Run ON the Pi: /opt/kvitt/deploy.sh +# 1. backup first (backup-now.sh: Drive upload confirmed, last-success updated), because a tag may carry a +# migration and the Pi cannot tell beforehand (the rule "backup before a deploy with a migration" is applied +# 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 && docker compose up -d, waits for server to be healthy, prints GET /health +# 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 +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +. "$here/lib.sh" + +skip_backup=0 +tag="" +for arg in "$@"; do + case "$arg" in + --skip-backup) skip_backup=1 ;; + -h|--help) sed -n '2,10p' "$0"; exit 0 ;; + -*) die "unknown option $arg" ;; + *) [[ -z "$tag" ]] || die "usage: $0 [--skip-backup]"; tag=$arg ;; + esac +done +[[ -n "$tag" ]] || die "usage: $0 [--skip-backup]" +[[ "$tag" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]] || die "not a valid image tag: $tag" +[[ -f "$KVITT_DIR/.env" ]] || die "$KVITT_DIR/.env is missing: run install.sh first" + +current=$(env_value KVITT_TAG) +say "Deploying $tag (running: ${current:-none})" + +# 1. Backup before the deploy. +if (( skip_backup == 1 )); then + say "Backup skipped (--skip-backup)" +elif [[ "$(service_health server)" == none ]] || ! compose exec -T backup test -f /data/kvitt.sqlite 2>/dev/null; then + say "No running server with a database: no backup needed" +else + "$here/backup-now.sh" || die "the pre-deploy backup failed; nothing deployed (fix the backup, or use --skip-backup knowingly)" +fi + +# 2. Tag. +[[ -n "$current" ]] && printf '%s\n' "$current" > "$KVITT_DIR/previous-tag" +env_set KVITT_TAG "$tag" + +# 3. Pull and start. +if ! compose pull; then + [[ -n "$current" ]] && env_set KVITT_TAG "$current" + die "pull of $tag failed; KVITT_TAG left at ${current:-its old value}" +fi +compose up -d --remove-orphans +if ! wait_healthy server 180; then + compose logs --tail 50 server || true + die "server is not healthy on $tag. Roll back: $KVITT_DIR/deploy.sh ${current:-} --skip-backup" +fi +compose ps +print_health || die "server is healthy but /health through Caddy failed (docker compose logs caddy cert-sync)" +say "Deployed $tag." diff --git a/infra/pi/install-apk.sh b/infra/pi/install-apk.sh new file mode 100755 index 0000000..e45519a --- /dev/null +++ b/infra/pi/install-apk.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Put the APKs that infra/publish-apk.sh made on the Mac into the server's data volume (SPEC §10.6). +# The owner copies infra/pi/out/apk/ from the Mac to /opt/kvitt/apk/ on the Pi, then runs ON the Pi: +# /opt/kvitt/install-apk.sh +# Copies every kvitt-.apk from /opt/kvitt/apk into /data/apk, then latest.json last and atomically +# (temp file, rename), so the server never announces a file it does not have; keeps the newest 5 APKs in /data/apk. +# R8 mapping files (kvitt-.mapping.txt) stay in /opt/kvitt/apk; the server never needs them. +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +. "$here/lib.sh" + +src="$KVITT_DIR/apk" +[[ -f "$src/latest.json" ]] || die "no $src/latest.json: copy infra/pi/out/apk/ from the Mac first (README, Publishing an APK)" +file_name=$(sed -n 's/.*"file_name": *"\([^"]*\)".*/\1/p' "$src/latest.json") +[[ "$file_name" =~ ^kvitt-[0-9]+\.apk$ ]] || die "latest.json has no valid file_name" +[[ -f "$src/$file_name" ]] || die "latest.json names $file_name, which is not in $src" +sha=$(sed -n 's/.*"sha256": *"\([0-9a-f]\{64\}\)".*/\1/p' "$src/latest.json") +[[ "$(sha256sum "$src/$file_name" | cut -d' ' -f1)" == "$sha" ]] || die "$file_name does not match the sha256 in latest.json" + +# backup runs as uid 10001, the owner of /data, and has a shell; the server image has none. +# shellcheck disable=SC2016 # the script runs inside the backup container +compose run --rm --no-deps -T -v "$src:/incoming:ro" --entrypoint /bin/sh backup -c ' + set -eu + mkdir -p /data/apk + for f in /incoming/kvitt-*.apk; do + [ -f "$f" ] || continue + n=$(basename "$f") + [ -f "/data/apk/$n" ] && cmp -s "$f" "/data/apk/$n" && continue + cp "$f" "/data/apk/.$n.tmp" && mv -f "/data/apk/.$n.tmp" "/data/apk/$n" + echo "copied $n" + done + cp /incoming/latest.json /data/apk/.latest.json.tmp + mv -f /data/apk/.latest.json.tmp /data/apk/latest.json + # keep the newest 5 APKs by version code + ls /data/apk | sed -n "s/^kvitt-\([0-9]*\)\.apk$/\1/p" | sort -n -r | tail -n +6 | while read -r v; do + 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" +echo diff --git a/infra/pi/install.sh b/infra/pi/install.sh new file mode 100755 index 0000000..45f7b3b --- /dev/null +++ b/infra/pi/install.sh @@ -0,0 +1,134 @@ +#!/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. +# 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 +# 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 +# 6. pulls the images, gives the named volumes their owners, starts the stack, waits for /health +# Uses sudo for /opt/kvitt, the secret files and the Docker daemon configuration. +set -euo pipefail +bundle="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +. "$bundle/lib.sh" + +# 1. Docker +command -v docker >/dev/null || die "docker is not installed (README step 2)" +docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing (README step 2)" +docker info >/dev/null 2>&1 || die "cannot talk to Docker as $(id -un): add yourself to the docker group (sudo usermod -aG docker $(id -un)), log out and in" +command -v curl >/dev/null || die "curl is missing: sudo apt-get install -y curl" + +# 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 + value=$(env_value "$key" "$env_src") + [[ -n "$value" ]] || die ".env: $key is empty" + [[ "$value" != CHANGE_ME* ]] || die ".env: $key is still $value" +done +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 (SPEC §10.2, §10.7, D-023, 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=() +for spec in "${secret_specs[@]}"; do + IFS=: read -r name uid gid mode <<< "$spec" + src="$bundle/secrets/$name" + dst="$KVITT_DIR/secrets/$name" + if [[ -f "$src" ]]; then + sudo install -m "$mode" -o "$uid" -g "$gid" "$src" "$dst" + say " secrets/$name installed (mode $mode, owner $uid:$gid)" + elif sudo test -f "$dst"; then + sudo chown "$uid:$gid" "$dst" + sudo chmod "$mode" "$dst" + say " secrets/$name already in place (mode $mode, owner $uid:$gid)" + else + missing+=("$name") + fi + if sudo test -d "$dst"; then + die "$dst is a directory (Docker creates one when a file was missing at start): sudo rmdir $dst, then run again" + fi +done +(( ${#missing[@]} == 0 )) || die "missing secret files: ${missing[*]} (put them in $bundle/secrets/, README step 5)" +if [[ -d "$bundle/secrets" ]]; then + say " The copies in $bundle/secrets/ are no longer needed: rm -rf $bundle/secrets" +fi + +# 4. Registry trust (plain HTTP on the home Wi-Fi, SPEC §10.3) and optional login. +daemon_json=/etc/docker/daemon.json +changed=$(sudo python3 - "$daemon_json" "$registry_host" <<'PY' +import json, os, sys +path, host = sys.argv[1], sys.argv[2] +cfg = {} +if os.path.exists(path): + with open(path) as f: + text = f.read().strip() + cfg = json.loads(text) if text else {} +regs = cfg.setdefault("insecure-registries", []) +if host in regs: + print("no") +else: + regs.append(host) + tmp = path + ".kvitt-tmp" + with open(tmp, "w") as f: + json.dump(cfg, f, indent=2) + f.write("\n") + os.replace(tmp, path) + print("yes") +PY +) +if [[ "$changed" == yes ]]; then + say "Added $registry_host to insecure-registries in $daemon_json; restarting Docker" + sudo systemctl restart docker +else + say "$registry_host is already in insecure-registries" +fi +if [[ -n "$registry_user" && -n "$registry_token" ]]; then + printf '%s' "$registry_token" | docker login "$registry_host" -u "$registry_user" --password-stdin >/dev/null + say "Logged in to $registry_host as $registry_user" +else + say "No registry credentials in .env: pulling anonymously" +fi + +# 5. Stack files and operation scripts. +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" +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" + +# 6. Pull, volume owners, start. +compose config --quiet +compose pull +compose create +# Named volumes start owned by root; give each its container's uid (the server image has no shell, so the +# 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' +compose run --rm --no-deps -T --user 0:0 --cap-add CHOWN --entrypoint /bin/sh cert-sync \ + -c 'chown 1000:1000 /certs' +compose up -d --remove-orphans +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)." diff --git a/infra/pi/lib.sh b/infra/pi/lib.sh new file mode 100755 index 0000000..dc5705e --- /dev/null +++ b/infra/pi/lib.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Shared helpers for the Kvitt Pi scripts (install.sh, deploy.sh, bootstrap.sh, backup-now.sh, install-apk.sh). +# Sourced, not run. These scripts run ON the Kvitt Pi as the owner's user (a member of the docker group). +# 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 + +say() { printf '%s\n' "$*"; } +die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } + +# Value of KEY in an env file (default /opt/kvitt/.env); empty when unset. +env_value() { + local file=${2:-$KVITT_DIR/.env} + sed -n "s/^$1=//p" "$file" | tail -n 1 +} + +# Set KEY=VALUE in an env file in place (adds the line when missing). Keeps the file's mode. +env_set() { + local key=$1 value=$2 file=${3:-$KVITT_DIR/.env} tmp + tmp=$(mktemp "$file.XXXXXX") + if grep -q "^$key=" "$file"; then + awk -v k="$key" -v v="$value" 'index($0, k "=") == 1 { print k "=" v; next } { print }' "$file" > "$tmp" + else + cat "$file" > "$tmp" + printf '%s=%s\n' "$key" "$value" >> "$tmp" + fi + chmod --reference="$file" "$tmp" + mv -f "$tmp" "$file" +} + +compose() { + docker compose --project-directory "$KVITT_DIR" -f "$KVITT_DIR/compose.yaml" --env-file "$KVITT_DIR/.env" "$@" +} + +# Health status of a compose service's container: healthy, unhealthy, starting, or none. +service_health() { + local id + id=$(compose ps -q "$1" 2>/dev/null | head -n 1) + [[ -n "$id" ]] || { echo none; return; } + docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$id" 2>/dev/null || echo none +} + +# Wait until a service is healthy (default 180 s). Returns 1 on timeout. +wait_healthy() { + local service=$1 timeout=${2:-180} waited=0 status + while :; do + status=$(service_health "$service") + [[ "$status" == healthy ]] && { say "$service: healthy"; return 0; } + if (( waited >= timeout )); then + say "$service: $status after ${timeout}s" + return 1 + fi + sleep 5 + waited=$((waited + 5)) + done +} + +# GET /health through Caddy on this Pi, with the real certificate check (no -k): proves TLS and the proxy path. +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" + return 0 + fi + say "GET https://$PUBLIC_HOST:$PUBLIC_PORT/health (via this Pi) failed: $out" + return 1 +} diff --git a/infra/publish-apk.sh b/infra/publish-apk.sh new file mode 100755 index 0000000..b4438b0 --- /dev/null +++ b/infra/publish-apk.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Build the release APK on the Mac and stage it for the Pi (SPEC §10.6, D-100). +# Usage, from the main checkout: infra/publish-apk.sh +# 1. refuses a dirty working tree (the version is derived from HEAD) +# 2. version (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 / -Pkvitt.versionName +# 3. :app:assembleRelease through tools/gradle-serial.sh (JDK 25 from Homebrew on PATH, JAVA_HOME untouched) +# 4. writes into infra/pi/out/apk/: kvitt-.apk, kvitt-.mapping.txt (R8 mapping, for +# reading stack traces; never served), and latest.json (written to a temp file, then renamed) +# 5. keeps the newest 5 APKs (and their mappings) in infra/pi/out/apk/ +# Then the owner copies the folder to the Pi and runs /opt/kvitt/install-apk.sh there (infra/README.md). +# Refuses an unsigned build: without the kvitt.release.* keys in android/local.properties the APK cannot be +# installed over an earlier release (D-024, D-101). +set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +[[ -z "$(git status --porcelain)" ]] || { echo "publish-apk.sh: the working tree is dirty; commit or stash first." >&2; exit 1; } + +version_code="$(git rev-list --count HEAD)" +short_sha="$(git rev-parse --short=7 HEAD)" +commit_day="$(TZ=Europe/Stockholm git show -s --date=format-local:%Y.%m.%d --format=%cd HEAD)" +version_name="$commit_day-$short_sha" +echo "Release $version_name (version_code $version_code)" + +PATH="/opt/homebrew/opt/openjdk@25/bin:$PATH" tools/gradle-serial.sh :app:assembleRelease \ + "-Pkvitt.versionCode=$version_code" "-Pkvitt.versionName=$version_name" + +out_dir="$repo_root/android/app/build/outputs/apk/release" +apk="$out_dir/app-release.apk" +mapping="$repo_root/android/app/build/outputs/mapping/release/mapping.txt" +if [[ ! -f "$apk" ]]; then + if [[ -f "$out_dir/app-release-unsigned.apk" ]]; then + echo "publish-apk.sh: the build is unsigned (no kvitt.release.* keys in android/local.properties); not published." >&2 + else + echo "publish-apk.sh: no APK in $out_dir" >&2 + fi + exit 1 +fi +# The APK must be the one just built: output-metadata.json carries the version code Gradle used. +python3 - "$out_dir/output-metadata.json" "$version_code" <<'PY' +import json, sys +meta = json.load(open(sys.argv[1])) +codes = {str(e.get("versionCode")) for e in meta.get("elements", [])} +if sys.argv[2] not in codes: + sys.exit(f"publish-apk.sh: output-metadata.json has version codes {sorted(codes)}, expected {sys.argv[2]}") +PY +[[ -f "$mapping" ]] || { echo "publish-apk.sh: no R8 mapping at $mapping" >&2; exit 1; } + +dest="$repo_root/infra/pi/out/apk" +mkdir -p "$dest" +file_name="kvitt-$version_code.apk" +cp "$apk" "$dest/.$file_name.tmp" && mv -f "$dest/.$file_name.tmp" "$dest/$file_name" +cp "$mapping" "$dest/kvitt-$version_code.mapping.txt" +size_bytes="$(wc -c < "$dest/$file_name" | tr -d ' ')" +sha256="$(shasum -a 256 "$dest/$file_name" | cut -d' ' -f1)" +released_at="$(python3 -c 'import datetime; t=datetime.datetime.now(datetime.timezone.utc); print(t.strftime("%Y-%m-%dT%H:%M:%S.") + f"{t.microsecond // 1000:03d}Z")')" + +tmp="$(mktemp "$dest/.latest.json.XXXXXX")" +cat > "$tmp" <@:/opt/kvitt/apk/" +echo " ssh @ /opt/kvitt/install-apk.sh"