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:
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# The FCM key staged by up.sh for the dev server (D-117); never committed.
|
||||
secrets/
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user