diff --git a/.gitea/workflows/build-images.yaml b/.gitea/workflows/build-images.yaml index 902b2d2..f4df82a 100644 --- a/.gitea/workflows/build-images.yaml +++ b/.gitea/workflows/build-images.yaml @@ -6,8 +6,9 @@ # 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 . # -# Secret: `kvitt_cicd`, a repository secret holding a Gitea access token (scopes write:package, and -# write:repository for the tag push) of the user that pushes (owner step, infra/README.md). +# Secret: `kvitt_cicd`, a repository secret holding a Gitea access token with scope write:package of the user that +# 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 # (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 \ -t "$REGISTRY/kvitt-cert-sync:$VERSION" \ -t "$REGISTRY/kvitt-cert-sync:latest" \ + --metadata-file /tmp/kvitt-cert-sync.metadata.json \ ./infra/cert-sync \ --push - name: Build backup image @@ -99,6 +101,7 @@ jobs: --provenance=false \ -t "$REGISTRY/kvitt-backup:$VERSION" \ -t "$REGISTRY/kvitt-backup:latest" \ + --metadata-file /tmp/kvitt-backup.metadata.json \ ./infra/backup \ --push - name: Build server image @@ -111,8 +114,17 @@ jobs: -f ./server/Dockerfile \ -t "$REGISTRY/kvitt-server:$VERSION" \ -t "$REGISTRY/kvitt-server:latest" \ + --metadata-file /tmp/kvitt-server.metadata.json \ ./server \ --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 run: | git tag "$VERSION" diff --git a/infra/README.md b/infra/README.md index 8d5061b..5a68e5a 100644 --- a/infra/README.md +++ b/infra/README.md @@ -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/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 | | `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/` | @@ -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 (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. The secrets folder is mounted read-only at `/secrets`; if -`fcm-service-account.json` is missing the server runs with push disabled (§10.4). +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 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. @@ -43,7 +48,7 @@ On this Mac `docker` is Podman (`docker compose` runs `podman-compose`); the fil ```bash 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 ``` @@ -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 `infra/dev/backups/kvitt-.sqlite.gz`, `pictures-.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. To check by hand: `gunzip -c infra/dev/backups/kvitt-.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-.sqlite.gz > /tmp/k.sqlite && sqlite3 /tmp/k.sqlite "PRAGMA integrity_check"`. ### 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 refreshed access tokens back to its config; the mounted file is read-only, so `backup.sh` works on a private copy in `/tmp` each run. `install.sh` gives the named volumes their owners once (`/data`, `/data/apk`, `/backups` to -10001, `/certs` to 1000) with a one-off root container (`docker compose run --user 0:0 --cap-add CHOWN`). Caddy -runs as root (official image) and reads the 0640 certificate files. +10001, `/certs` to 1000 with mode 0750, Caddy's `/data` to 10002) with one-off root containers +(`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) @@ -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 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. +* 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/?from=` 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 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 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 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, 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 | | `HA_HOST` | LAN address of the Home Assistant Pi | Router client list, or Home Assistant, Settings, System, Network | | `HA_PORT` | SSH port of the SSH add-on (default 22) | Home Assistant, Settings, Add-ons, the SSH add-on, Configuration, Network | -| `HA_USER` | SSH user of the add-on (`root` for "Terminal & SSH"; the configured `username` for "Advanced SSH & Web Terminal") | Same add-on, Configuration | +| `HA_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` | | `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 | @@ -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 -Home Assistant, Settings, Add-ons, your SSH add-on, Configuration, `authorized_keys`: add **one** new entry. -Try variant A first (replace `/ssl` if your `HA_CERT_DIR` differs): - -Variant A (restricted, preferred): +Home Assistant, Settings, Add-ons, your SSH add-on, Configuration, `authorized_keys`: add **one** new entry, the +restricted key (variant A; replace `/ssl` if your `HA_CERT_DIR` differs): ``` restrict,command="tar -C /ssl -cf - fullchain.pem privkey.pem" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMiXj2dU8GU0Xmdj91WxLhVpPOI1zRWvBF5h2MbE4S8V kvitt-cert-sync ``` -Save, restart the add-on. If the add-on refuses to save or start with it (its Log tab shows an error about the -key), replace it with variant B: +Optionally also limit it to the Kvitt Pi: start the line with `restrict,from="",command=...`. +Save, restart the add-on; its Log tab shows no error about the key. -Variant B (plain key): - -``` -ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMiXj2dU8GU0Xmdj91WxLhVpPOI1zRWvBF5h2MbE4S8V kvitt-cert-sync -``` - -cert-sync works with both unchanged: it always asks for exactly that `tar` command. Tell the orchestrator which -variant the add-on accepted, so it is recorded here. The private key is -`/Users/jhag/projects/kvitt/infra/secrets/ha_ssh_key` (git-ignored; never paste it anywhere). +**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 +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 +(SPEC §10.5.3). cert-sync works unchanged with both: it always asks for exactly that `tar` command. Tell the +orchestrator which one the add-on accepted (restricted root key, or the dedicated user), so it is recorded here. +The private key is `/Users/jhag/projects/kvitt/infra/secrets/ha_ssh_key` (git-ignored; never paste it anywhere). ### 4. Fill the `.env` and stage the secrets, on the Mac @@ -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: `server: healthy`, `cert-sync: healthy`, `caddy: healthy`, a `docker compose ps` table, the line `GET https://icehack.asuscomm.com:8443/health (via this Pi): {"status":"ok",...}`, then "Kvitt is running.". -Then `rm -r ~/kvitt-pi/secrets` (the script reminds you). Running `install.sh` again is safe. +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 `. ### 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 -`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, 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`. @@ -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) | | 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 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` | +**APK downloads (security review L-3).** `GET /apk/files/` 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 * `cert-sync` unhealthy, so `caddy` does not start: `docker compose logs cert-sync`. "Host key verification diff --git a/infra/dev/.gitignore b/infra/dev/.gitignore new file mode 100644 index 0000000..4a8a5ee --- /dev/null +++ b/infra/dev/.gitignore @@ -0,0 +1,2 @@ +# The FCM key staged by up.sh for the dev server (D-117); never committed. +secrets/ diff --git a/infra/dev/compose.yaml b/infra/dev/compose.yaml index 8aff495..24f8e07 100644 --- a/infra/dev/compose.yaml +++ b/infra/dev/compose.yaml @@ -45,10 +45,11 @@ services: volumes: # SQLite database, pictures and APKs: a bind-mounted folder on the Mac (git-ignored). - ${KVITT_DEV_DATA_DIR:-./data}:/data - # The secrets folder, read-only, so the server finds /secrets/fcm-service-account.json (git-ignored). - # The folder is mounted rather than the file: a missing source file would make Docker create a directory - # in its place, while a missing key must simply leave push disabled with a start-up warning (§10.4). - - ${KVITT_SECRETS_DIR:-../secrets}:/secrets:ro + # Only the FCM key, read-only (D-117): up.sh copies ${KVITT_SECRETS_DIR}/fcm-service-account.json into + # infra/dev/secrets/ (git-ignored), which holds nothing else. A folder is mounted rather than the file, because + # a missing source file would make Docker create a directory in its place, while a missing key must simply + # leave push disabled with a start-up warning (§10.4); without the key the folder is empty. + - ./secrets:/secrets:ro healthcheck: test: ["CMD", "kvitt-server", "healthcheck"] interval: 30s diff --git a/infra/dev/lib.sh b/infra/dev/lib.sh index 11a9b75..4e15fcd 100644 --- a/infra/dev/lib.sh +++ b/infra/dev/lib.sh @@ -35,3 +35,19 @@ lan_address() { iface="$(route -n get default 2>/dev/null | awk '/interface:/ {print $2}')" [[ -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 +} diff --git a/infra/dev/up.sh b/infra/dev/up.sh index 42d2e2f..03f373d 100755 --- a/infra/dev/up.sh +++ b/infra/dev/up.sh @@ -21,11 +21,8 @@ fi export KVITT_PUBLIC_URL="$public_url" 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" -if [[ ! -f "$secrets_dir/fcm-service-account.json" ]]; then - echo "up.sh: no $secrets_dir/fcm-service-account.json; the server starts with push disabled." >&2 -fi +stage_fcm_key compose up -d server diff --git a/infra/pi/.env.example b/infra/pi/.env.example index 01745b4..b158071 100644 --- a/infra/pi/.env.example +++ b/infra/pi/.env.example @@ -13,7 +13,8 @@ KVITT_TAG=CHANGE_ME_TAG HA_HOST=CHANGE_ME_HA_HOST # SSH port of the Home Assistant SSH add-on (OQ-3; the "Advanced SSH & Web Terminal" and "Terminal & SSH" add-ons default to 22). HA_PORT=22 -# SSH user on the Home Assistant Pi (OQ-3; the Home Assistant SSH add-ons log in as root, or the user set in the add-on). +# 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 # 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 diff --git a/infra/pi/Caddyfile b/infra/pi/Caddyfile index b6c7944..5c2879a 100644 --- a/infra/pi/Caddyfile +++ b/infra/pi/Caddyfile @@ -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 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 { @@ -8,9 +17,22 @@ icehack.asuscomm.com:8443 { request_body { 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 + # Access log: the invite code in /i/ 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 { output stdout - format json + format filter { + request>uri regexp `^(/i/)[^?]+|([?]).+` `${1}${2}REDACTED` + wrap json + } } } diff --git a/infra/pi/compose.yaml b/infra/pi/compose.yaml index fe39947..a90b98c 100644 --- a/infra/pi/compose.yaml +++ b/infra/pi/compose.yaml @@ -17,8 +17,22 @@ services: # Official image, pinned by version and multi-arch index digest (resolved 2026-10-18, infra/README.md). image: docker.io/library/caddy:2.11.7@sha256:685ae3d7c34d9b5f8caeddc5d14051db7bbd88ff1f5a2fa0c16bd8ca328fb7af restart: unless-stopped + # 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: - no-new-privileges:true + tmpfs: + - /config + - /tmp mem_limit: 64m ports: - "8443:8443" diff --git a/infra/pi/deploy.sh b/infra/pi/deploy.sh index 3618e68..721f543 100755 --- a/infra/pi/deploy.sh +++ b/infra/pi/deploy.sh @@ -4,7 +4,8 @@ # migration and the Pi cannot tell beforehand (the rule "backup before a deploy with a migration" is applied # to every deploy); skipped when no database exists yet, or with --skip-backup (rollback while Drive is down) # 2. sets KVITT_TAG in /opt/kvitt/.env (the previous tag is kept in /opt/kvitt/previous-tag) -# 3. docker compose pull && docker compose up -d, waits for server to be healthy, prints GET /health +# 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 # partially applied, restore the pre-deploy backup (infra/RESTORE.md). set -euo pipefail @@ -17,7 +18,7 @@ tag="" for arg in "$@"; do case "$arg" in --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" ;; *) [[ -z "$tag" ]] || die "usage: $0 [--skip-backup]"; tag=$arg ;; esac @@ -47,6 +48,7 @@ if ! compose pull; then [[ -n "$current" ]] && env_set KVITT_TAG "$current" die "pull of $tag failed; KVITT_TAG left at ${current:-its old value}" fi +record_digests "$tag" compose up -d --remove-orphans if ! wait_healthy server 180; then compose logs --tail 50 server || true diff --git a/infra/pi/install.sh b/infra/pi/install.sh index 334e728..0c09c6c 100755 --- a/infra/pi/install.sh +++ b/infra/pi/install.sh @@ -8,7 +8,8 @@ # 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 # 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. set -euo pipefail bundle="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -35,7 +36,8 @@ registry_host=${registry%%/*} registry_user=$(env_value KVITT_REGISTRY_USER "$env_src") registry_token=$(env_value KVITT_REGISTRY_TOKEN "$env_src") -# 3. Directories and secrets. uid 1000 = cert-sync, uid 10001 = server and backup (SPEC §10.2, §10.7, D-023, D-105). +# 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" 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" @@ -125,13 +127,17 @@ say "Installed compose.yaml, Caddyfile, .env and the scripts in $KVITT_DIR" # 6. Pull, volume owners, start. compose config --quiet compose pull +record_digests "$(env_value KVITT_TAG)" compose create # Named volumes start owned by root; give each its container's uid (the server image has no shell, so the # backup image, which mounts the same data volume, sets /data). Runs as root once per install, then exits. compose run --rm --no-deps -T --user 0:0 --cap-add CHOWN --entrypoint /bin/sh backup \ -c 'chown 10001:10001 /data /backups && mkdir -p /data/apk && chown 10001:10001 /data/apk' -compose run --rm --no-deps -T --user 0:0 --cap-add CHOWN --entrypoint /bin/sh cert-sync \ - -c 'chown 1000:1000 /certs' +# /certs: cert-sync (1000) writes, Caddy (10002, supplementary group 1000) reads; /data of Caddy: 10002 (D-117). +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 ok=1 wait_healthy server 180 || ok=0 diff --git a/infra/pi/lib.sh b/infra/pi/lib.sh index 12e116c..695db49 100755 --- a/infra/pi/lib.sh +++ b/infra/pi/lib.sh @@ -66,3 +66,36 @@ print_health() { say "GET https://$PUBLIC_HOST:$PUBLIC_PORT/health (via this Pi) failed: $out" return 1 } + +# Print the registry digest of every image the stack runs and append one line per image to +# $KVITT_DIR/deployed-digests: " " (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 +}