4b1ae0c473
Reads KVITT_PUBLIC_HOST from infra/pi/.env without sourcing it, refuses a missing file or key, a CHANGE_ME value, anything that is not a host name, and icehack.asuscomm.com (the checks of infra/pi/install.sh), passes it to :app:assembleRelease as -Pkvitt.publicHost and prints the release server in the header and summary. README publish paragraph updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
614 lines
41 KiB
Markdown
614 lines
41 KiB
Markdown
# infra: Kvitt infrastructure
|
|
|
|
This file covers the **development stack on the owner's Mac** (`infra/dev/`, milestone M2c-dev, D-001), the
|
|
**Firebase setup** (SPEC §10.7) and the **Pi stack** (`infra/pi/`, milestone M2c-pi, D-100): a self-contained
|
|
bundle the owner copies to the Kvitt Pi and runs there, behind the owner's Nginx Proxy Manager on the Home Assistant
|
|
Pi (D-120). No agent and no Mac script connects to the Pis; the owner runs every Pi command and every NPM setting
|
|
from the checklist in "Pi: what the owner does".
|
|
|
|
## Layout
|
|
|
|
| Path | What |
|
|
|---|---|
|
|
| `dev/compose.yaml` | Dev stack: `server` (image `kvitt-server:dev`) and the on-demand `backup` job |
|
|
| `dev/.env.example` | Dev settings, no secret values; copy to `dev/.env` (git-ignored) to override |
|
|
| `dev/build.sh` | Builds `kvitt-server:dev` from `server/Dockerfile` and `kvitt-backup:dev` from `backup/Dockerfile` |
|
|
| `dev/up.sh` | Starts the server, waits for `/health`, prints the Mac and phone URLs; `--down` stops the stack |
|
|
| `dev/logs.sh` | Follows the logs (`dev/logs.sh --tail 100 server`) |
|
|
| `dev/backup.sh` | Runs the backup job once and checks the snapshot with `PRAGMA integrity_check` |
|
|
| `dev/copy-google-services.sh` | Copies `secrets/google-services.json` to `android/app/google-services.json` |
|
|
| `dev/lib.sh` | Helpers sourced by the scripts |
|
|
| `dev/secrets/` | Git-ignored: only `fcm-service-account.json`, copied there by `dev/up.sh` and mounted read-only at `/secrets` |
|
|
| `backup/Dockerfile`, `backup/backup.sh`, `backup/healthcheck.sh` | Backup image and job of SPEC §10.9 (shared with the Pi stack); Drive upload and D-002 deletion |
|
|
| `pi/` | **The Pi bundle** (copied to the Kvitt Pi): `compose.yaml`, `.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`, `rclone.conf`, optional `registry.env` (and `ha_ssh_key(.pub)`, unused since D-120). Never committed |
|
|
|
|
All Mac commands below run from the repository root of the main checkout (`/Users/jhag/projects/kvitt`).
|
|
|
|
## Development stack
|
|
|
|
**Choices.** No Caddy and no TLS: debug builds accept `http://` to private LAN addresses (AC-2.8), so the server
|
|
publishes its own port (default 8080) on all of the Mac's interfaces. The server keeps the hardening of SPEC §8.9
|
|
(uid 10001, read-only root, no capabilities, `no-new-privileges`), the 160 MiB limit, `json-file` logging
|
|
(10 MB x 3) and `TZ=Europe/Stockholm`. Data lives in the bind-mounted folder `infra/dev/data/` (git-ignored) so it
|
|
is easy to inspect and reset. Only the FCM key reaches the container (D-117): `up.sh` copies
|
|
`${KVITT_SECRETS_DIR}/fcm-service-account.json` (default `infra/secrets/`) into `infra/dev/secrets/` (git-ignored,
|
|
nothing else in it) and that folder is mounted read-only at `/secrets`, so the server never sees `rclone.conf`,
|
|
the registry credentials or `google-services.json`. If the key is missing the folder is
|
|
empty and the server runs with push disabled (§10.4). Run `up.sh` once before the bare `docker compose` commands
|
|
below (they mount the same folder); after rotating the key, run `up.sh` again.
|
|
|
|
On this Mac `docker` is Podman (`docker compose` runs `podman-compose`); the files work with Docker Desktop as well.
|
|
|
|
### Build
|
|
|
|
```bash
|
|
infra/dev/build.sh # both images
|
|
infra/dev/build.sh server # only kvitt-server:dev; needs server/Dockerfile
|
|
infra/dev/build.sh backup # only kvitt-backup:dev
|
|
```
|
|
|
|
The server build is `docker build --platform linux/arm64 -f server/Dockerfile -t kvitt-server:dev server`
|
|
(override the context with `KVITT_SERVER_BUILD_CONTEXT`). The script refuses to run while a Gradle build holds
|
|
`/tmp/kvitt-gradle.lock` (CLAUDE.md).
|
|
|
|
### Start, stop, logs
|
|
|
|
```bash
|
|
infra/dev/up.sh # start; prints health, the Mac URL and the phone URL
|
|
infra/dev/logs.sh # follow logs (Ctrl-C to stop following)
|
|
infra/dev/up.sh --down # stop and remove the containers (data stays in infra/dev/data/)
|
|
```
|
|
|
|
`KVITT_PUBLIC_URL` (used in invite links) is left empty in `.env.example`; `up.sh` sets it to
|
|
`http://<Mac LAN address>:<port>`. Pin it in `infra/dev/.env` if the Mac's address changes often.
|
|
|
|
### Finding the Mac's LAN address
|
|
|
|
`up.sh` prints it. By hand: `ipconfig getifaddr en0` (Wi-Fi on most Macs), or the interface of the default route:
|
|
`route -n get default | grep interface`, then `ipconfig getifaddr <interface>`. It must be a private address
|
|
(`192.168.*`, `10.*`, `172.16.*` to `172.31.*`) for a debug build to accept `http://` (AC-2.8).
|
|
|
|
The first time a phone connects, macOS may ask whether the container runtime (Podman's `gvproxy`, or Docker)
|
|
may accept incoming network connections: answer Allow.
|
|
|
|
### Bootstrap and pairing a debug build
|
|
|
|
The `bootstrap` and `pair-device` subcommands (SPEC §10.4, AC-1.1, AC-1.6) arrive with the server milestones.
|
|
|
|
```bash
|
|
C="docker compose -f infra/dev/compose.yaml --env-file infra/dev/.env.example" # or --env-file infra/dev/.env
|
|
$C exec server kvitt-server bootstrap # first admin invite: code, link, expiry
|
|
$C exec server kvitt-server pair-device --user <user_id> # extra device invite for an existing user
|
|
$C exec server kvitt-server invite-partner # partner invite by the admin, as E-12 (D-113)
|
|
$C run --rm server migrate # when the server is stopped (entrypoint is kvitt-server)
|
|
```
|
|
|
|
On the phone (home Wi-Fi, debug build `se.hagstedt.kvitt.debug`): open the pairing screen, enter the server URL
|
|
`http://<Mac LAN address>:8080` and the printed code (AC-2.7). Check reachability first by opening
|
|
`http://<Mac LAN address>:8080/health` in Chrome on the phone: it shows `"status":"ok"`.
|
|
|
|
### Backup (local, no Drive upload)
|
|
|
|
```bash
|
|
infra/dev/backup.sh
|
|
```
|
|
|
|
Runs `infra/backup/backup.sh` once in the `backup` image (compose profile `backup`, so `up` never starts it) with
|
|
`RCLONE_FOLDER_ID` empty: `.backup` snapshot, `PRAGMA integrity_check` must print `ok`, then
|
|
`infra/dev/backups/kvitt-<UTC stamp>.sqlite.gz`, `pictures-<stamp>.tar.gz` and `last-success`; the Drive upload
|
|
is skipped with a log line and the files are kept (SPEC §10.9, D-001). The wrapper then checks the new snapshot
|
|
again. The dev env file has no rclone settings (`RCLONE_REMOTE` is no longer in `dev/.env.example`): only the Pi's
|
|
`.env` names the remote. To check by hand: `gunzip -c infra/dev/backups/kvitt-<stamp>.sqlite.gz > /tmp/k.sqlite && sqlite3 /tmp/k.sqlite "PRAGMA integrity_check"`.
|
|
|
|
### Resetting the dev data
|
|
|
|
```bash
|
|
infra/dev/up.sh --down
|
|
rm -rf infra/dev/data # the dev database, pictures and APKs; never the phones' data
|
|
infra/dev/up.sh # fresh database; run bootstrap again
|
|
```
|
|
|
|
Paired debug builds then hold a token the new database does not know: re-pair them (clear the debug app's data on
|
|
the phone, or reinstall the debug build).
|
|
|
|
### Worktrees
|
|
|
|
Agents work in `.claude/worktrees/agent-<id>`, which has no `infra/secrets/` unless copied (CLAUDE.md). Either copy
|
|
it from the main checkout, or set `KVITT_SECRETS_DIR=/Users/jhag/projects/kvitt/infra/secrets` in `infra/dev/.env`.
|
|
`infra/dev/copy-google-services.sh /Users/jhag/projects/kvitt` copies the Firebase config from the main checkout
|
|
into the worktree's `android/app/`.
|
|
|
|
## Firebase (SPEC §10.7)
|
|
|
|
Project `kvitt-1337` (Firebase added to the existing Google Cloud project). Free tier only; nothing billable is
|
|
created by this setup.
|
|
|
|
| Resource | Name |
|
|
|---|---|
|
|
| APIs enabled | `firebase.googleapis.com`, `fcm.googleapis.com`, `cloudresourcemanager.googleapis.com`, `iam.googleapis.com` |
|
|
| Android app | "Kvitt", package `se.hagstedt.kvitt` |
|
|
| Android app | "Kvitt debug", package `se.hagstedt.kvitt.debug` |
|
|
| Service account | `kvitt-fcm@kvitt-1337.iam.gserviceaccount.com` ("Kvitt FCM sender"), role `roles/firebasecloudmessaging.admin` (the narrowest predefined role holding `cloudmessaging.messages.create`) |
|
|
| Key | `infra/secrets/fcm-service-account.json` (git-ignored, mode 600) |
|
|
| Config | `infra/secrets/google-services.json`, one file with both Android clients (git-ignored) |
|
|
|
|
Put the config where Gradle reads it: `infra/dev/copy-google-services.sh`.
|
|
|
|
The setup used only `gcloud` and the Firebase Management REST API. To repeat or inspect it:
|
|
|
|
```bash
|
|
T=$(gcloud auth print-access-token)
|
|
H=(-H "Authorization: Bearer $T" -H "x-goog-user-project: kvitt-1337")
|
|
API=https://firebase.googleapis.com/v1beta1/projects/kvitt-1337
|
|
curl -sS "${H[@]}" "$API/androidApps" # list the apps (ids, packages)
|
|
curl -sS "${H[@]}" "$API/androidApps/<appId>/config" # configFileContents is base64 google-services.json
|
|
gcloud iam service-accounts keys list --iam-account kvitt-fcm@kvitt-1337.iam.gserviceaccount.com --project kvitt-1337
|
|
```
|
|
|
|
Rotating the key: create a new one with
|
|
`gcloud iam service-accounts keys create infra/secrets/fcm-service-account.json --iam-account kvitt-fcm@kvitt-1337.iam.gserviceaccount.com --project kvitt-1337`,
|
|
restart the server, then delete the old key id with `gcloud iam service-accounts keys delete <key id> ...`.
|
|
Copying the key to the Pi (§10.7 step 7, mode 0440, group 10001) is done by `pi/install.sh` ("Pi: what the owner does", step 5).
|
|
|
|
## 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` and `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/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`, `.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 | Nginx Proxy Manager (NPM): a proxy host for `<KVITT_PUBLIC_HOST>` with its own Let's Encrypt certificate, TLS on 443, plain HTTP to `<KVITT_PI_HOST>:8080` over the LAN (D-120) | The owner, in NPM's web UI (step 6) |
|
|
| Router (ASUS) | External TCP 8443 to the Home Assistant Pi's 443 (NPM); external 80 already reaches NPM for Let's Encrypt | The owner (step 7) |
|
|
|
|
Stack (`pi/compose.yaml`, project name `kvitt`): `server` (publishes `8080:8080` on the Pi's LAN interfaces, the
|
|
only published port; `KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST}:8443`, `KVITT_TRUSTED_PROXY=${HA_HOST}`) and
|
|
`backup`; volumes `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-backup:${KVITT_TAG}` with
|
|
`KVITT_REGISTRY=gitea.lan/jacobh2` (D-104; the names the workflow pushes). Caddy and cert-sync were removed by
|
|
D-120: TLS terminates in NPM, which already runs on the Home Assistant Pi.
|
|
|
|
**Client IPs (SPEC §8.5, §8.6).** NPM's request to the server carries `X-Forwarded-For: <what the client sent>,
|
|
<client address>`; the server takes the last entry only when the TCP peer is `KVITT_TRUSTED_PROXY`, so `HA_HOST`
|
|
must be NPM's LAN IPv4 address (the server accepts an IP literal or a resolvable name, not a CIDR; `install.sh`
|
|
requires the address because the container cannot resolve an mDNS name such as `homeassistant.local`). The
|
|
router's forward keeps the internet client's source address, and Docker's published port keeps NPM's (DNAT, no
|
|
SNAT, for traffic arriving on the LAN interface), so the per-IP limits of D-032, D-115 and D-119 see real
|
|
addresses. The server does not use `X-Forwarded-Proto` or `Host`: links are built from `KVITT_PUBLIC_URL` (E-12).
|
|
Anyone on the home LAN can also reach `http://<KVITT_PI_HOST>:8080` directly; such requests count against their own
|
|
address, and only `HA_HOST` may set `X-Forwarded-For`.
|
|
|
|
### Versions (resolved 2026-10-18)
|
|
|
|
| Component | Version | Source |
|
|
|---|---|---|
|
|
| Nginx Proxy Manager | The owner's existing install on the Home Assistant Pi; the block of step 6 was written against NPM v2.16.0 (2026-09-24) templates | github.com/NginxProxyManager/nginx-proxy-manager, `backend/templates/proxy_host.conf` |
|
|
| Alpine (backup) | `alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6` | Docker Hub official image; `3.24` = `latest` |
|
|
| 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).** The backup sidecar runs as a non-root user (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 schedule is unchanged (`30 3 * * *`).
|
|
|
|
### 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). 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) with a one-off root container (`docker compose run --user 0:0 --cap-add CHOWN`).
|
|
|
|
### What was checked on the Mac
|
|
|
|
2026-10-19 (D-120, Nginx Proxy Manager instead of Caddy and cert-sync):
|
|
|
|
* `shellcheck -S style` on every script under `infra/` and `tools/`: clean.
|
|
* `podman-compose -f infra/pi/compose.yaml --env-file <filled sample> config` and the same for `infra/dev/`: exit 0.
|
|
* `nginx -t` (nginx:alpine 1.31.7) of the step 6 block inside a minimal server block, and inside a copy of NPM
|
|
v2.16.0's `nginx.conf` with the proxy host rendered as NPM's template renders it for the step 6 settings (Block
|
|
Common Exploits, Force SSL, HTTP/2, HSTS, the block; NPM's regex `^(?:.*;)?\s*?location\s*?\/\s*?{` finds the
|
|
block's `location /`, so NPM leaves its own out): both successful.
|
|
* Rehearsal with Podman: the server image built from `server/Dockerfile` at this commit (arm64, hardened as on the
|
|
Pi) behind that nginx with a self-signed certificate, `KVITT_TRUSTED_PROXY` = the nginx container's address,
|
|
clients on a non-private test subnet (so NPM's `set_real_ip_from` does not apply, as for internet clients).
|
|
21 checks passed: `GET /health` 200 over HTTP/2 with `Content-Security-Policy`, `X-Content-Type-Options: nosniff`,
|
|
`X-Frame-Options: DENY`, `Referrer-Policy: no-referrer` and `Strict-Transport-Security` once each and no
|
|
`X-Served-By`; the landing page `/i/<code>?from=<name>` 200 with one `Referrer-Policy`; `http://` answered 301 to
|
|
`https://`; a 7 MB body refused by nginx (413), a 2 MB body answered by the server (`payload_too_large`); one
|
|
client limited after 60 requests a minute while a second client was still served, a forged `X-Forwarded-For`
|
|
did not escape the limit, and with `KVITT_TRUSTED_PROXY` set to another address both clients shared one bucket
|
|
(so the limits key on the forwarded address); after `/i/`, `//i/` and `/%69/` requests, and a 502 on `/i/` with
|
|
the server stopped, the invite code, the name and `/i/` occurred 0 times in nginx's access and error logs, while
|
|
other paths were logged with their query strings and a 502 on `/health` was in the error log.
|
|
* Not checked (needs the real hosts): NPM's own UI and Let's Encrypt for `<KVITT_PUBLIC_HOST>`, the router
|
|
forward, the Gitea workflow, pulls from `gitea.lan`, Drive.
|
|
|
|
2026-10-18 (M2c-pi, D-104 to D-106, D-117): 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. (The Caddy and cert-sync rehearsals of that day concern the removed stack.)
|
|
|
|
### Gitea Actions workflow (`.gitea/workflows/build-images.yaml`)
|
|
|
|
Same setup as the owner's working example: `runs-on: host`, `sh` steps, `apk add git docker-cli docker-cli-buildx`,
|
|
`docker login gitea.lan` with the repository secret `kvitt_cicd` (created by the owner) and `${{ github.actor }}`,
|
|
a buildx builder whose `buildkitd.toml` sets `http = true` for `gitea.lan` (created once and reused, so its cache
|
|
survives), `actions/checkout@v4` with `set-safe-directory` and `fetch-depth: 0`, the version from the newest
|
|
`v1.0.*` git tag plus one, and the git tag pushed after the build. That version is what `deploy.sh` takes.
|
|
The token is interpolated into the login step's `run` line (`printf '%s' "${{ secrets.kvitt_cicd }}" | docker login
|
|
...`), the owner's proven form: the first run on the runner (2026-10-19) got an empty token from the env-based form
|
|
(`env: REGISTRY_TOKEN: ${{ secrets.kvitt_cicd }}`) on act_runner 0.6.1 and failed with "password is empty";
|
|
act_runner masks the secret in the log. That first run got as far as the login (container, `apk add`, checkout
|
|
all worked). `kvitt_cicd` needs only the `write:package` scope: the tag push uses the credentials `actions/checkout` stored (the job's own token).
|
|
If the "Tag and Push Version" step fails with a permission error, the job token cannot push tags on this Gitea;
|
|
then add `write:repository` to the token (owner decision below). The step "Print image digests" prints each
|
|
image's `sha256:` digest, which must match what `deploy.sh` prints on the Pi (D-117).
|
|
|
|
The builder's `buildkitd.toml` is read only when the builder is created. After changing the registry setting in the
|
|
workflow, remove the builder once on the runner Pi: `docker buildx rm local-http-builder` (this also drops its
|
|
build cache, so the next server build is a full one).
|
|
|
|
**Risk: the runner is a Raspberry Pi 3 with 1 GB RAM.** The server image compiles about 232 Rust crates,
|
|
aws-lc-sys's C code among them. The workflow passes `--build-arg CARGO_BUILD_JOBS=1`; it takes effect only once
|
|
`server/Dockerfile` declares `ARG CARGO_BUILD_JOBS` in its build stage (requested from the server's owner; until
|
|
then buildx warns about an unused build argument and cargo uses all four cores, which may run out of memory). The
|
|
Dockerfile already keeps the cargo registry and target directory in BuildKit cache mounts, so only the first build
|
|
is a full one. The first build's duration on the Pi is unknown (a cold arm64 build takes 48 s on the M4 Mac). If
|
|
it fails for memory, add swap on the runner Pi, or build on the Mac with `infra/build-images.sh`.
|
|
|
|
### Mac fallback: `infra/build-images.sh [tag]`
|
|
|
|
Builds the same two 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 script
|
|
reads `KVITT_PUBLIC_HOST` from `infra/pi/.env` (refusing a missing file or key or a `CHANGE_ME` value) and passes it as
|
|
`-Pkvitt.publicHost`, the app's release server and invite-link host (D-121). 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_PUBLIC_HOST` | Kvitt's own public host name (D-120), for example a DuckDNS name or a name in your own domain, pointing at the home's public address like `icehack.asuscomm.com` does. Not `icehack.asuscomm.com` itself: that name is NPM's proxy host for Home Assistant, and NPM chooses the proxy host by name, not by port | Your choice; tell the orchestrator the name (it goes into the app and `docs/HANDOFF.md`) |
|
|
| `KVITT_PI_HOST` | LAN IPv4 address of the Kvitt Pi (give it a static DHCP lease); also NPM's Forward Hostname / IP | 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 IPv4 address of the Home Assistant Pi, where NPM runs (static DHCP lease); the server trusts `X-Forwarded-For` only from it. An address such as `192.168.1.20`, not `homeassistant.local` | Router client list, or Home Assistant, Settings, System, Network |
|
|
| `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. 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/rclone.conf infra/pi/secrets/
|
|
```
|
|
|
|
Only these two secret files go to the Pi. `infra/secrets/ha_ssh_key` and `ha_ssh_key.pub` are not used since
|
|
D-120: do not copy them (and no SSH add-on or `authorized_keys` entry on the Home Assistant Pi is needed).
|
|
`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.
|
|
|
|
### 4. Copy the bundle to the Pi
|
|
|
|
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/
|
|
```
|
|
|
|
### 5. Install and start
|
|
|
|
On the Pi:
|
|
|
|
```bash
|
|
cd ~/kvitt-pi && ./install.sh
|
|
```
|
|
|
|
It checks the `.env` (`KVITT_PUBLIC_HOST` a host name, `HA_HOST` an IPv4 address), creates
|
|
`/opt/kvitt/{secrets,import,apk}`, installs the two secret files (`fcm-service-account.json` 0440 root:10001;
|
|
`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`, `.env` (0600) and the scripts to `/opt/kvitt`, pulls,
|
|
starts and waits. If an earlier bundle with Caddy and cert-sync was installed, it also removes its leftovers
|
|
(`/opt/kvitt/Caddyfile`, `secrets/ha_ssh_key`, `secrets/ha_known_hosts`, the containers and the volumes
|
|
`kvitt_certs` and `kvitt_caddy_data`) and says so. Expected end: `server: healthy`, a `docker compose ps` table with
|
|
`server` (ports `0.0.0.0:8080->8080/tcp`) and `backup`, the line `GET http://localhost:8080/health: {"status":"ok",...}`,
|
|
then "Not reachable through Nginx Proxy Manager yet" (expected on the first install, before step 6) or
|
|
`GET https://<KVITT_PUBLIC_HOST>:443/health (via NPM at <HA_HOST>): {"status":"ok",...}`, then "Kvitt is running.".
|
|
Then `rm -r ~/kvitt-pi/secrets` (the script reminds you). Running `install.sh` again is safe. Re-running
|
|
`install.sh` keeps the `KVITT_TAG` that `deploy.sh` set; change the tag only with `deploy.sh <tag>`.
|
|
|
|
### 6. Nginx Proxy Manager on the Home Assistant Pi
|
|
|
|
NPM terminates TLS for Kvitt with its own Let's Encrypt certificate for `<KVITT_PUBLIC_HOST>` and forwards plain
|
|
HTTP over the home LAN to the Kvitt Pi's port 8080 (plain HTTP between the two Pis is accepted, D-120). First make
|
|
sure `<KVITT_PUBLIC_HOST>` points at the home's public address (your DNS or DuckDNS page shows the same address as
|
|
`icehack.asuscomm.com`). Let's Encrypt's HTTP-01 challenge reaches NPM through external port 80, which already
|
|
works: it is how NPM got the certificate for Home Assistant. Serving Kvitt under a path of `icehack.asuscomm.com`
|
|
(for example `/kvitt/`) is not supported.
|
|
|
|
Open NPM's web UI (Home Assistant, Settings, Add-ons, Nginx Proxy Manager, Open Web UI), then Hosts, Proxy Hosts,
|
|
Add Proxy Host.
|
|
|
|
Details tab:
|
|
|
|
| Field | Value |
|
|
|---|---|
|
|
| Domain Names | `<KVITT_PUBLIC_HOST>` |
|
|
| Scheme | `http` |
|
|
| Forward Hostname / IP | `<KVITT_PI_HOST>` (the IPv4 address) |
|
|
| Forward Port | `8080` |
|
|
| Cache Assets | off |
|
|
| Block Common Exploits | on |
|
|
| Websockets Support | off |
|
|
| Access List | Publicly Accessible |
|
|
|
|
Custom Locations tab: leave empty.
|
|
|
|
SSL tab:
|
|
|
|
| Field | Value |
|
|
|---|---|
|
|
| SSL Certificate | Request a new Certificate (Let's Encrypt, HTTP challenge: Use DNS Challenge off); Email address: yours; if a Let's Encrypt Terms of Service checkbox is shown, tick it |
|
|
| Force SSL | on |
|
|
| HTTP/2 Support | on |
|
|
| HSTS Enabled | on |
|
|
| HSTS Sub-domains | off |
|
|
| Trust Upstream Forwarded Proto Headers | off |
|
|
|
|
Advanced tab, Custom Nginx Configuration: paste this block exactly (all of it, comments included):
|
|
|
|
```nginx
|
|
# Kvitt (SPEC §10.5.2, D-120). NPM, Proxy Host <KVITT_PUBLIC_HOST>, Advanced tab, Custom Nginx Configuration.
|
|
# Server body: 1 MiB, PUT /me/picture 5 MiB (SPEC §8.6); the server answers anything larger with its own 413.
|
|
client_max_body_size 6m;
|
|
# No long-poll in the API (SPEC §14.3); 60 s between two reads from the server.
|
|
proxy_read_timeout 60s;
|
|
# Response headers (D-117). They live at server level, so no location below may use add_header (nginx would then
|
|
# drop all of these in it); NPM's HSTS line (SSL tab) sits at the same level. The server's own copy is hidden, so
|
|
# each header appears once (the landing page sends Referrer-Policy itself).
|
|
proxy_hide_header Content-Security-Policy;
|
|
proxy_hide_header X-Content-Type-Options;
|
|
proxy_hide_header X-Frame-Options;
|
|
proxy_hide_header Referrer-Policy;
|
|
add_header Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always;
|
|
add_header X-Content-Type-Options nosniff always;
|
|
add_header X-Frame-Options DENY always;
|
|
add_header Referrer-Policy no-referrer always;
|
|
# Replaces NPM's own "location /" (NPM leaves it out when this field has one), whose add_header X-Served-By would
|
|
# drop the headers above. $forward_scheme, $server and $port are NPM's Details tab values.
|
|
location / {
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_pass $forward_scheme://$server:$port$request_uri;
|
|
}
|
|
# Invite links /i/<code>?from=<name>: neither the code nor the inviter's name may reach a log (SPEC §8.7). NPM's
|
|
# access log format writes the whole $request_uri, query string included, so this path is not logged at all, and
|
|
# its error log (which would carry the request line on a 502) goes nowhere.
|
|
location ^~ /i/ {
|
|
access_log off;
|
|
error_log /dev/null crit;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_pass $forward_scheme://$server:$port$request_uri;
|
|
}
|
|
```
|
|
|
|
Save. The new row under Proxy Hosts shows the status Online and an SSL entry for Let's Encrypt (yes/no). Then on
|
|
the Kvitt Pi:
|
|
|
|
```bash
|
|
curl -fsS --resolve <KVITT_PUBLIC_HOST>:443:<HA_HOST> https://<KVITT_PUBLIC_HOST>/health
|
|
```
|
|
|
|
It prints `{"status":"ok",...}` (no `-k`: this also checks the certificate). `deploy.sh` and `install.sh` run the
|
|
same check.
|
|
|
|
**Why the block has its own `location /`.** nginx drops every `add_header` inherited from the server level in a
|
|
location that has an `add_header` of its own, and NPM's default `location /` has one (`X-Served-By`), which would
|
|
silently drop the four security headers (D-117). NPM leaves its default location out when the Custom Nginx
|
|
Configuration contains `location /`, so the block brings its own without `add_header`; NPM's HSTS line from the SSL
|
|
tab stays at server level and still applies. The block's `proxy_pass` uses NPM's Forward Hostname / IP and Forward
|
|
Port, so change those on the Details tab as usual. If a later NPM release changes its proxy host template, check
|
|
the headers again (Operations, "Headers through NPM").
|
|
|
|
**What NPM logs (SPEC §8.7).** NPM writes one access log line per request to `/data/logs/proxy-host-<id>_access.log`
|
|
inside NPM, in its `proxy` format: time, cache and upstream status, status, method, scheme, host,
|
|
`"$request_uri"` (the path **with** its query string), `[Client <address>]`, length, gzip ratio,
|
|
`[Sent-to <KVITT_PI_HOST>]`, user agent and referer. It logs no request headers, so the `Authorization` token is never
|
|
logged. Query strings are logged for every path except `/i/`; the other query parameters of the API are `since` and
|
|
`limit` (`GET /sync`) and `size` and `v` (pictures), which hold no personal content. `/i/<code>?from=<name>` is not
|
|
logged at all: the block turns the access log off for `/i/` and sends its error log to `/dev/null`, so neither the
|
|
invite code nor the inviter's name reaches a log. Errors on other paths (for example a 502 while the server is down)
|
|
go to `proxy-host-<id>_error.log` with the request line.
|
|
|
|
**HSTS.** HSTS tells a browser to use only HTTPS for `<KVITT_PUBLIC_HOST>`, on every port, for two years
|
|
(`max-age=63072000; preload`, NPM's value), and to refuse certificate warnings for it. Because the name is Kvitt's
|
|
own, nothing else is affected; but if you ever serve anything else under `<KVITT_PUBLIC_HOST>` (any port), it must be
|
|
HTTPS with a valid certificate too, and turning HSTS off later does not clear browsers that already stored it. The
|
|
app itself does not rely on HSTS (release builds allow no cleartext, SPEC §8.5); it protects the landing page in a
|
|
browser.
|
|
|
|
**Accepted: NPM trusts `X-Real-IP` from the LAN.** NPM's `nginx.conf` takes the client address from `X-Real-IP` when
|
|
the connection comes from 10/8, 172.16/12, 192.168/16 (and the CDN ranges it downloads), so a device on the home
|
|
LAN can choose its own client address for the rate limits. Internet clients arrive with their public address (the
|
|
router's forward keeps the source address), so their `X-Real-IP` is ignored.
|
|
|
|
### 7. Router forward
|
|
|
|
ASUS router web UI, WAN, Virtual Server / Port Forwarding: add a rule: Service Name `Kvitt`, Protocol TCP, External
|
|
Port `8443`, Internal Port `443` (NPM's HTTPS port; if the add-on's Network settings map `443/tcp` to another host
|
|
port, use that one), Internal IP Address `<HA_HOST>`; Apply. No rule forwards to the Kvitt Pi (remove one to
|
|
`<KVITT_PI_HOST>` if you made it for the Caddy version). Then on the phone with Wi-Fi off, open
|
|
`https://<KVITT_PUBLIC_HOST>: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
|
|
```
|
|
|
|
The invite link starts with `https://<KVITT_PUBLIC_HOST>:8443/i/`. The release app opens such links and connects to
|
|
that host only once an Android build carries the new host name (SPEC §8.5; until then the app knows
|
|
`icehack.asuscomm.com`): tell the orchestrator before pairing a release build.
|
|
|
|
### 9. Deploying a new version
|
|
|
|
After the Gitea workflow pushed `v1.0.N` (or `infra/build-images.sh` printed a tag), on the Pi:
|
|
|
|
```bash
|
|
/opt/kvitt/deploy.sh v1.0.N
|
|
```
|
|
|
|
It backs up first (Drive confirmed) on every deploy, because any tag may carry a migration (SPEC §10.10), sets
|
|
`KVITT_TAG`, pulls, prints the `sha256:` digest of every image and appends it to `/opt/kvitt/deployed-digests`,
|
|
starts, waits for `server: healthy` and prints `/health` on port 8080 and through NPM (a failure through NPM is a
|
|
WARNING: the deploy succeeded, but Kvitt is not reachable from outside). The images come over plain HTTP from
|
|
`gitea.lan` (D-001), so compare the printed digests with the workflow's "Print image digests" step for that run; a
|
|
line starting `WARNING:` about a digest means a tag that was deployed before now points at a different image: do not
|
|
continue, roll back and find out why (D-117). If the server does not come up:
|
|
`/opt/kvitt/deploy.sh "$(cat /opt/kvitt/previous-tag)" --skip-backup`; if a migration had partially applied,
|
|
restore the pre-deploy backup (`infra/RESTORE.md`). A newer bundle with a changed `compose.yaml` is installed by
|
|
copying the bundle again (step 4, without the secrets) and running `./install.sh`.
|
|
|
|
### Fallback (SPEC §10.5.3): when NPM is down
|
|
|
|
* NPM or the Home Assistant Pi down means Kvitt is unreachable from outside, and also from home over
|
|
`https://<KVITT_PUBLIC_HOST>:8443` (that path goes through NPM too). The server on the Kvitt Pi keeps running and
|
|
nothing there needs to change. The phones keep working on their full local copy and sync when NPM is back.
|
|
* On the home Wi-Fi only **debug builds** can reach the server directly, at `http://<KVITT_PI_HOST>:8080` (AC-2.8
|
|
allows `http://` to private LAN addresses in debug builds only). **Release builds require HTTPS with a publicly
|
|
trusted certificate (SPEC §8.5) and cannot use that address: there is no LAN fallback for the release app.** A
|
|
debug build is a separate app (`se.hagstedt.kvitt.debug`) with its own pairing and data, not a stand-in for the
|
|
release app.
|
|
* If NPM cannot renew the Let's Encrypt certificate: fix it in NPM (SSL Certificates, the certificate, Renew Now;
|
|
port 80 must reach NPM). The pinned-root switch of the app (`kvitt.tls.pinnedRoot`, D-082) still exists for a
|
|
self-made root whose certificate would be uploaded to NPM as a Custom certificate; it is not prepared: tell the
|
|
orchestrator.
|
|
|
|
### Operations (SPEC §10.10), on the Pi in `/opt/kvitt`
|
|
|
|
| Task | Command |
|
|
|---|---|
|
|
| Status | `docker compose ps` |
|
|
| Logs | `docker compose logs --tail 100 <server or backup>`; NPM's own log files are inside NPM on the Home Assistant Pi |
|
|
| 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` |
|
|
| Health through NPM | `curl -fsS --resolve <KVITT_PUBLIC_HOST>:443:<HA_HOST> https://<KVITT_PUBLIC_HOST>/health` |
|
|
| Headers through NPM | `curl -sS -D - -o /dev/null --resolve <KVITT_PUBLIC_HOST>:443:<HA_HOST> https://<KVITT_PUBLIC_HOST>/health` lists `content-security-policy`, `x-content-type-options`, `x-frame-options`, `referrer-policy`, `strict-transport-security` once each and no `x-served-by` |
|
|
| Certificate expiry and issuer | `echo \| openssl s_client -connect <HA_HOST>:443 -servername <KVITT_PUBLIC_HOST> 2>/dev/null \| openssl x509 -noout -enddate -issuer` (NPM renews it; NPM, SSL Certificates shows the same) |
|
|
| 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 10 MB each since R8, 5 kept |
|
|
| Image digests deployed | `tail -n 8 /opt/kvitt/deployed-digests` |
|
|
| Client IPs are real (once, after the router forward) | Open `https://<KVITT_PUBLIC_HOST>:8443/health` on the phone with Wi-Fi off, then within a minute on the Kvitt Pi `sudo apt install -y conntrack` (once) and `sudo conntrack -L -p tcp --orig-port-dst 8080`: in every line the first `src=` is `<HA_HOST>` and the second half (from the second `src=`) has `dst=<HA_HOST>`, no `172.*` there, so the server sees NPM's address and trusts its `X-Forwarded-For`. If the second half shows a `172.*` address instead, every internet client shares one per-IP limit (SPEC §8.6): tell the orchestrator |
|
|
| Restore | `infra/RESTORE.md` |
|
|
|
|
**APK downloads (security review L-3, D-119).** `GET /apk/files/<name>` is public (the landing page links it) and
|
|
about 11 MB per response; the server allows 10 per minute per client IP. If one address still fetches
|
|
`/apk/files/` over and over (NPM's access log), remove the router forward (step 7) until it stops.
|
|
|
|
### Owner decisions (open)
|
|
|
|
Backup encryption (security review M-1) is decided: no rclone `crypt`, the `drive` scope stays (SPEC §8.7, 2026-10-19).
|
|
|
|
1. **Gitea token scope.** Does the workflow's tag push work with `kvitt_cicd` limited to `write:package`? If not,
|
|
is adding `write:repository` acceptable?
|
|
2. **Kvitt's host name (D-120).** Which name is `<KVITT_PUBLIC_HOST>`? It goes into the release app and
|
|
`docs/HANDOFF.md`.
|
|
|
|
### Troubleshooting
|
|
|
|
* Step 6 check or `deploy.sh` says /health through NPM failed: "Could not resolve host" or "SSL certificate problem":
|
|
the proxy host has no certificate for `<KVITT_PUBLIC_HOST>` yet (SSL tab) or a different name; a `502 Bad Gateway`:
|
|
NPM cannot reach `<KVITT_PI_HOST>:8080` (wrong Forward Hostname / IP or Forward Port, or the server is down:
|
|
`docker compose ps`); the Home Assistant page instead of `{"status":"ok"}`: Domain Names does not match the name
|
|
you requested (NPM fell back to another host).
|
|
* "Request a new Certificate" fails in NPM: `<KVITT_PUBLIC_HOST>` does not point at the home's public address yet,
|
|
or external port 80 does not reach NPM.
|
|
* 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`.
|