Files
kvitt/PLAN-M2c-pi.md
T
jacobh2 4bcff2cf89 SPEC v0.21: D-120 Nginx Proxy Manager instead of Caddy and cert-sync; D-121 Android host property
SPEC §10.5 rewritten (10.5.1 Nginx Proxy Manager, 10.5.2 Custom Nginx Configuration, 10.5.3 Fallback:
NPM down); Caddy and cert-sync text replaced in §1, §6.1, §7, §8.5, §8.7, §8.9, §10 intro, §10.1 to §10.4,
§10.7, §10.10, §11.2 to §11.4, OQ-3, OQ-6, E-7 and §14.7; the Kvitt host name is KVITT_PUBLIC_HOST.
D-121: kvitt.publicHost, RELEASE_SERVER_URL and @ReleaseServerUrl (AC-2.6, AC-2.7, §10.6, §13.3 S-1).
Decisions D-120 and D-121 with pointers in D-023, D-103 to D-106 and D-117; HANDOFF; prompts/README;
a superseded-in-part note on PLAN-M2c-pi; dated D-120 notes under M-2, L-1, L-2, L-8 and I-5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-19 20:05:49 +02:00

113 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PLAN-M2c-pi: Infra on the Pi (D-001)
> **Superseded in part by D-120 (SPEC v0.21, 2026-10-19).** TLS terminates in the owner's Nginx Proxy Manager on the Home Assistant Pi: Caddy, cert-sync, the Caddyfile, the Home Assistant SSH key and the router forward to the Kvitt Pi are gone. Where this plan names them, SPEC §10 and `infra/README.md` are current; the plan is kept as the record of what M2c-pi was briefed with.
Runs after M8 lands and before M10 (D-001, §11.1).
## 1. Goal
Deploy Kvitt's production stack on the Raspberry Pi 3: Caddy with the Let's Encrypt certificate synced from the Home Assistant Pi, the server, cert-sync and nightly Drive backups, with images in the Gitea registry at `http://gitea.lan`. Write the runbooks so the owner can provision, deploy, publish the APK and restore without an agent.
## 2. Read first
1. SPEC §10 Deployment & ops, all of it: intro "Development first (D-001)", §10.1 Hosts and network, §10.2 Compose stack, §10.3 Images, registry and build, §10.4 Server configuration and CLI, §10.5 TLS (10.5.1 Certificate sync, 10.5.2 Caddyfile, 10.5.3 Fallback), §10.6 APK hosting and release, §10.7 Firebase setup (step 7), §10.9 Backups and restore, §10.10 Operations.
2. SPEC §8.5 Transport, §8.9 Server hardening, §8.7 Privacy and logging.
3. SPEC §4 FR-18 Backups and restore, FR-20 Health (AC-20.2).
4. SPEC §11.2 (rows M2c-dev, M2c-pi, M10), §11.3 (note M2c-dev / M2c-pi), §12 (OQ-1, OQ-3, OQ-4), §14.7.
5. `docs/decisions.md` D-001, D-002, D-022, D-023, D-025.
6. Files: everything under `infra/` from M2c-dev (`infra/dev/**`, `infra/backup/**`, `infra/README.md`, `infra/.shellcheckrc`; `infra/env.example` does not exist yet and is this plan's), `server/Dockerfile`, `.gitignore`, `CLAUDE.md` (EXECUTION PROTOCOLS: Stay inside the repository, Stop and ask; THE OWNER).
## 3. Already established
- Hosts: Kvitt Pi (Raspberry Pi 3, 1 GB, 64-bit Raspberry Pi OS Lite, Docker Engine with compose plugin, static LAN address); Home Assistant Pi owns 80/443 and the certificate; ASUS router forwards external TCP 8443 to Kvitt Pi 8443; the Pi never builds anything (§10.1).
- Registry: Gitea at `http://gitea.lan`, plain HTTP, home Wi-Fi only; the Pi's Docker daemon trusts it via `insecure-registries` (its own HUMAN step, D-023); images will be built by Gitea Actions from the owner's example workflow; until then `infra/build-images.sh` on the Mac pushes to `gitea.lan` (D-001, §10.1, §10.3). Gitea credentials, if needed, come from the owner and live in `infra/secrets/` (§10.1).
- Compose stack exactly as §10.2: services `caddy`, `server`, `cert-sync`, `backup`, their images, volumes, memory limits, healthchecks, `depends_on`, `restart: unless-stopped`, json-file logging 10m × 3, `TZ=Europe/Stockholm`, network `kvitt`; `.env` keys exactly `KVITT_REGISTRY`, `KVITT_TAG`, `HA_HOST`, `HA_PORT`, `HA_USER`, `HA_CERT_DIR`, `RCLONE_REMOTE` (default `gdrive:`), `RCLONE_FOLDER_ID`.
- Files deployed to `/opt/kvitt/compose.yaml`, `/opt/kvitt/.env`, secrets in `/opt/kvitt/secrets/` (§10.2, §10 intro).
- Images: `server` per §10.3 (M2a's Dockerfile); `cert-sync` = `alpine` + `openssh-client`, `curl`, `infra/cert-sync/sync.sh` under `crond`; `backup` from M2c-dev (§10.3).
- Scripts: `infra/build-images.sh [tag]` (buildx `linux/arm64 --push`, tag default short SHA, also `latest`, refuses a dirty tree); `infra/deploy.sh <tag>` (copy compose and Caddyfile over SSH, set `KVITT_TAG`, pull, up, wait healthy, print `/health`; triggers a backup and waits for `last-success` before a deploy with a migration); `infra/publish-apk.sh` (assembleRelease via `tools/gradle-serial.sh`, SHA-256 and size, copy to `apk/kvitt-<version_code>.apk`, atomic `apk/latest.json`, keep newest 5) (§10.3, §10.6, §10.10).
- cert-sync: at start and daily 04:00; SSH with `StrictHostKeyChecking=yes`; `restrict,command="tar -C <cert dir> -cf - fullchain.pem privkey.pem"` where the HA SSH setup allows it, and the plan records which variant the add-on supports; installs only valid, newer, changed PEMs with mode 0640, reloads Caddy through its admin API; failures keep the old certificate (§10.5.1).
- Caddyfile exactly §10.5.2; Caddy must not log `Authorization` (verify); fallback §10.5.3 documented in `infra/README.md`, not deployed (§10.5).
- Backups: daily 03:30; Drive root folder id `1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV` as `RCLONE_FOLDER_ID` / `--drive-root-folder-id`; `rclone copy --immutable`, never `sync`, never delete on Drive; local files deleted as soon as both are confirmed on Drive with `rclone lsf` (D-002, §10.9, OQ-4). rclone remote `[gdrive]`, `type = drive`, `scope = drive`, token from `rclone authorize "drive"` (§10.9).
- FCM key on the Pi at `/opt/kvitt/secrets/fcm-service-account.json`, mode 0440, group 10001 (§10.7 step 7).
- The `cert-sync` and `backup` containers run as uid 1000; `ha_ssh_key`, `ha_known_hosts` and `rclone.conf` on the Pi have mode 0600, owned by uid 1000 (§10.2, D-023).
- The `server` service mounts `/opt/kvitt/import:/import:ro` for the one-off Splitwise import (§10.2, §10.8, D-025); the directory exists and is empty outside the import.
- `rclone` is needed on the Mac for H-7 only; installing it is the owner's decision (D-022 leaves it to this plan).
- Restore steps are §10.9 1 to 7, documented in `infra/RESTORE.md` (§10.9).
## 4. Owned paths
From SPEC §14.7 (v0.3): `infra/**` (owner "M2c-dev, then M2c-pi (D-001)"; after landing: "M10 updates runbooks"), `.gitea/workflows/**` (D-023), and the git-ignored `infra/secrets/**` in the main checkout.
Edit nothing outside these paths; a needed change elsewhere is a contract request to the orchestrator. Never edit `SPEC.md` or `docs/decisions.md`: put proposed decision entries in the report.
Resolved open points (SPEC v0.3, D-023):
- (a) Key-based SSH from the Mac to the Kvitt Pi is a HUMAN step (H-0). The allowances to act on the Kvitt Pi, the Home Assistant Pi, Gitea and Google Drive are requested from the owner by the orchestrator at the start of this sprint and stated in the start prompt; without them, stop before step 7.
- (b) The Gitea Actions workflow lives at `.gitea/workflows/` at the repository root, owned by this plan. Mirroring or pushing the repository to Gitea needs the owner's explicit ask; without it, write the workflow and report it as not yet run.
- (c) `ha_ssh_key`, `ha_known_hosts`, `rclone.conf` on the Pi: mode 0600, owned by uid 1000, the uid the `cert-sync` and `backup` containers run as. Verify that `backup` can read the `data` volume written by uid 10001 and record how in `infra/README.md`.
- Gitea credentials are a HUMAN step (H-2).
## 5. Deliverables
- `infra/compose.yaml`: the §10.2 stack, including the `/opt/kvitt/import:/import:ro` mount on `server` and uid 1000 for `cert-sync` and `backup`.
- `infra/Caddyfile`: §10.5.2 verbatim.
- `infra/env.example`: written for the Pi (`KVITT_REGISTRY` on `gitea.lan`, `RCLONE_FOLDER_ID` `1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV`; no secrets).
- `infra/cert-sync/Dockerfile`, `infra/cert-sync/sync.sh`: §10.5.1.
- `infra/backup/backup.sh`: D-002 deletion after Drive confirmation (kept local mode from M2c-dev).
- `infra/build-images.sh`, `infra/deploy.sh`, `infra/publish-apk.sh`: §10.3, §10.6, §10.10.
- `.gitea/workflows/<name>.yaml`: the image build from the owner's example (resolved point b).
- `infra/README.md` (extend M2c-dev's file; keep its dev and Firebase sections): Pi provisioning, Docker install, `gitea.lan` registry trust, router forwarding, HA SSH add-on key setup and which `authorized_keys` variant it supports, the §10.5.3 fallback, deploy and publish, operations of §10.10.
- `infra/RESTORE.md`: §10.9 restore steps 1 to 7, with exact commands.
- On the Pi (not in git): `/opt/kvitt/compose.yaml`, `/opt/kvitt/.env`, `/opt/kvitt/secrets/{fcm-service-account.json, ha_ssh_key, ha_known_hosts, rclone.conf}`, the empty directory `/opt/kvitt/import/`.
- In `infra/secrets/` on the Mac (git-ignored, main checkout): `ha_ssh_key`, `ha_ssh_key.pub`, `ha_known_hosts`, `rclone.conf`, Gitea credentials if any.
## 6. Steps
1. Worktree. From the main checkout `/Users/jhag/projects/kvitt`: `git worktree add .claude/worktrees/agent-m2c-pi -b agent-m2c-pi main`, then in `.claude/worktrees/agent-m2c-pi`: `git reset --hard main`. Copy `infra/secrets/` from the main checkout (`mkdir -p infra/secrets && cp -R /Users/jhag/projects/kvitt/infra/secrets/. infra/secrets/`) and `android/local.properties` (needed by `publish-apk.sh` only). Run `tools/install-git-hooks.sh`.
2. Read §2 in order. Confirm OQ-3 is answered in the start prompt (or SPEC §12); without it only steps 3 to 6 can run; stop before step 7 and report.
3. Versions: resolve from official sources and record: `caddy` image version and digest (Docker Hub official image), `alpine` tag and digest, `openssh-client` and `curl` package versions in that Alpine release, Docker Engine install method for Raspberry Pi OS (docs.docker.com, Debian/Raspberry Pi OS page).
4. Write `infra/compose.yaml`, `infra/Caddyfile`, `infra/cert-sync/**`, the D-002 change in `infra/backup/backup.sh`, the three scripts, `infra/README.md`, `infra/RESTORE.md`. Generate the dedicated cert-sync key in the main checkout: `ssh-keygen -t ed25519 -N '' -C kvitt-cert-sync -f /Users/jhag/projects/kvitt/infra/secrets/ha_ssh_key`.
5. Run `docker compose -f infra/compose.yaml config` (with a `.env` made from `infra/env.example` in a temp location, never committed) and `shellcheck $(find infra -name '*.sh')`.
6. Gitea Actions: write the workflow from the owner's example (H-2); until it exists, build with `infra/build-images.sh` on the Mac (no Gradle build running) and push to `gitea.lan`.
7. Pi provisioning over SSH (needs OQ-3, the allowances of resolved point a, H-0, H-1, H-3, H-4): create `/opt/kvitt/`, `/opt/kvitt/secrets/` and `/opt/kvitt/import/`; copy the secrets with the modes of resolved point (c) and §10.7 step 7; the owner adds `insecure-registries` for `gitea.lan` (H-4).
8. cert-sync: `ssh-keyscan -p <HA port> <HA host> > /Users/jhag/projects/kvitt/infra/secrets/ha_known_hosts`; the owner installs the public key (H-6); test the tar-stream variant first, fall back to plain copy if the add-on refuses `authorized_keys` options, and record the variant.
9. rclone: after H-7, write `/Users/jhag/projects/kvitt/infra/secrets/rclone.conf` with `[gdrive]`, `type = drive`, `scope = drive`, the token; copy to the Pi.
10. Deploy: `infra/deploy.sh <tag>`; on the Pi `docker compose ps` shows all four services healthy.
11. Router: the owner sets the forward (H-5); then check `https://icehack.asuscomm.com:8443/health` from the phone on mobile data (H-8).
12. Backup: `docker compose exec backup /backup.sh` on the Pi; `rclone lsf` shows the new files in the Drive folder; local files are gone afterwards (D-002).
13. Verify Caddy logs contain no `Authorization` header (one authenticated request, then grep `docker compose logs caddy`).
14. Run the gate (§7). Commit on `agent-m2c-pi` (never a secret), `git rebase main`, rerun the gate. Never merge, push or remove the worktree.
## 7. Gate
From SPEC §11.2 (v0.3), verbatim: "`docker compose -f infra/compose.yaml config` succeeds; `shellcheck $(find infra -name '*.sh')` clean; on the Pi: cert-sync fetched a valid certificate, `https://icehack.asuscomm.com:8443/health` answers from the phone on mobile data; one backup file appears in the Drive folder".
Green means: `config` exits 0; shellcheck clean; `docker compose exec cert-sync` shows `/certs/fullchain.pem` with a not-after in the future and `openssl s_client -connect icehack.asuscomm.com:8443` (from the Mac) presents that certificate; the phone on mobile data gets `"status":"ok"` (H-8); `rclone lsf` lists the new `kvitt-*.sqlite.gz` and `pictures-*.tar.gz` in folder `1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV`.
## 8. HUMAN steps
From SPEC §11.2 (v0.3), verbatim: "Answer OQ-3; grant at the start of the sprint the allowances to act on the Kvitt Pi, the Home Assistant Pi, Gitea and Google Drive (D-023); set up key-based SSH from the Mac to the Kvitt Pi; provide the example Gitea Actions workflow and the Gitea credentials; install Docker on the Pi if missing; add `gitea.lan` to `insecure-registries` on the Pi; set the router forward 8443 → Kvitt Pi; add the cert-sync public key to the Home Assistant SSH add-on; `rclone authorize "drive"`".
Each with where it is needed, the exact command, and what blocks it (OQ-1 and OQ-4 are answered; OQ-3 is the only open question):
- H-A, at the start of the sprint (through the orchestrator): grant the allowances to act on the Kvitt Pi, the Home Assistant Pi, Gitea and Google Drive. Blocks steps 7 to 13.
- H-0, before step 7, on the owner's Mac: key-based SSH login to the Kvitt Pi, for example `ssh-copy-id <ssh user>@<Kvitt Pi address>`, then `ssh <ssh user>@<Kvitt Pi address> true` succeeds without a password prompt. Blocked by OQ-3.
- H-1, before step 7: answer OQ-3 (Kvitt Pi LAN address and SSH user; HA Pi LAN address, SSH port and user; certificate directory; which SSH add-on). Blocks H-3, H-5, H-6 and steps 7 to 13.
- H-2, step 6: provide the example Gitea Actions workflow and the Gitea credentials (written by the owner into a file the agent names inside `infra/secrets/`, never pasted into chat). Not blocked by an open question; blocks the Gitea Actions part and any push to `gitea.lan` that needs credentials.
- H-3, step 7, on the Kvitt Pi (only if `docker version` fails there), using the method from docs.docker.com for Raspberry Pi OS, for example: `curl -fsSL https://get.docker.com -o get-docker.sh && sudo sh get-docker.sh && sudo usermod -aG docker <ssh user>`, then log out and in. Blocked by OQ-3.
- H-4, step 7, on the Kvitt Pi (sudo; its own HUMAN step, §10.3, D-023): add `{"insecure-registries": ["gitea.lan"]}` to `/etc/docker/daemon.json` and run `sudo systemctl restart docker`. Blocked by OQ-3.
- H-5, step 11, in the ASUS router web interface: WAN, Virtual Server / Port Forwarding: add a TCP rule, external port 8443, internal IP = the Kvitt Pi's LAN address, internal port 8443; Apply. Blocked by OQ-3.
- H-6, step 8, in Home Assistant: Settings, Add-ons, <the SSH add-on from OQ-3>, Configuration: add the one line from `infra/secrets/ha_ssh_key.pub` (with the `restrict,command=…` prefix the agent gives) to `authorized_keys`; Save; restart the add-on. Blocked by OQ-3.
- H-7, step 9, on the owner's Mac: `rclone authorize "drive"`, sign in with the owner's Google account in the browser, and leave the terminal output for the agent (the agent reads the token from the terminal session or the owner pastes it into a file the agent names inside `infra/secrets/`). Not blocked (OQ-4 answered). Needs `rclone` installed on the Mac.
- H-8, step 11 (needed by the gate): on the phone with Wi-Fi off, open `https://icehack.asuscomm.com:8443/health` in Chrome; answer yes/no: the page shows `"status":"ok"` without a certificate warning. Blocked by H-5.
## 9. Out of scope
- The development stack `infra/dev/**` (M2c-dev; do not break it).
- Firebase project and app creation (done in M2c-dev); only copying the key to the Pi is here.
- Release keystore, signed release, publishing the first real APK, pairing both phones, restore drill, memory check (M10).
- Server or Android code changes; the Splitwise import (M11).
- Changing the Home Assistant Pi beyond adding the one `authorized_keys` line; touching certbot there.
## 10. Report
- Branch `agent-m2c-pi`, head commit hash after rebasing on `main`.
- Gate output lines: `config` exit status, shellcheck result, certificate not-after and issuer, phone health result (H-8), `rclone lsf` lines (file names only).
- Versions chosen (images with digests, packages, Docker install method) with sources.
- The `authorized_keys` variant the HA add-on supports.
- Image tag deployed; `docker compose ps` summary.
- Paths of secrets written (never contents), modes set on the Pi.
- How resolved points (a) to (c) were applied; which allowances the start prompt granted.
- Proposed decision entries (never written to `SPEC.md` or `docs/decisions.md` by this agent) or `TODO(spec)` occurrences.
- HUMAN steps done and outstanding, worded for relay.
- Contract requests, open questions, spec gaps (with section numbers).
## 11. Numbering
Decision ids, spec version and schema version are assigned by the orchestrator in the start prompt; use `TODO(spec)` if none was given.