Infra security fixes: Caddy log redaction, headers and non-root container; dev mounts only the FCM key; deploy records image digests (D-117)

- Caddyfile: access and error logs replace the /i/<code> segment and every query string with REDACTED
  (regexp filter on request>uri); Authorization stays redacted by default. Deferred response headers:
  Content-Security-Policy, X-Content-Type-Options, X-Frame-Options, Referrer-Policy.
- Pi compose: caddy as uid 10002 with group 1000, read-only root, cap_drop ALL plus NET_BIND_SERVICE
  (needed to exec the image's file-capability binary), tmpfs /config and /tmp; install.sh sets /certs
  0750 1000:1000 and Caddy's /data to 10002.
- deploy.sh and install.sh print the pulled image digests and append them to /opt/kvitt/deployed-digests,
  warning when a recorded tag changed digest; the workflow prints the pushed digests; kvitt_cicd needs
  only write:package.
- Dev stack: up.sh stages only fcm-service-account.json into infra/dev/secrets/ (git-ignored), mounted
  read-only at /secrets.
- README: restricted cert-sync key required (plain root key not acceptable), remote_ip and log checks,
  APK download note, owner decisions (backup encryption, APK limit, key form, token scope), overhaul lines.

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 22:24:32 +02:00
parent 8660b7fa16
commit 7fb86f5524
12 changed files with 207 additions and 42 deletions
+14 -2
View File
@@ -6,8 +6,9 @@
# plain HTTP), each tagged with the version vMAJOR.MINOR.PATCH and `latest`, then pushes the git tag of that version. # 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>. # On the Kvitt Pi: /opt/kvitt/deploy.sh <version>.
# #
# Secret: `kvitt_cicd`, a repository secret holding a Gitea access token (scopes write:package, and # Secret: `kvitt_cicd`, a repository secret holding a Gitea access token with scope write:package of the user that
# write:repository for the tag push) of the user that pushes (owner step, infra/README.md). # pushes (owner step, infra/README.md). It is used only for `docker login`; the git tag push at the end goes through
# the credentials actions/checkout stored (the job's own token), not this secret, so it needs no write:repository.
# #
# RISK: the runner is a Raspberry Pi 3 with 1 GB RAM and the server image compiles about 232 Rust crates # 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 # (aws-lc-sys's C code included). The build passes CARGO_BUILD_JOBS=1 to keep memory down; server/Dockerfile
@@ -90,6 +91,7 @@ jobs:
--provenance=false \ --provenance=false \
-t "$REGISTRY/kvitt-cert-sync:$VERSION" \ -t "$REGISTRY/kvitt-cert-sync:$VERSION" \
-t "$REGISTRY/kvitt-cert-sync:latest" \ -t "$REGISTRY/kvitt-cert-sync:latest" \
--metadata-file /tmp/kvitt-cert-sync.metadata.json \
./infra/cert-sync \ ./infra/cert-sync \
--push --push
- name: Build backup image - name: Build backup image
@@ -99,6 +101,7 @@ jobs:
--provenance=false \ --provenance=false \
-t "$REGISTRY/kvitt-backup:$VERSION" \ -t "$REGISTRY/kvitt-backup:$VERSION" \
-t "$REGISTRY/kvitt-backup:latest" \ -t "$REGISTRY/kvitt-backup:latest" \
--metadata-file /tmp/kvitt-backup.metadata.json \
./infra/backup \ ./infra/backup \
--push --push
- name: Build server image - name: Build server image
@@ -111,8 +114,17 @@ jobs:
-f ./server/Dockerfile \ -f ./server/Dockerfile \
-t "$REGISTRY/kvitt-server:$VERSION" \ -t "$REGISTRY/kvitt-server:$VERSION" \
-t "$REGISTRY/kvitt-server:latest" \ -t "$REGISTRY/kvitt-server:latest" \
--metadata-file /tmp/kvitt-server.metadata.json \
./server \ ./server \
--push --push
- name: Print image digests
# The digests deploy.sh prints and records on the Kvitt Pi (/opt/kvitt/deployed-digests, D-117) must match.
run: |
for name in cert-sync backup server; do
digest=$(sed -n 's/.*"containerimage.digest": *"\([^"]*\)".*/\1/p' "/tmp/kvitt-$name.metadata.json")
echo "$REGISTRY/kvitt-$name:$VERSION ${digest:-unknown}"
rm -f "/tmp/kvitt-$name.metadata.json"
done
- name: Tag and Push Version - name: Tag and Push Version
run: | run: |
git tag "$VERSION" git tag "$VERSION"
+83 -24
View File
@@ -17,6 +17,7 @@ runs every Pi command from the checklist in "Pi: what the owner does".
| `dev/backup.sh` | Runs the backup job once and checks the snapshot with `PRAGMA integrity_check` | | `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/copy-google-services.sh` | Copies `secrets/google-services.json` to `android/app/google-services.json` |
| `dev/lib.sh` | Helpers sourced by the scripts | | `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 | | `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 | | `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/` | | `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/` |
@@ -34,8 +35,12 @@ All Mac commands below run from the repository root of the main checkout (`/User
publishes its own port (default 8080) on all of the Mac's interfaces. The server keeps the hardening of SPEC §8.9 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 (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 (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. The secrets folder is mounted read-only at `/secrets`; if is easy to inspect and reset. Only the FCM key reaches the container (D-117): `up.sh` copies
`fcm-service-account.json` is missing the server runs with push disabled (§10.4). `${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 the Home Assistant
SSH key, `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. On this Mac `docker` is Podman (`docker compose` runs `podman-compose`); the files work with Docker Desktop as well.
@@ -43,7 +48,7 @@ On this Mac `docker` is Podman (`docker compose` runs `podman-compose`); the fil
```bash ```bash
infra/dev/build.sh # both images infra/dev/build.sh # both images
infra/dev/build.sh server # only kvitt-server:dev; needs server/Dockerfile (milestone M2a) infra/dev/build.sh server # only kvitt-server:dev; needs server/Dockerfile
infra/dev/build.sh backup # only kvitt-backup:dev infra/dev/build.sh backup # only kvitt-backup:dev
``` ```
@@ -97,7 +102,8 @@ Runs `infra/backup/backup.sh` once in the `backup` image (compose profile `backu
`RCLONE_FOLDER_ID` empty: `.backup` snapshot, `PRAGMA integrity_check` must print `ok`, then `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 `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 is skipped with a log line and the files are kept (SPEC §10.9, D-001). The wrapper then checks the new snapshot
again. To check by hand: `gunzip -c infra/dev/backups/kvitt-<stamp>.sqlite.gz > /tmp/k.sqlite && sqlite3 /tmp/k.sqlite "PRAGMA integrity_check"`. 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 ### Resetting the dev data
@@ -190,8 +196,16 @@ mode 0600 **owned by 10001:10001** (not 1000). cert-sync stays uid 1000 with `ha
mode 0600 owned by 1000:1000 (ssh refuses a key not owned by its user or readable by others). rclone writes 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 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 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 10001, `/certs` to 1000 with mode 0750, Caddy's `/data` to 10002) with one-off root containers
runs as root (official image) and reads the 0640 certificate files. (`docker compose run --user 0:0 --cap-add CHOWN`, plus `FOWNER` for the `chmod`).
**Caddy uid (D-117).** Caddy runs as **uid 10002** with the supplementary group **1000** (cert-sync's group): the
certificate files are 0640 `1000:1000` and `/certs` is 0750 `1000:1000`, so cert-sync writes and Caddy only reads.
Read-only root filesystem, `no-new-privileges`, `cap_drop: [ALL]` with `cap_add: [NET_BIND_SERVICE]`, `/config`
and `/tmp` as tmpfs, `caddy_data` writable by 10002. Port 8443 needs no capability, but the official image's
`/usr/bin/caddy` has the file capability `cap_net_bind_service=ep`, and the kernel refuses to exec it when that
capability is outside the bounding set ("exec container process /usr/bin/caddy: Operation not permitted" with
`cap_drop: [ALL]` alone, checked 2026-10-18), so it is the one capability kept.
### What was checked on the Mac (2026-10-18) ### What was checked on the Mac (2026-10-18)
@@ -208,6 +222,16 @@ runs as root (official image) and reads the 0640 certificate files.
* Backup rehearsal with a local rclone remote: upload, `lsf` confirmation by name and size, local deletion only * 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 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. leftovers uploaded by the next good run; healthcheck green on a fresh start and after a run, red at 26 h.
* Caddy hardening rehearsal (D-117, 2026-10-18, Podman, pinned Caddy image, `pi/Caddyfile`, a self-signed pair in
a `certs` volume set up as `install.sh` and cert-sync do, a stand-in upstream named `server`): Caddy runs as
`uid=10002 gid=10002 groups=1000,10002`, `NoNewPrivs: 1`, capability sets `0x400` (NET_BIND_SERVICE only), the root
filesystem refuses writes, the healthcheck command succeeds, memory about 12 MB. `GET /i/<code>?from=<name>` with a
bearer header answered 200 with `Content-Security-Policy`, `X-Content-Type-Options: nosniff`,
`X-Frame-Options: DENY` and one `Referrer-Policy: no-referrer` (the upstream sent it too). The access log showed
`"uri":"/i/REDACTED?REDACTED"` and `"/sync/pull?REDACTED"`, `"Authorization":["REDACTED"]`; with the upstream
stopped (502) Caddy's error log carried `/i/REDACTED?REDACTED` too; the code, the name, `kvt_` and the query value
occurred 0 times in either log. A reload through the admin API from another container (`POST /load`, as
cert-sync does) succeeded. TLS 1.1 refused.
* Not checked (needs the real hosts): the Home Assistant add-on's handling of `authorized_keys` options, the * 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 workflow (not yet run), pulls from `gitea.lan`, the router forward, Drive.
@@ -218,6 +242,15 @@ Same setup as the owner's working example: `runs-on: host`, `sh` steps, `apk add
a buildx builder whose `buildkitd.toml` sets `http = true` for `gitea.lan` (created once and reused, so its cache 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 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. `v1.0.*` git tag plus one, and the git tag pushed after the build. That version is what `deploy.sh` takes.
The token reaches `docker login` through an environment variable, never as text in the script. `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, **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 aws-lc-sys's C code among them. The workflow passes `--build-arg CARGO_BUILD_JOBS=1`; it takes effect only once
@@ -265,7 +298,7 @@ Fill these in first (in `infra/pi/.env` on the Mac; the bundle carries the file
| `KVITT_PI_SSH_USER` | Your user on the Kvitt Pi | The user set in Raspberry Pi Imager | | `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_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_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_USER` | SSH user of the add-on (`root` for "Terminal & SSH"; the configured `username` for "Advanced SSH & Web Terminal"); `root` only with the restricted key of step 3, otherwise a dedicated non-root user | 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` | | `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_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_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 |
@@ -306,27 +339,25 @@ Log in again; `docker compose version` prints a version and `docker run --rm hel
### 3. Put the cert-sync key on the Home Assistant SSH add-on ### 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. Home Assistant, Settings, Add-ons, your SSH add-on, Configuration, `authorized_keys`: add **one** new entry, the
Try variant A first (replace `/ssl` if your `HA_CERT_DIR` differs): restricted key (variant A; 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 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 Optionally also limit it to the Kvitt Pi: start the line with `restrict,from="<KVITT_PI_HOST>",command=...`.
key), replace it with variant B: Save, restart the add-on; its Log tab shows no error about the key.
Variant B (plain key): **A plain key for `root` (the old variant B) is not acceptable (D-117).** The Home Assistant SSH add-ons log in as
root, and without `restrict,command=` whoever holds this key (a compromised cert-sync container, or a copy of the
``` key on the Mac or the Pi) gets an unrestricted root shell on the Home Assistant Pi, which also owns ports 80/443
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMiXj2dU8GU0Xmdj91WxLhVpPOI1zRWvBF5h2MbE4S8V kvitt-cert-sync and the house's automation. If the add-on refuses the restricted line, use a dedicated non-root user instead: a
``` user with no `sudo` that can only read `HA_CERT_DIR` (set `HA_USER` to it), with the plain key line in that user's
`authorized_keys`. If neither is possible, stop and tell the orchestrator; the fallback is Caddy's internal CA
cert-sync works with both unchanged: it always asks for exactly that `tar` command. Tell the orchestrator which (SPEC §10.5.3). cert-sync works unchanged with both: it always asks for exactly that `tar` command. Tell the
variant the add-on accepted, so it is recorded here. The private key is orchestrator which one the add-on accepted (restricted root key, or the dedicated user), so it is recorded here.
`/Users/jhag/projects/kvitt/infra/secrets/ha_ssh_key` (git-ignored; never paste it anywhere). 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 ### 4. Fill the `.env` and stage the secrets, on the Mac
@@ -373,7 +404,8 @@ root:10001; `ha_ssh_key`, `ha_known_hosts` 0600 1000:1000; `rclone.conf` 0600 10
`Caddyfile`, `.env` (0600) and the scripts to `/opt/kvitt`, pulls, starts and waits. Expected end: `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 `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.". `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. 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>`.
### 7. Router forward ### 7. Router forward
@@ -399,7 +431,11 @@ After the Gitea workflow pushed `v1.0.N` (or `infra/build-images.sh` printed a t
``` ```
It backs up first (Drive confirmed) on every deploy, because any tag may carry a migration (SPEC §10.10), sets 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: `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`. 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:` 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, `/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 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`. installed by copying the bundle again (step 5, without the secrets) and running `./install.sh`.
@@ -431,8 +467,31 @@ condition from `depends_on` in `/opt/kvitt/compose.yaml`.
| 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 20 MB each, 5 kept | | 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` | | No token in Caddy's log | `docker compose logs caddy \| grep -c 'kvt_'` prints `0` |
| No invite code or name in Caddy's log | `docker compose logs caddy \| grep '"uri":"/i/' \| grep -vc '/i/REDACTED'` prints `0` |
| Image digests deployed | `tail -n 8 /opt/kvitt/deployed-digests` |
| Client IPs are real (once, after the router forward) | open `https://icehack.asuscomm.com:8443/health` on the phone with Wi-Fi off, then `docker compose logs --tail 20 caddy \| grep -o '"remote_ip":"[^"]*"'`: the address is your mobile network's public address, not `172.*` or `10.*`. If every request shows the Docker bridge gateway, all internet clients share one per-IP rate limit (SPEC §8.6): tell the orchestrator |
| Restore | `infra/RESTORE.md` | | Restore | `infra/RESTORE.md` |
**APK downloads (security review L-3).** `GET /apk/files/<name>` is public (the landing page links it) and about
11 MB per response; the only limit is the general 60 requests per minute per IP of SPEC §8.6, so one stranger can
pull several hundred MB a minute and fill the home uplink. A lower per-IP limit for this path is a server change
and a SPEC number that is not decided yet (owner decision below). Until then, if `docker compose logs caddy`
shows one address fetching `/apk/files/` over and over, remove the router forward (step 7) until it stops.
### Owner decisions (open)
1. **Backup encryption (security review M-1).** The Drive backups (`kvitt-*.sqlite.gz`, `pictures-*.tar.gz`) are
not encrypted (SPEC §8.7), and `rclone.conf` holds a token with the full `drive` scope (read and write to your
whole Drive). Should the backups go through an rclone `crypt` remote (two passwords kept in `rclone.conf`,
restore through the same remote) and should rclone be re-authorised with the narrower `drive.file` scope? Both
change SPEC §8.7 and §10.9. Until you decide, the backup scripts stay as they are.
2. **APK download limit (security review L-3).** May `/apk/files/*` get its own lower per-IP limit (for example a
few downloads per minute per IP)? The number goes into SPEC §8.6 and the server; it is not set here.
3. **cert-sync key (security review M-2, step 3).** Which form did the Home Assistant add-on accept: the restricted
root key, or a dedicated non-root user?
4. **Gitea token scope.** Does the workflow's tag push work with `kvitt_cicd` limited to `write:package`? If not,
is adding `write:repository` acceptable?
### Troubleshooting ### Troubleshooting
* `cert-sync` unhealthy, so `caddy` does not start: `docker compose logs cert-sync`. "Host key verification * `cert-sync` unhealthy, so `caddy` does not start: `docker compose logs cert-sync`. "Host key verification
+2
View File
@@ -0,0 +1,2 @@
# The FCM key staged by up.sh for the dev server (D-117); never committed.
secrets/
+5 -4
View File
@@ -45,10 +45,11 @@ services:
volumes: volumes:
# SQLite database, pictures and APKs: a bind-mounted folder on the Mac (git-ignored). # SQLite database, pictures and APKs: a bind-mounted folder on the Mac (git-ignored).
- ${KVITT_DEV_DATA_DIR:-./data}:/data - ${KVITT_DEV_DATA_DIR:-./data}:/data
# The secrets folder, read-only, so the server finds /secrets/fcm-service-account.json (git-ignored). # Only the FCM key, read-only (D-117): up.sh copies ${KVITT_SECRETS_DIR}/fcm-service-account.json into
# The folder is mounted rather than the file: a missing source file would make Docker create a directory # infra/dev/secrets/ (git-ignored), which holds nothing else. A folder is mounted rather than the file, because
# in its place, while a missing key must simply leave push disabled with a start-up warning (§10.4). # a missing source file would make Docker create a directory in its place, while a missing key must simply
- ${KVITT_SECRETS_DIR:-../secrets}:/secrets:ro # leave push disabled with a start-up warning (§10.4); without the key the folder is empty.
- ./secrets:/secrets:ro
healthcheck: healthcheck:
test: ["CMD", "kvitt-server", "healthcheck"] test: ["CMD", "kvitt-server", "healthcheck"]
interval: 30s interval: 30s
+16
View File
@@ -35,3 +35,19 @@ lan_address() {
iface="$(route -n get default 2>/dev/null | awk '/interface:/ {print $2}')" iface="$(route -n get default 2>/dev/null | awk '/interface:/ {print $2}')"
[[ -n "$iface" ]] && ipconfig getifaddr "$iface" 2>/dev/null [[ -n "$iface" ]] && ipconfig getifaddr "$iface" 2>/dev/null
} }
# Fill infra/dev/secrets/ (git-ignored, mounted read-only at /secrets) with only the FCM key from
# KVITT_SECRETS_DIR, so the server container sees no other secret (D-117). Without the key the folder is left
# empty and the server starts with push disabled (§10.4).
stage_fcm_key() {
local src stage="$dev_dir/secrets"
src="$(dev_path "$(env_value KVITT_SECRETS_DIR ../secrets)")/fcm-service-account.json"
install -d -m 0700 "$stage"
find "$stage" -mindepth 1 -maxdepth 1 ! -name fcm-service-account.json -exec rm -rf {} +
if [[ -f "$src" ]]; then
install -m 0600 "$src" "$stage/fcm-service-account.json"
else
rm -f "$stage/fcm-service-account.json"
echo "$(basename "$0"): no $src; the server starts with push disabled." >&2
fi
}
+1 -4
View File
@@ -21,11 +21,8 @@ fi
export KVITT_PUBLIC_URL="$public_url" export KVITT_PUBLIC_URL="$public_url"
data_dir="$(dev_path "$(env_value KVITT_DEV_DATA_DIR ./data)")" data_dir="$(dev_path "$(env_value KVITT_DEV_DATA_DIR ./data)")"
secrets_dir="$(dev_path "$(env_value KVITT_SECRETS_DIR ../secrets)")"
mkdir -p "$data_dir" mkdir -p "$data_dir"
if [[ ! -f "$secrets_dir/fcm-service-account.json" ]]; then stage_fcm_key
echo "up.sh: no $secrets_dir/fcm-service-account.json; the server starts with push disabled." >&2
fi
compose up -d server compose up -d server
+2 -1
View File
@@ -13,7 +13,8 @@ KVITT_TAG=CHANGE_ME_TAG
HA_HOST=CHANGE_ME_HA_HOST 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). # 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 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). # SSH user on the Home Assistant Pi (OQ-3). root only with the restricted key line of infra/README.md step 3; otherwise a
# dedicated non-root user that can only read HA_CERT_DIR (D-117). A plain, unrestricted root key is not acceptable.
HA_USER=CHANGE_ME_HA_USER 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). # 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 HA_CERT_DIR=CHANGE_ME_HA_CERT_DIR
+23 -1
View File
@@ -1,6 +1,15 @@
# Kvitt Caddyfile (SPEC §10.5.2, D-117). Installed as /opt/kvitt/Caddyfile; cert-sync reloads it through the admin API.
{ {
admin 0.0.0.0:2019 admin 0.0.0.0:2019
auto_https off auto_https off
# Caddy's own error log (for example a 502 when the server is down) carries the request too: same URI filter.
log default {
output stderr
format filter {
request>uri regexp `^(/i/)[^?]+|([?]).+` `${1}${2}REDACTED`
wrap json
}
}
} }
icehack.asuscomm.com:8443 { icehack.asuscomm.com:8443 {
@@ -8,9 +17,22 @@ icehack.asuscomm.com:8443 {
request_body { request_body {
max_size 6MB max_size 6MB
} }
# Set after the server's response headers (defer), so a header the server also sends appears once.
header {
defer
Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy no-referrer
}
reverse_proxy server:8080 reverse_proxy server:8080
# Access log: the invite code in /i/<code> and every query string (the inviter's name in ?from=) become
# REDACTED (SPEC §8.7); the Authorization header is redacted by Caddy's default.
log { log {
output stdout output stdout
format json format filter {
request>uri regexp `^(/i/)[^?]+|([?]).+` `${1}${2}REDACTED`
wrap json
}
} }
} }
+14
View File
@@ -17,8 +17,22 @@ services:
# Official image, pinned by version and multi-arch index digest (resolved 2026-10-18, infra/README.md). # 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 image: docker.io/library/caddy:2.11.7@sha256:685ae3d7c34d9b5f8caeddc5d14051db7bbd88ff1f5a2fa0c16bd8ca328fb7af
restart: unless-stopped restart: unless-stopped
# Non-root (D-117): uid 10002, plus group 1000 (cert-sync's group) to read the 0640 files in the 0750 /certs.
# 8443 needs no capability, but the image's /usr/bin/caddy carries the file capability cap_net_bind_service=ep,
# and the kernel refuses to exec it when that capability is not in the bounding set (checked 2026-10-18:
# "exec container process /usr/bin/caddy: Operation not permitted" with cap_drop ALL alone). So NET_BIND_SERVICE
# is the one capability kept. caddy_data (/data) is owned by 10002 (install.sh); /config (autosave of the loaded
# config) and /tmp are tmpfs, the rest of the root filesystem is read-only.
user: "10002:10002"
group_add: ["1000"]
read_only: true
cap_drop: [ALL]
cap_add: [NET_BIND_SERVICE]
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
tmpfs:
- /config
- /tmp
mem_limit: 64m mem_limit: 64m
ports: ports:
- "8443:8443" - "8443:8443"
+4 -2
View File
@@ -4,7 +4,8 @@
# migration and the Pi cannot tell beforehand (the rule "backup before a deploy with a migration" is applied # 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) # 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) # 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 # 3. docker compose pull, prints the pulled image digests and appends them to /opt/kvitt/deployed-digests (D-117),
# 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 # 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). # partially applied, restore the pre-deploy backup (infra/RESTORE.md).
set -euo pipefail set -euo pipefail
@@ -17,7 +18,7 @@ tag=""
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
--skip-backup) skip_backup=1 ;; --skip-backup) skip_backup=1 ;;
-h|--help) sed -n '2,10p' "$0"; exit 0 ;; -h|--help) sed -n '2,11p' "$0"; exit 0 ;;
-*) die "unknown option $arg" ;; -*) die "unknown option $arg" ;;
*) [[ -z "$tag" ]] || die "usage: $0 <tag> [--skip-backup]"; tag=$arg ;; *) [[ -z "$tag" ]] || die "usage: $0 <tag> [--skip-backup]"; tag=$arg ;;
esac esac
@@ -47,6 +48,7 @@ if ! compose pull; then
[[ -n "$current" ]] && env_set KVITT_TAG "$current" [[ -n "$current" ]] && env_set KVITT_TAG "$current"
die "pull of $tag failed; KVITT_TAG left at ${current:-its old value}" die "pull of $tag failed; KVITT_TAG left at ${current:-its old value}"
fi fi
record_digests "$tag"
compose up -d --remove-orphans compose up -d --remove-orphans
if ! wait_healthy server 180; then if ! wait_healthy server 180; then
compose logs --tail 50 server || true compose logs --tail 50 server || true
+10 -4
View File
@@ -8,7 +8,8 @@
# 4. trusts the plain-HTTP registry in /etc/docker/daemon.json (insecure-registries) and logs in when credentials are set # 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; a KVITT_TAG that deploy.sh set # 5. copies compose.yaml, Caddyfile, .env and the operation scripts to /opt/kvitt; a KVITT_TAG that deploy.sh set
# in /opt/kvitt/.env is kept, so re-running an older bundle never rolls the server back to the bundle's tag # in /opt/kvitt/.env is kept, so re-running an older bundle never rolls the server back to the bundle's tag
# 6. pulls the images, gives the named volumes their owners, starts the stack, waits for /health # 6. pulls the images (digests recorded in /opt/kvitt/deployed-digests), 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. # Uses sudo for /opt/kvitt, the secret files and the Docker daemon configuration.
set -euo pipefail set -euo pipefail
bundle="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" bundle="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -35,7 +36,8 @@ registry_host=${registry%%/*}
registry_user=$(env_value KVITT_REGISTRY_USER "$env_src") registry_user=$(env_value KVITT_REGISTRY_USER "$env_src")
registry_token=$(env_value KVITT_REGISTRY_TOKEN "$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). # 3. Directories and secrets. uid 1000 = cert-sync, uid 10001 = server and backup, uid 10002 = caddy (SPEC §10.2,
# §10.7, D-023, D-105, D-117).
say "Creating $KVITT_DIR" 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 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" sudo install -d -m 0700 -o root -g root "$KVITT_DIR/secrets"
@@ -125,13 +127,17 @@ say "Installed compose.yaml, Caddyfile, .env and the scripts in $KVITT_DIR"
# 6. Pull, volume owners, start. # 6. Pull, volume owners, start.
compose config --quiet compose config --quiet
compose pull compose pull
record_digests "$(env_value KVITT_TAG)"
compose create compose create
# Named volumes start owned by root; give each its container's uid (the server image has no shell, so the # 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. # 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 \ 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' -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 \ # /certs: cert-sync (1000) writes, Caddy (10002, supplementary group 1000) reads; /data of Caddy: 10002 (D-117).
-c 'chown 1000:1000 /certs' compose run --rm --no-deps -T --user 0:0 --cap-add CHOWN --cap-add FOWNER --entrypoint /bin/sh cert-sync \
-c 'chown 1000:1000 /certs && chmod 0750 /certs'
compose run --rm --no-deps -T --user 0:0 --cap-add CHOWN --entrypoint /bin/sh caddy \
-c 'chown -R 10002:10002 /data'
compose up -d --remove-orphans compose up -d --remove-orphans
ok=1 ok=1
wait_healthy server 180 || ok=0 wait_healthy server 180 || ok=0
+33
View File
@@ -66,3 +66,36 @@ print_health() {
say "GET https://$PUBLIC_HOST:$PUBLIC_PORT/health (via this Pi) failed: $out" say "GET https://$PUBLIC_HOST:$PUBLIC_PORT/health (via this Pi) failed: $out"
return 1 return 1
} }
# Print the registry digest of every image the stack runs and append one line per image to
# $KVITT_DIR/deployed-digests: "<UTC time> <KVITT_TAG> <image reference> <digest>" (D-117). The registry is plain
# HTTP on the LAN (D-001), so a swapped image would otherwise go unnoticed: a tag that was recorded before with a
# different digest prints a WARNING (expected only for a tag like `latest` that is meant to move).
record_digests() {
local tag=$1 log=$KVITT_DIR/deployed-digests stamp ref repo digest prev d
local -a refs repo_digests
stamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)
mapfile -t refs < <(compose config --images)
touch "$log"
say "Image digests (also in $log):"
for ref in "${refs[@]}"; do
[[ -n "$ref" ]] || continue
digest=""
if [[ "$ref" == *@sha256:* ]]; then
digest=${ref#*@}
else
repo=${ref%:*}
read -r -a repo_digests <<< "$(docker image inspect --format '{{range .RepoDigests}}{{.}} {{end}}' "$ref" 2>/dev/null || true)"
for d in "${repo_digests[@]}"; do
if [[ "$d" == "$repo@"* ]]; then digest=${d#*@}; break; fi
done
fi
[[ -n "$digest" ]] || digest=unknown
prev=$(awk -v r="$ref" '$3 == r { d = $4 } END { print d }' "$log")
if [[ -n "$prev" && "$prev" != "$digest" ]]; then
say "WARNING: $ref was $prev at an earlier deploy and is $digest now: the image behind this tag changed"
fi
say " $ref $digest"
printf '%s %s %s %s\n' "$stamp" "$tag" "$ref" "$digest" >> "$log"
done
}