Pi bundle: Kvitt on port 443 through NPM (D-124)
Build Kvitt images / build (push) Successful in 2h51m36s

KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST} with no port; the health check
through NPM prints the port-less URL; publish-apk.sh's summary drops :8443.
README: no new router rule (the existing external 443 forward to the Home
Assistant Pi serves Kvitt, NPM selects by name), NPM listens on 443, the
fallback, operations and APK-abuse notes, and every :8443 URL.

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-19 20:22:11 +02:00
parent a86ad241f1
commit 4852803cdb
5 changed files with 32 additions and 27 deletions
+21 -17
View File
@@ -165,10 +165,10 @@ Copying the key to the Pi (§10.7 step 7, mode 0440, group 10001) is done by `pi
| 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) | | 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 | | 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) | | 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) | | Router (ASUS) | No new rule: the existing external TCP 443 forward to the Home Assistant Pi (NPM, for Home Assistant) serves Kvitt too, NPM picks the proxy host by name; external 80 already reaches NPM for Let's Encrypt; nothing is forwarded to the Kvitt Pi (D-124) | The owner checks it (step 7) |
Stack (`pi/compose.yaml`, project name `kvitt`): `server` (publishes `8080:8080` on the Pi's LAN interfaces, the 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 only published port; `KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST}` (default port 443, D-124), `KVITT_TRUSTED_PROXY=${HA_HOST}`) and
`backup`; volumes `data`, `backups`; network `kvitt`; every service `restart: unless-stopped`, json-file logs `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 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 `KVITT_REGISTRY=gitea.lan/jacobh2` (D-104; the names the workflow pushes). Caddy and cert-sync were removed by
@@ -385,7 +385,7 @@ starts and waits. If an earlier bundle with Caddy and cert-sync was installed, i
`kvitt_certs` and `kvitt_caddy_data`) and says so. Expected end: `server: healthy`, a `docker compose ps` table with `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",...}`, `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 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.". `GET https://<KVITT_PUBLIC_HOST>/health (via NPM at <HA_HOST>:443): {"status":"ok",...}`, then "Kvitt is running.".
Then `rm -r ~/kvitt-pi/secrets` (the script reminds you). Running `install.sh` again is safe. Re-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>`. `install.sh` keeps the `KVITT_TAG` that `deploy.sh` set; change the tag only with `deploy.sh <tag>`.
@@ -396,7 +396,8 @@ HTTP over the home LAN to the Kvitt Pi's port 8080 (plain HTTP between the two P
sure `<KVITT_PUBLIC_HOST>` points at the home's public address (your DNS or DuckDNS page shows the same address as 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 `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` 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. (for example `/kvitt/`) is not supported. NPM listens on the default HTTPS port 443 for every proxy host and
picks the host by the name the client asks for, so Kvitt and Home Assistant share that port (D-124).
Open NPM's web UI (Home Assistant, Settings, Add-ons, Nginx Proxy Manager, Open Web UI), then Hosts, Proxy Hosts, Open NPM's web UI (Home Assistant, Settings, Add-ons, Nginx Proxy Manager, Open Web UI), then Hosts, Proxy Hosts,
Add Proxy Host. Add Proxy Host.
@@ -509,13 +510,14 @@ the connection comes from 10/8, 172.16/12, 192.168/16 (and the CDN ranges it dow
LAN can choose its own client address for the rate limits. Internet clients arrive with their public address (the 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. router's forward keeps the source address), so their `X-Real-IP` is ignored.
### 7. Router forward ### 7. Router: no new rule
ASUS router web UI, WAN, Virtual Server / Port Forwarding: add a rule: Service Name `Kvitt`, Protocol TCP, External No new rule (D-124): external TCP 443 already reaches the Home Assistant Pi (NPM) for Home Assistant, and NPM picks
Port `8443`, Internal Port `443` (NPM's HTTPS port; if the add-on's Network settings map `443/tcp` to another host Kvitt's proxy host by name on the same port. Verify it exists: ASUS router web UI, WAN, Virtual Server / Port
port, use that one), Internal IP Address `<HA_HOST>`; Apply. No rule forwards to the Kvitt Pi (remove one to Forwarding: the list has a TCP rule with External Port `443` and Internal IP Address `<HA_HOST>` (yes/no). Nothing is
`<KVITT_PI_HOST>` if you made it for the Caddy version). Then on the phone with Wi-Fi off, open forwarded to the Kvitt Pi: remove a rule to `<KVITT_PI_HOST>` if you made one for the Caddy version, and a rule with
`https://<KVITT_PUBLIC_HOST>:8443/health` in Chrome: it shows `"status":"ok"` without a certificate warning (yes/no). External Port `8443` if you made one for the earlier NPM version (D-120). Then on the phone with Wi-Fi off, open
`https://<KVITT_PUBLIC_HOST>/health` in Chrome: it shows `"status":"ok"` without a certificate warning (yes/no).
### 8. First backup and bootstrap ### 8. First backup and bootstrap
@@ -526,9 +528,10 @@ On the Pi:
/opt/kvitt/bootstrap.sh # first install only: prints the admin invite code, link and expiry /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 The invite link starts with `https://<KVITT_PUBLIC_HOST>/i/` (no port). 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 that host only once its Android build carries the host name (`kvitt.publicHost`, D-121) and the default port 443
`icehack.asuscomm.com`): tell the orchestrator before pairing a release build. (D-124; builds made for port 8443 do not match a link without a port): tell the orchestrator before pairing a
release build.
### 9. Deploying a new version ### 9. Deploying a new version
@@ -552,8 +555,8 @@ copying the bundle again (step 4, without the secrets) and running `./install.sh
### Fallback (SPEC §10.5.3): when NPM is down ### 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 * 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 `https://<KVITT_PUBLIC_HOST>` (that path goes through NPM too); Home Assistant is then unreachable from outside as
nothing there needs to change. The phones keep working on their full local copy and sync when NPM is back. well (same NPM, same port 443). 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 * 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 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 trusted certificate (SPEC §8.5) and cannot use that address: there is no LAN fallback for the release app.** A
@@ -580,12 +583,13 @@ copying the bundle again (step 4, without the secrets) and running `./install.sh
| Memory | `docker stats --no-stream` (server RSS under 100 MB with both phones syncing) | | 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 | | 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` | | 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 | | Client IPs are real (once, after step 7) | Open `https://<KVITT_PUBLIC_HOST>/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` | | Restore | `infra/RESTORE.md` |
**APK downloads (security review L-3, D-119).** `GET /apk/files/<name>` is public (the landing page links it) and **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 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. `/apk/files/` over and over (NPM's access log), disable Kvitt's proxy host in NPM (Hosts, Proxy Hosts, the row's
menu, Disable) until it stops; the router's 443 forward also serves Home Assistant, so leave it in place.
### Owner decisions (open) ### Owner decisions (open)
+3 -3
View File
@@ -10,9 +10,9 @@ KVITT_REGISTRY=gitea.lan/jacobh2
# or the short git SHA that infra/build-images.sh printed. # or the short git SHA that infra/build-images.sh printed.
KVITT_TAG=CHANGE_ME_TAG KVITT_TAG=CHANGE_ME_TAG
# Kvitt's own public host name (D-120), owner-chosen: a domain or DuckDNS name pointing at the home's public address. # Kvitt's own public host name (D-120), owner-chosen: a domain or DuckDNS name pointing at the home's public address.
# NPM holds its Let's Encrypt certificate; the router forwards external 8443 to NPM's 443. Invite links and the # NPM holds its Let's Encrypt certificate and serves it on the default HTTPS port 443, which the router already
# landing page use https://<KVITT_PUBLIC_HOST>:8443 (KVITT_PUBLIC_URL). Not icehack.asuscomm.com: that name is # forwards to NPM for Home Assistant (D-124). Invite links and the landing page use https://<KVITT_PUBLIC_HOST>
# NPM's proxy host for Home Assistant. # (KVITT_PUBLIC_URL, no port). Not icehack.asuscomm.com: that name is NPM's proxy host for Home Assistant.
KVITT_PUBLIC_HOST=CHANGE_ME_KVITT_PUBLIC_HOST KVITT_PUBLIC_HOST=CHANGE_ME_KVITT_PUBLIC_HOST
# LAN IPv4 address of the Home Assistant Pi, where Nginx Proxy Manager terminates TLS and forwards to this Pi's port # LAN IPv4 address of the Home Assistant Pi, where Nginx Proxy Manager terminates TLS and forwards to this Pi's port
# 8080 (D-120). The server trusts X-Forwarded-For only from this address (KVITT_TRUSTED_PROXY), so it must be the # 8080 (D-120). The server trusts X-Forwarded-For only from this address (KVITT_TRUSTED_PROXY), so it must be the
+1 -1
View File
@@ -30,7 +30,7 @@ services:
environment: environment:
TZ: Europe/Stockholm TZ: Europe/Stockholm
KVITT_DATA_DIR: /data KVITT_DATA_DIR: /data
KVITT_PUBLIC_URL: https://${KVITT_PUBLIC_HOST:?set KVITT_PUBLIC_HOST in .env}:8443 KVITT_PUBLIC_URL: https://${KVITT_PUBLIC_HOST:?set KVITT_PUBLIC_HOST in .env}
# NPM's LAN address: only a request whose TCP peer is this address may set X-Forwarded-For, whose last entry # NPM's LAN address: only a request whose TCP peer is this address may set X-Forwarded-For, whose last entry
# is then the client IP of the rate limits (SPEC §8.5, §8.6, D-120). An IP literal (install.sh checks it). # is then the client IP of the rate limits (SPEC §8.5, §8.6, D-120). An IP literal (install.sh checks it).
KVITT_TRUSTED_PROXY: ${HA_HOST:?set HA_HOST in .env} KVITT_TRUSTED_PROXY: ${HA_HOST:?set HA_HOST in .env}
+5 -4
View File
@@ -6,7 +6,8 @@
KVITT_DIR=${KVITT_DIR:-/opt/kvitt} KVITT_DIR=${KVITT_DIR:-/opt/kvitt}
# The server's port, published on the Pi's LAN interfaces; Nginx Proxy Manager forwards to it (D-120). # The server's port, published on the Pi's LAN interfaces; Nginx Proxy Manager forwards to it (D-120).
SERVER_URL=http://localhost:8080 SERVER_URL=http://localhost:8080
# Nginx Proxy Manager's HTTPS port on the Home Assistant Pi (the router forwards external 8443 to it, D-120). # Nginx Proxy Manager's HTTPS port on the Home Assistant Pi, the default HTTPS port that Kvitt's public URL uses
# (the router's existing external 443 forward reaches it, D-124).
NPM_HTTPS_PORT=443 NPM_HTTPS_PORT=443
say() { printf '%s\n' "$*"; } say() { printf '%s\n' "$*"; }
@@ -76,11 +77,11 @@ print_proxy_health() {
local out ha_host host local out ha_host host
ha_host=$(env_value HA_HOST) ha_host=$(env_value HA_HOST)
host=$(env_value KVITT_PUBLIC_HOST) host=$(env_value KVITT_PUBLIC_HOST)
if out=$(curl -fsS --max-time 15 --resolve "$host:$NPM_HTTPS_PORT:$ha_host" "https://$host:$NPM_HTTPS_PORT/health" 2>&1); then if out=$(curl -fsS --max-time 15 --resolve "$host:$NPM_HTTPS_PORT:$ha_host" "https://$host/health" 2>&1); then
say "GET https://$host:$NPM_HTTPS_PORT/health (via NPM at $ha_host): $out" say "GET https://$host/health (via NPM at $ha_host:$NPM_HTTPS_PORT): $out"
return 0 return 0
fi fi
say "GET https://$host:$NPM_HTTPS_PORT/health (via NPM at $ha_host) failed: $out" say "GET https://$host/health (via NPM at $ha_host:$NPM_HTTPS_PORT) failed: $out"
return 1 return 1
} }
+2 -2
View File
@@ -35,7 +35,7 @@ KVITT_PUBLIC_HOST="$(sed -n 's/^KVITT_PUBLIC_HOST=//p' "$env_file" | tail -n 1)"
[[ "$KVITT_PUBLIC_HOST" != icehack.asuscomm.com ]] \ [[ "$KVITT_PUBLIC_HOST" != icehack.asuscomm.com ]] \
|| { echo "publish-apk.sh: KVITT_PUBLIC_HOST cannot be icehack.asuscomm.com, NPM's proxy host for Home Assistant (D-120)." >&2; exit 1; } || { echo "publish-apk.sh: KVITT_PUBLIC_HOST cannot be icehack.asuscomm.com, NPM's proxy host for Home Assistant (D-120)." >&2; exit 1; }
echo "Release $version_name (version_code $version_code) for https://$KVITT_PUBLIC_HOST:8443" echo "Release $version_name (version_code $version_code) for https://$KVITT_PUBLIC_HOST"
PATH="/opt/homebrew/opt/openjdk@25/bin:$PATH" tools/gradle-serial.sh :app:assembleRelease \ PATH="/opt/homebrew/opt/openjdk@25/bin:$PATH" tools/gradle-serial.sh :app:assembleRelease \
"-Pkvitt.versionCode=$version_code" "-Pkvitt.versionName=$version_name" "-Pkvitt.publicHost=$KVITT_PUBLIC_HOST" "-Pkvitt.versionCode=$version_code" "-Pkvitt.versionName=$version_name" "-Pkvitt.publicHost=$KVITT_PUBLIC_HOST"
@@ -94,7 +94,7 @@ done | sort -n -r | tail -n +6 | while read -r old; do
done done
echo "Staged in infra/pi/out/apk/: $file_name ($size_bytes bytes, sha256 $sha256) and latest.json." echo "Staged in infra/pi/out/apk/: $file_name ($size_bytes bytes, sha256 $sha256) and latest.json."
echo "Built for the release server https://$KVITT_PUBLIC_HOST:8443 (KVITT_PUBLIC_HOST in infra/pi/.env)." echo "Built for the release server https://$KVITT_PUBLIC_HOST (KVITT_PUBLIC_HOST in infra/pi/.env)."
echo "Copy to the Pi and install (infra/README.md, Publishing an APK):" 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 " 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" echo " ssh <ssh user>@<Kvitt Pi address> /opt/kvitt/install-apk.sh"