M2c-pi: Pi bundle (infra/pi), cert-sync, Drive backup upload, Gitea workflow

The owner copies infra/pi/ to the Kvitt Pi and runs it there (D-100); nothing
on the Mac connects to the Pis. compose stack of SPEC §10.2 with images
${KVITT_REGISTRY}/kvitt-{server,cert-sync,backup}, Caddyfile §10.5.2 verbatim,
.env.example with the OQ-3 values as CHANGE_ME placeholders, install.sh,
deploy.sh (backup before every deploy), bootstrap.sh, backup-now.sh,
install-apk.sh. cert-sync image and sync.sh (§10.5.1, tar stream for both
authorized_keys variants); backup.sh uploads with rclone copy --immutable and
deletes local files once both are confirmed on Drive (D-002); both sidecars
schedule with supercronic because busybox crond runs no jobs as non-root;
backup runs as uid 10001. .gitea/workflows/build-images.yaml from the owner's
example (not yet run); infra/build-images.sh and infra/publish-apk.sh (R8
mapping kept, version per §10.6) on the Mac; README checklist and RESTORE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-18 21:46:44 +02:00
parent bfc3acbbe0
commit 2476667510
21 changed files with 1409 additions and 24 deletions
+104
View File
@@ -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 <version>.
#
# 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"
+311 -8
View File
@@ -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 <key id> ...`.
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 <filled sample> config`: exit 0.
* `cert-sync` and `backup` images built for linux/arm64 with Podman (not pushed).
* End-to-end rehearsal with Podman: a stand-in Home Assistant sshd with the variant A key below, the cert-sync
image as uid 1000 with `StrictHostKeyChecking=yes`, the pinned Caddy with `pi/Caddyfile` in front of a stand-in
server. The first fetch installed both files (0640); `GET /health` through Caddy answered over TLS; a renewed
certificate was installed and Caddy reloaded through its admin API without a restart; an unchanged certificate
was left alone; an older certificate and a key that does not match the certificate were refused with the old pair
kept; TLS 1.1 was refused. **Caddy logged the request headers with `"Authorization":["REDACTED"]` and the token
never appeared in its log** (SPEC §10.5.2).
* Backup rehearsal with a local rclone remote: upload, `lsf` confirmation by name and size, local deletion only
after both files were confirmed (D-002); files kept and `last-success` unchanged when the upload failed; the
leftovers uploaded by the next good run; healthcheck green on a fresh start and after a run, red at 26 h.
* 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-<short sha>`
of HEAD's commit date (Europe/Stockholm), passed as `-Pkvitt.versionCode` and `-Pkvitt.versionName`. The image
tags of the Gitea workflow (`v1.0.N`) are separate and say nothing about the APK. The script refuses a dirty tree,
builds `:app:assembleRelease` through `tools/gradle-serial.sh` (JDK 25 on `PATH`), refuses an unsigned APK, and
writes into `infra/pi/out/apk/`: `kvitt-<version_code>.apk`, `kvitt-<version_code>.mapping.txt` (the R8 mapping,
for reading stack traces; never served) and `latest.json` (temp file, then rename); it keeps the newest 5 APKs
with their mappings. Then the owner, on the Mac:
```bash
cd /Users/jhag/projects/kvitt
scp infra/pi/out/apk/* <KVITT_PI_SSH_USER>@<KVITT_PI_HOST>:/opt/kvitt/apk/
ssh <KVITT_PI_SSH_USER>@<KVITT_PI_HOST> /opt/kvitt/install-apk.sh
```
`install-apk.sh` checks the SHA-256 against `latest.json`, copies the APKs into the `data` volume (`/data/apk/`)
and `latest.json` last with a rename, keeps the newest 5 there and prints `GET /apk/latest`.
## Pi: what the owner does
Fill these in first (in `infra/pi/.env` on the Mac; the bundle carries the file to the Pi):
| Placeholder in `infra/pi/.env` | What | Where to find it |
|---|---|---|
| `KVITT_PI_HOST` | LAN address of the Kvitt Pi (give it a static DHCP lease) | ASUS router web UI, LAN, DHCP Server, client list |
| `KVITT_PI_SSH_USER` | Your user on the Kvitt Pi | The user set in Raspberry Pi Imager |
| `HA_HOST` | LAN address of the Home Assistant Pi | Router client list, or Home Assistant, Settings, System, Network |
| `HA_PORT` | SSH port of the SSH add-on (default 22) | Home Assistant, Settings, Add-ons, the SSH add-on, Configuration, Network |
| `HA_USER` | SSH user of the add-on (`root` for "Terminal & SSH"; the configured `username` for "Advanced SSH & Web Terminal") | Same add-on, Configuration |
| `HA_CERT_DIR` | Directory holding `fullchain.pem` and `privkey.pem` on the Home Assistant Pi (`/ssl` on Home Assistant OS) | In the add-on's terminal: `ls -l /ssl` |
| `KVITT_TAG` | Image tag to run: the newest `v1.0.N` the Gitea workflow pushed (or the SHA from `build-images.sh`) | Gitea, the repository, Packages, `kvitt-server` |
| `KVITT_REGISTRY_USER`, `KVITT_REGISTRY_TOKEN` | Only if the Gitea packages are private: a Gitea user and a token with `read:package` | Gitea, Settings, Applications, Generate New Token |
| `KVITT_REGISTRY` | Already `gitea.lan/jacobh2` | |
| `RCLONE_REMOTE`, `RCLONE_FOLDER_ID` | Already `gdrive:` and `1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV` | |
### 1. Provision the Kvitt Pi
Raspberry Pi Imager on the Mac: Raspberry Pi 3, **Raspberry Pi OS Lite (64-bit)**; in its settings set the host
name, your user and password, the network, time zone Europe/Stockholm, and enable SSH with your public key. Boot
it, give it a static DHCP lease in the router, then from the Mac: `ssh <KVITT_PI_SSH_USER>@<KVITT_PI_HOST>`.
### 2. Install Docker Engine on the Pi (docs.docker.com, Debian arm64 repository)
On the Pi:
```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 <<EOF
Types: deb
URIs: https://download.docker.com/linux/debian
Suites: $(. /etc/os-release && echo "$VERSION_CODENAME")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker "$USER"
exit
```
Log in again; `docker compose version` prints a version and `docker run --rm hello-world` prints
"Hello from Docker!".
### 3. Put the cert-sync key on the Home Assistant SSH add-on
Home Assistant, Settings, Add-ons, your SSH add-on, Configuration, `authorized_keys`: add **one** new entry.
Try variant A first (replace `/ssl` if your `HA_CERT_DIR` differs):
Variant A (restricted, preferred):
```
restrict,command="tar -C /ssl -cf - fullchain.pem privkey.pem" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMiXj2dU8GU0Xmdj91WxLhVpPOI1zRWvBF5h2MbE4S8V kvitt-cert-sync
```
Save, restart the add-on. If the add-on refuses to save or start with it (its Log tab shows an error about the
key), replace it with variant B:
Variant B (plain key):
```
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMiXj2dU8GU0Xmdj91WxLhVpPOI1zRWvBF5h2MbE4S8V kvitt-cert-sync
```
cert-sync works with both unchanged: it always asks for exactly that `tar` command. Tell the orchestrator which
variant the add-on accepted, so it is recorded here. The private key is
`/Users/jhag/projects/kvitt/infra/secrets/ha_ssh_key` (git-ignored; never paste it anywhere).
### 4. Fill the `.env` and stage the secrets, on the Mac
```bash
cd /Users/jhag/projects/kvitt
cp infra/pi/.env.example infra/pi/.env
open -e infra/pi/.env # replace every CHANGE_ME (table above), save
mkdir -p infra/pi/secrets
cp infra/secrets/fcm-service-account.json infra/secrets/ha_ssh_key infra/secrets/rclone.conf infra/pi/secrets/
```
`rclone.conf` is already generated from your `rclone authorize "drive"` (remote `[gdrive]`, `type = drive`,
`scope = drive`, the token); the Drive folder id is `RCLONE_FOLDER_ID` in `.env`, not in the conf.
`infra/pi/.env` and `infra/pi/secrets/` are git-ignored.
### 5. Copy the bundle to the Pi and record the Home Assistant host key
On the Mac:
```bash
scp -r infra/pi <KVITT_PI_SSH_USER>@<KVITT_PI_HOST>:kvitt-pi
rm -r infra/pi/secrets # the originals stay in infra/secrets/
```
On the Pi:
```bash
cd ~/kvitt-pi
ssh-keyscan -p <HA_PORT> <HA_HOST> > secrets/ha_known_hosts
wc -l secrets/ha_known_hosts # at least 1 line
```
### 6. Install and start
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 `<KVITT_PI_HOST>`, internal port 8443; Apply. Then on the phone with Wi-Fi off, open
`https://icehack.asuscomm.com:8443/health` in Chrome: it shows `"status":"ok"` without a certificate warning (yes/no).
### 8. First backup and bootstrap
On the Pi:
```bash
/opt/kvitt/backup-now.sh # ends with "last-success: <stamp>" and the newest kvitt-*.sqlite.gz and pictures-*.tar.gz on Drive
/opt/kvitt/bootstrap.sh # first install only: prints the admin invite code, link and expiry
```
### 9. Deploying a new version
After the Gitea workflow pushed `v1.0.N` (or `infra/build-images.sh` printed a tag), on the Pi:
```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 <KVITT_PI_SSH_USER>@<KVITT_PI_HOST>:/tmp/kvitt_root_ca.crt android/app/src/release/res/raw/kvitt_root_ca.crt`
(a public certificate, committed by an Android agent) and set `kvitt.tls.pinnedRoot=true` in
`android/gradle.properties` (D-082). The root is valid 10 years. Caddy still waits for cert-sync to be healthy,
which needs the two files in the `certs` volume: keep the last fetched pair there, or remove the `cert-sync`
condition from `depends_on` in `/opt/kvitt/compose.yaml`.
### Operations (SPEC §10.10), on the Pi in `/opt/kvitt`
| Task | Command |
|---|---|
| Status | `docker compose ps` |
| Logs | `docker compose logs --tail 100 <caddy, server, cert-sync or backup>` |
| Backup now | `/opt/kvitt/backup-now.sh` |
| Upgrade | `/opt/kvitt/deploy.sh <tag>` |
| Roll back | `/opt/kvitt/deploy.sh "$(cat /opt/kvitt/previous-tag)" --skip-backup` |
| Certificate now | `docker compose exec cert-sync /usr/local/bin/sync.sh` |
| Certificate expiry and issuer | `docker compose exec cert-sync openssl x509 -in /certs/fullchain.pem -noout -enddate -issuer` |
| Extra device invite | `docker compose exec server kvitt-server pair-device --user <user_id>` |
| Memory | `docker stats --no-stream` (server RSS under 100 MB with both phones syncing) |
| Disk | `df -h /` and `docker system df`; SQLite a few MB, pictures under 1 MB, APKs about 20 MB each, 5 kept |
| No token in Caddy's log | `docker compose logs caddy \| grep -c 'kvt_'` prints `0` |
| 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/<file>` is a directory: Docker created it because the file was missing at a
start; `sudo rmdir` it, put the file in `~/kvitt-pi/secrets/`, run `./install.sh`.
+88
View File
@@ -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 <file> 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-<stamp>/` 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.
+16 -9
View File
@@ -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"]
+32 -7
View File
@@ -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
+14
View File
@@ -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
+99
View File
@@ -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=<gitea user> optional
# KVITT_REGISTRY_TOKEN=<token, write:package> 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
+26
View File
@@ -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"]
+5
View File
@@ -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
+95
View File
@@ -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 <cert dir> -cf - fullchain.pem privkey.pem`, read as a tar stream. That
# works with both authorized_keys variants of the README: with `restrict,command="tar ..."` the forced command
# runs whatever is requested, and with a plain key the requested command is the same tar line.
#
# Environment (from /opt/kvitt/.env): HA_HOST, HA_PORT, HA_USER, HA_CERT_DIR.
# Files: /secrets/ha_ssh_key, /secrets/known_hosts (read-only), /certs (writable), /etc/caddy/Caddyfile (read-only).
set -eu
CERTS=${CERTS_DIR:-/certs}
KEY=${HA_SSH_KEY:-/secrets/ha_ssh_key}
KNOWN_HOSTS=${HA_KNOWN_HOSTS:-/secrets/known_hosts}
CADDYFILE=${CADDYFILE:-/etc/caddy/Caddyfile}
CADDY_ADMIN=${CADDY_ADMIN:-http://caddy:2019}
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) cert-sync: $*"; }
tmp=""
cleanup() { [ -n "$tmp" ] && rm -rf "$tmp"; return 0; }
trap cleanup EXIT INT TERM
fail() { log "FAILED: $*; existing certificate kept"; exit 1; }
for v in HA_HOST HA_PORT HA_USER HA_CERT_DIR; do
eval "val=\${$v:-}"
# shellcheck disable=SC2154 # val is set by the eval above
[ -n "$val" ] || fail "$v is not set"
done
[ -r "$KEY" ] || fail "no readable key at $KEY"
[ -r "$KNOWN_HOSTS" ] || fail "no readable known_hosts at $KNOWN_HOSTS"
tmp=$(mktemp -d /tmp/cert-sync.XXXXXX)
# 1. Fetch as a tar stream.
if ! ssh -i "$KEY" -p "$HA_PORT" \
-o BatchMode=yes -o IdentitiesOnly=yes -o ConnectTimeout=20 \
-o StrictHostKeyChecking=yes -o UserKnownHostsFile="$KNOWN_HOSTS" -o GlobalKnownHostsFile=/dev/null \
-o LogLevel=ERROR \
"$HA_USER@$HA_HOST" "tar -C '$HA_CERT_DIR' -cf - fullchain.pem privkey.pem" > "$tmp/certs.tar" 2> "$tmp/ssh.err"; then
fail "ssh to $HA_USER@$HA_HOST:$HA_PORT failed: $(head -c 300 "$tmp/ssh.err" | tr '\n' ' ')"
fi
mkdir "$tmp/new"
tar -xf "$tmp/certs.tar" -C "$tmp/new" fullchain.pem privkey.pem 2>/dev/null || fail "the tar stream did not hold fullchain.pem and privkey.pem"
new_chain="$tmp/new/fullchain.pem"
new_key="$tmp/new/privkey.pem"
# 2. Both parse as PEM, the certificate has not expired, and the key belongs to the certificate.
openssl x509 -in "$new_chain" -noout 2>/dev/null || fail "fullchain.pem does not parse as a PEM certificate"
openssl pkey -in "$new_key" -noout 2>/dev/null || fail "privkey.pem does not parse as a PEM private key"
openssl x509 -in "$new_chain" -noout -checkend 0 >/dev/null 2>&1 || fail "fullchain.pem has expired"
cert_pub=$(openssl x509 -in "$new_chain" -noout -pubkey | openssl pkey -pubin -outform DER 2>/dev/null | sha256sum | cut -d' ' -f1)
key_pub=$(openssl pkey -in "$new_key" -pubout -outform DER 2>/dev/null | sha256sum | cut -d' ' -f1)
[ "$cert_pub" = "$key_pub" ] || fail "privkey.pem does not belong to fullchain.pem"
new_end_iso=$(openssl x509 -in "$new_chain" -noout -enddate -dateopt iso_8601 | cut -d= -f2)
# 3. Unchanged? Older than the installed one?
cur_chain="$CERTS/fullchain.pem"
cur_key="$CERTS/privkey.pem"
if [ -s "$cur_chain" ] && [ -s "$cur_key" ]; then
if [ "$(sha256sum < "$new_chain")" = "$(sha256sum < "$cur_chain")" ] && \
[ "$(sha256sum < "$new_key")" = "$(sha256sum < "$cur_key")" ]; then
log "unchanged (not after $new_end_iso)"
exit 0
fi
# ISO 8601 not-after dates compare as strings.
cur_end_iso=$(openssl x509 -in "$cur_chain" -noout -enddate -dateopt iso_8601 2>/dev/null | cut -d= -f2 || true)
if [ -n "$cur_end_iso" ] && openssl x509 -in "$cur_chain" -noout -checkend 0 >/dev/null 2>&1 && \
[ "$new_end_iso" != "$cur_end_iso" ] && \
[ "$(printf '%s\n%s\n' "$new_end_iso" "$cur_end_iso" | sort | head -n 1)" = "$new_end_iso" ]; then
fail "fetched certificate (not after $new_end_iso) expires before the installed one ($cur_end_iso)"
fi
fi
# 4. Install: mode 0640, rename into place (key first, so a reload never pairs a new chain with an old key for long).
install -m 0640 "$new_key" "$CERTS/.privkey.pem.new" || fail "cannot write $CERTS"
install -m 0640 "$new_chain" "$CERTS/.fullchain.pem.new" || fail "cannot write $CERTS"
mv -f "$CERTS/.privkey.pem.new" "$cur_key"
mv -f "$CERTS/.fullchain.pem.new" "$cur_chain"
log "installed new certificate (not after $new_end_iso)"
# 5. Reload Caddy. At first start Caddy is not up yet (it waits for this container to be healthy) and loads the
# files when it starts, so an unreachable admin API is not a failure.
if curl -fsS -o /dev/null --max-time 20 -X POST "$CADDY_ADMIN/load" \
-H 'Content-Type: text/caddyfile' -H 'Cache-Control: must-revalidate' --data-binary @"$CADDYFILE" 2> "$tmp/curl.err"; then
log "caddy reloaded"
elif curl -fsS -o /dev/null --max-time 5 "$CADDY_ADMIN/config/" 2>/dev/null; then
log "FAILED: caddy reload: $(head -c 300 "$tmp/curl.err" | tr '\n' ' '); the new files are in place and load when Caddy restarts"
exit 1
else
log "caddy not reachable; it loads the certificate when it starts"
fi
+37
View File
@@ -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
+5
View File
@@ -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/
+16
View File
@@ -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
}
}
+19
View File
@@ -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
+17
View File
@@ -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 <user_id>" ;;
*) die "bootstrap exited $status (docker compose logs server)" ;;
esac
+137
View File
@@ -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-<name>, 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
+57
View File
@@ -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 <tag>
# 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 <tag> [--skip-backup]"; tag=$arg ;;
esac
done
[[ -n "$tag" ]] || die "usage: $0 <tag> [--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:-<previous tag>} --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."
+43
View File
@@ -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-<version_code>.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-<version_code>.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
+134
View File
@@ -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)."
+69
View File
@@ -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
}
+85
View File
@@ -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-<short sha> 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-<version_code>.apk, kvitt-<version_code>.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" <<JSON
{
"version_code": $version_code,
"version_name": "$version_name",
"file_name": "$file_name",
"size_bytes": $size_bytes,
"sha256": "$sha256",
"released_at": "$released_at"
}
JSON
chmod 0644 "$tmp"
mv -f "$tmp" "$dest/latest.json"
# Keep the newest 5 APKs by version code, with their mappings.
for f in "$dest"/kvitt-*.apk; do
[[ -f "$f" ]] || continue
basename "$f" | sed -n 's/^kvitt-\([0-9]*\)\.apk$/\1/p'
done | sort -n -r | tail -n +6 | while read -r old; do
rm -f "$dest/kvitt-$old.apk" "$dest/kvitt-$old.mapping.txt"
echo "removed kvitt-$old.apk and its mapping"
done
echo "Staged in infra/pi/out/apk/: $file_name ($size_bytes bytes, sha256 $sha256) and latest.json."
echo "Copy to the Pi and install (infra/README.md, Publishing an APK):"
echo " scp infra/pi/out/apk/latest.json infra/pi/out/apk/$file_name infra/pi/out/apk/kvitt-$version_code.mapping.txt <ssh user>@<Kvitt Pi address>:/opt/kvitt/apk/"
echo " ssh <ssh user>@<Kvitt Pi address> /opt/kvitt/install-apk.sh"