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.
# On the Kvitt Pi: /opt/kvitt/deploy.sh <version>.
#
# 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"
+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/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-<UTC stamp>.sqlite.gz`, `pictures-<stamp>.tar.gz` and `last-success`; the Drive upload
is skipped with a log line and the files are kept (SPEC §10.9, D-001). The wrapper then checks the new snapshot
again. 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
@@ -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/<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
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="<KVITT_PI_HOST>",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 <tag>`.
### 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/<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
* `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:
# 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
+16
View File
@@ -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
}
+1 -4
View File
@@ -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
+2 -1
View File
@@ -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
+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
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/<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 {
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).
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"
+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
# 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 <tag> [--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
+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
# 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
+33
View File
@@ -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: "<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
}