diff --git a/.gitea/workflows/build-images.yaml b/.gitea/workflows/build-images.yaml index c4dbb8c..9cd6806 100644 --- a/.gitea/workflows/build-images.yaml +++ b/.gitea/workflows/build-images.yaml @@ -2,7 +2,7 @@ # example workflow and the Gitea Actions documentation; the first run is an owner step, infra/README.md). # # Runner: act_runner with the label `host` on the owner's Raspberry Pi (Alpine-based host, docker builds on the -# Pi itself). Pushes three linux/arm64 images to the Gitea container registry of the same instance (gitea.lan, +# Pi itself). Pushes two linux/arm64 images to the Gitea container registry of the same instance (gitea.lan, # 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 . # @@ -34,7 +34,6 @@ on: push: paths: - "server/**" - - "infra/cert-sync/**" - "infra/backup/**" - ".gitea/workflows/**" workflow_dispatch: {} @@ -82,16 +81,6 @@ jobs: # --provenance=false on every build: one plain linux/arm64 manifest per tag, as Podman and infra/build-images.sh # push. Without it buildx pushes an index with an attestation entry (platform unknown/unknown), which the Gitea # package page lists as an extra platform; nothing here reads attestations. - - name: Build cert-sync image - run: | - docker buildx build \ - --platform linux/arm64 \ - --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 run: | docker buildx build \ @@ -118,7 +107,7 @@ jobs: - 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 + for name in 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" diff --git a/infra/build-images.sh b/infra/build-images.sh index 5b82cce..2eb9356 100755 --- a/infra/build-images.sh +++ b/infra/build-images.sh @@ -1,11 +1,10 @@ #!/usr/bin/env bash -# Build the three Kvitt images for linux/arm64 on the Mac and push them to the Gitea registry (SPEC §10.3). +# Build the two Kvitt images for linux/arm64 on the Mac and push them to the Gitea registry (SPEC §10.3). # The Mac fallback for the Gitea Actions workflow .gitea/workflows/build-images.yaml (which is the normal path). # Usage: infra/build-images.sh [tag] tag defaults to the short git SHA of HEAD; `latest` is always tagged too. # -# Images (D-104): ${KVITT_REGISTRY}/kvitt-server (context server/), kvitt-cert-sync (infra/cert-sync), -# kvitt-backup (infra/backup). KVITT_REGISTRY and the optional credentials come from infra/secrets/registry.env -# (git-ignored): +# Images (D-104, D-120): ${KVITT_REGISTRY}/kvitt-server (context server/) and kvitt-backup (infra/backup). +# KVITT_REGISTRY and the optional credentials come from infra/secrets/registry.env (git-ignored): # KVITT_REGISTRY=gitea.lan/jacobh2 # KVITT_REGISTRY_USER= optional # KVITT_REGISTRY_TOKEN= optional @@ -41,7 +40,6 @@ registry_host="${registry%%/*}" # name|context|dockerfile images=( "kvitt-server|server|server/Dockerfile" - "kvitt-cert-sync|infra/cert-sync|infra/cert-sync/Dockerfile" "kvitt-backup|infra/backup|infra/backup/Dockerfile" ) diff --git a/infra/cert-sync/Dockerfile b/infra/cert-sync/Dockerfile deleted file mode 100644 index a9f5e19..0000000 --- a/infra/cert-sync/Dockerfile +++ /dev/null @@ -1,26 +0,0 @@ -# Kvitt cert-sync image (SPEC §10.3, §10.5.1): alpine with openssh-client, curl and sync.sh on a daily schedule. -# Versions resolved 2026-10-18: alpine 3.24.2 (Docker Hub official image, multi-arch index digest below); packages -# from Alpine v3.24 main/community: openssh-client-default 10.3_p1, curl 8.22.0, openssl 3.5.9 (PEM checks), -# supercronic 0.2.49, tzdata 2026e. -# The scheduler is supercronic, not busybox crond: the container runs as uid 1000 (SPEC §10.2, D-023) and busybox -# crond cannot run jobs as a non-root user (checked 2026-10-18 in alpine 3.24.2: no job ever ran). See D-104. -FROM docker.io/library/alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 - -RUN apk add --no-cache \ - "openssh-client-default~10.3" \ - "curl~8.22" \ - "openssl~3.5" \ - "supercronic~0.2" \ - tzdata \ - && adduser -D -u 1000 -g "Kvitt cert-sync" kvitt \ - && mkdir -p /certs \ - && chown 1000:1000 /certs - -COPY sync.sh entrypoint.sh /usr/local/bin/ -RUN chmod 0755 /usr/local/bin/sync.sh /usr/local/bin/entrypoint.sh \ - && echo '0 4 * * * /usr/local/bin/sync.sh' > /etc/kvitt-cert-sync.crontab - -ENV TZ=Europe/Stockholm -USER 1000:1000 -# sync.sh once at start, then daily at 04:00 Europe/Stockholm. -ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/infra/cert-sync/entrypoint.sh b/infra/cert-sync/entrypoint.sh deleted file mode 100755 index 2566714..0000000 --- a/infra/cert-sync/entrypoint.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/sh -# cert-sync container start (SPEC §10.5.1): one sync at start, then the daily schedule. A failed first sync keeps -# the container running (the healthcheck reports whether a certificate exists). -/usr/local/bin/sync.sh || true -exec supercronic -quiet /etc/kvitt-cert-sync.crontab diff --git a/infra/cert-sync/sync.sh b/infra/cert-sync/sync.sh deleted file mode 100755 index 0bc08f0..0000000 --- a/infra/cert-sync/sync.sh +++ /dev/null @@ -1,95 +0,0 @@ -#!/bin/sh -# Kvitt certificate sync (SPEC §10.5.1). Fetches fullchain.pem and privkey.pem from the Home Assistant Pi over SSH -# and installs them into /certs when they are valid, newer and changed; then reloads Caddy through its admin API. -# Any failure logs one line and keeps the existing certificate. -# -# The remote command is always `tar -C -cf - fullchain.pem privkey.pem`, read as a tar stream. That -# works with both authorized_keys variants of the README: with `restrict,command="tar ..."` the forced command -# runs whatever is requested, and with a plain key the requested command is the same tar line. -# -# Environment (from /opt/kvitt/.env): HA_HOST, HA_PORT, HA_USER, HA_CERT_DIR. -# Files: /secrets/ha_ssh_key, /secrets/known_hosts (read-only), /certs (writable), /etc/caddy/Caddyfile (read-only). -set -eu - -CERTS=${CERTS_DIR:-/certs} -KEY=${HA_SSH_KEY:-/secrets/ha_ssh_key} -KNOWN_HOSTS=${HA_KNOWN_HOSTS:-/secrets/known_hosts} -CADDYFILE=${CADDYFILE:-/etc/caddy/Caddyfile} -CADDY_ADMIN=${CADDY_ADMIN:-http://caddy:2019} - -log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) cert-sync: $*"; } - -tmp="" -cleanup() { [ -n "$tmp" ] && rm -rf "$tmp"; return 0; } -trap cleanup EXIT INT TERM - -fail() { log "FAILED: $*; existing certificate kept"; exit 1; } - -for v in HA_HOST HA_PORT HA_USER HA_CERT_DIR; do - eval "val=\${$v:-}" - # shellcheck disable=SC2154 # val is set by the eval above - [ -n "$val" ] || fail "$v is not set" -done -[ -r "$KEY" ] || fail "no readable key at $KEY" -[ -r "$KNOWN_HOSTS" ] || fail "no readable known_hosts at $KNOWN_HOSTS" - -tmp=$(mktemp -d /tmp/cert-sync.XXXXXX) - -# 1. Fetch as a tar stream. -if ! ssh -i "$KEY" -p "$HA_PORT" \ - -o BatchMode=yes -o IdentitiesOnly=yes -o ConnectTimeout=20 \ - -o StrictHostKeyChecking=yes -o UserKnownHostsFile="$KNOWN_HOSTS" -o GlobalKnownHostsFile=/dev/null \ - -o LogLevel=ERROR \ - "$HA_USER@$HA_HOST" "tar -C '$HA_CERT_DIR' -cf - fullchain.pem privkey.pem" > "$tmp/certs.tar" 2> "$tmp/ssh.err"; then - fail "ssh to $HA_USER@$HA_HOST:$HA_PORT failed: $(head -c 300 "$tmp/ssh.err" | tr '\n' ' ')" -fi -mkdir "$tmp/new" -tar -xf "$tmp/certs.tar" -C "$tmp/new" fullchain.pem privkey.pem 2>/dev/null || fail "the tar stream did not hold fullchain.pem and privkey.pem" -new_chain="$tmp/new/fullchain.pem" -new_key="$tmp/new/privkey.pem" - -# 2. Both parse as PEM, the certificate has not expired, and the key belongs to the certificate. -openssl x509 -in "$new_chain" -noout 2>/dev/null || fail "fullchain.pem does not parse as a PEM certificate" -openssl pkey -in "$new_key" -noout 2>/dev/null || fail "privkey.pem does not parse as a PEM private key" -openssl x509 -in "$new_chain" -noout -checkend 0 >/dev/null 2>&1 || fail "fullchain.pem has expired" -cert_pub=$(openssl x509 -in "$new_chain" -noout -pubkey | openssl pkey -pubin -outform DER 2>/dev/null | sha256sum | cut -d' ' -f1) -key_pub=$(openssl pkey -in "$new_key" -pubout -outform DER 2>/dev/null | sha256sum | cut -d' ' -f1) -[ "$cert_pub" = "$key_pub" ] || fail "privkey.pem does not belong to fullchain.pem" -new_end_iso=$(openssl x509 -in "$new_chain" -noout -enddate -dateopt iso_8601 | cut -d= -f2) - -# 3. Unchanged? Older than the installed one? -cur_chain="$CERTS/fullchain.pem" -cur_key="$CERTS/privkey.pem" -if [ -s "$cur_chain" ] && [ -s "$cur_key" ]; then - if [ "$(sha256sum < "$new_chain")" = "$(sha256sum < "$cur_chain")" ] && \ - [ "$(sha256sum < "$new_key")" = "$(sha256sum < "$cur_key")" ]; then - log "unchanged (not after $new_end_iso)" - exit 0 - fi - # ISO 8601 not-after dates compare as strings. - cur_end_iso=$(openssl x509 -in "$cur_chain" -noout -enddate -dateopt iso_8601 2>/dev/null | cut -d= -f2 || true) - if [ -n "$cur_end_iso" ] && openssl x509 -in "$cur_chain" -noout -checkend 0 >/dev/null 2>&1 && \ - [ "$new_end_iso" != "$cur_end_iso" ] && \ - [ "$(printf '%s\n%s\n' "$new_end_iso" "$cur_end_iso" | sort | head -n 1)" = "$new_end_iso" ]; then - fail "fetched certificate (not after $new_end_iso) expires before the installed one ($cur_end_iso)" - fi -fi - -# 4. Install: mode 0640, rename into place (key first, so a reload never pairs a new chain with an old key for long). -install -m 0640 "$new_key" "$CERTS/.privkey.pem.new" || fail "cannot write $CERTS" -install -m 0640 "$new_chain" "$CERTS/.fullchain.pem.new" || fail "cannot write $CERTS" -mv -f "$CERTS/.privkey.pem.new" "$cur_key" -mv -f "$CERTS/.fullchain.pem.new" "$cur_chain" -log "installed new certificate (not after $new_end_iso)" - -# 5. Reload Caddy. At first start Caddy is not up yet (it waits for this container to be healthy) and loads the -# files when it starts, so an unreachable admin API is not a failure. -if curl -fsS -o /dev/null --max-time 20 -X POST "$CADDY_ADMIN/load" \ - -H 'Content-Type: text/caddyfile' -H 'Cache-Control: must-revalidate' --data-binary @"$CADDYFILE" 2> "$tmp/curl.err"; then - log "caddy reloaded" -elif curl -fsS -o /dev/null --max-time 5 "$CADDY_ADMIN/config/" 2>/dev/null; then - log "FAILED: caddy reload: $(head -c 300 "$tmp/curl.err" | tr '\n' ' '); the new files are in place and load when Caddy restarts" - exit 1 -else - log "caddy not reachable; it loads the certificate when it starts" -fi diff --git a/infra/pi/.env.example b/infra/pi/.env.example index b158071..f021ef1 100644 --- a/infra/pi/.env.example +++ b/infra/pi/.env.example @@ -4,20 +4,21 @@ # --- Read by compose.yaml ----------------------------------------------------------------------------------------- -# Registry and owner path of the Kvitt images; images are ${KVITT_REGISTRY}/kvitt-server|kvitt-cert-sync|kvitt-backup. +# Registry and owner path of the Kvitt images; images are ${KVITT_REGISTRY}/kvitt-server|kvitt-backup. KVITT_REGISTRY=gitea.lan/jacobh2 # Image tag to run. deploy.sh sets it; first install: the newest version tag the Gitea workflow pushed (vMAJOR.MINOR.PATCH), # or the short git SHA that infra/build-images.sh printed. KVITT_TAG=CHANGE_ME_TAG -# LAN address (or host name) of the Home Assistant Pi that holds the Let's Encrypt certificate (OQ-3). +# Kvitt's own public host name (D-120), owner-chosen: a domain or DuckDNS name pointing at the home's public address. +# NPM holds its Let's Encrypt certificate; the router forwards external 8443 to NPM's 443. Invite links and the +# landing page use https://:8443 (KVITT_PUBLIC_URL). Not icehack.asuscomm.com: that name is +# NPM's proxy host for Home Assistant. +KVITT_PUBLIC_HOST=CHANGE_ME_KVITT_PUBLIC_HOST +# LAN IPv4 address of the Home Assistant Pi, where Nginx Proxy Manager terminates TLS and forwards to this Pi's port +# 8080 (D-120). The server trusts X-Forwarded-For only from this address (KVITT_TRUSTED_PROXY), so it must be the +# address NPM's connections come from: an IP literal such as 192.168.1.20, not a host name (install.sh checks it). +# Kept the name HA_HOST (the proxy host); it no longer has the SSH settings HA_PORT, HA_USER, HA_CERT_DIR of cert-sync. 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). 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 # rclone remote of the Drive backups, as named in rclone.conf. RCLONE_REMOTE=gdrive: # Google Drive folder id the backups go to (OQ-4, answered). @@ -30,9 +31,9 @@ KVITT_REGISTRY_USER= # Gitea access token of that user with read:package scope; leave empty for anonymous pull. Never commit a filled .env. KVITT_REGISTRY_TOKEN= -# --- Informational: used in the owner's commands on the Mac (scp), read by nothing on the Pi -------------------- +# --- Informational: used in the owner's commands on the Mac (scp) and in NPM, read by nothing on the Pi --------- -# LAN address of the Kvitt Pi itself (OQ-3). +# LAN address of the Kvitt Pi itself (OQ-3); also NPM's Forward Hostname / IP (infra/README.md, NPM section). KVITT_PI_HOST=CHANGE_ME_KVITT_PI_HOST # SSH user on the Kvitt Pi (OQ-3). KVITT_PI_SSH_USER=CHANGE_ME_KVITT_PI_SSH_USER diff --git a/infra/pi/Caddyfile b/infra/pi/Caddyfile deleted file mode 100644 index 5c2879a..0000000 --- a/infra/pi/Caddyfile +++ /dev/null @@ -1,38 +0,0 @@ -# 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 { - tls /certs/fullchain.pem /certs/privkey.pem - 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 filter { - request>uri regexp `^(/i/)[^?]+|([?]).+` `${1}${2}REDACTED` - wrap json - } - } -} diff --git a/infra/pi/compose.yaml b/infra/pi/compose.yaml index a90b98c..25c908b 100644 --- a/infra/pi/compose.yaml +++ b/infra/pi/compose.yaml @@ -1,7 +1,8 @@ # Kvitt production stack on the Kvitt Pi (SPEC §10.2). Deployed by install.sh to /opt/kvitt/compose.yaml together -# with /opt/kvitt/Caddyfile and /opt/kvitt/.env (from .env.example). Run every command from /opt/kvitt. -# Only caddy publishes a port (8443); the server, Caddy's admin API and the sidecars are reachable only on the -# internal network `kvitt` (SPEC §8.9). +# with /opt/kvitt/.env (from .env.example). Run every command from /opt/kvitt. +# TLS terminates in Nginx Proxy Manager on the Home Assistant Pi (SPEC §10.5, D-120), which forwards plain HTTP over +# the LAN to the server's published port 8080. Only the server publishes a port; backup is reachable only on the +# internal network `kvitt` (SPEC §8.9). The router forwards the internet only to NPM, never to this Pi. # Image names: ${KVITT_REGISTRY}/kvitt-, the names the Gitea Actions workflow pushes (D-104). name: kvitt @@ -13,49 +14,6 @@ x-logging: &logging max-file: "3" services: - caddy: - # 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" - environment: - TZ: Europe/Stockholm - volumes: - - ./Caddyfile:/etc/caddy/Caddyfile:ro - - certs:/certs:ro - - caddy_data:/data - depends_on: - cert-sync: - condition: service_healthy - server: - condition: service_started - healthcheck: - test: ["CMD-SHELL", "wget -qO- http://localhost:2019/config/ >/dev/null"] - interval: 30s - timeout: 5s - retries: 3 - start_period: 10s - logging: *logging - networks: [kvitt] - server: image: ${KVITT_REGISTRY:?set KVITT_REGISTRY in .env}/kvitt-server:${KVITT_TAG:?set KVITT_TAG in .env} command: ["serve"] @@ -66,11 +24,17 @@ services: security_opt: - no-new-privileges:true mem_limit: 160m + # On all interfaces of the Pi: NPM on the Home Assistant Pi connects to :8080 over the LAN. + ports: + - "8080:8080" environment: TZ: Europe/Stockholm - # The remaining server settings keep their SPEC §10.4 defaults (KVITT_PUBLIC_URL - # https://icehack.asuscomm.com:8443, KVITT_TRUSTED_PROXY caddy, KVITT_FCM_PROJECT_ID kvitt-1337, ...). KVITT_DATA_DIR: /data + KVITT_PUBLIC_URL: https://${KVITT_PUBLIC_HOST:?set KVITT_PUBLIC_HOST in .env}:8443 + # NPM's LAN address: only a request whose TCP peer is this address may set X-Forwarded-For, whose last entry + # is then the client IP of the rate limits (SPEC §8.5, §8.6, D-120). An IP literal (install.sh checks it). + KVITT_TRUSTED_PROXY: ${HA_HOST:?set HA_HOST in .env} + # The remaining server settings keep their SPEC §10.4 defaults (KVITT_FCM_PROJECT_ID kvitt-1337, ...). volumes: - data:/data - /opt/kvitt/secrets/fcm-service-account.json:/secrets/fcm-service-account.json:ro @@ -85,34 +49,6 @@ services: logging: *logging networks: [kvitt] - cert-sync: - image: ${KVITT_REGISTRY}/kvitt-cert-sync:${KVITT_TAG} - restart: unless-stopped - user: "1000:1000" - cap_drop: [ALL] - security_opt: - - no-new-privileges:true - mem_limit: 32m - environment: - TZ: Europe/Stockholm - HA_HOST: ${HA_HOST:?set HA_HOST in .env} - HA_PORT: ${HA_PORT:-22} - HA_USER: ${HA_USER:?set HA_USER in .env} - HA_CERT_DIR: ${HA_CERT_DIR:?set HA_CERT_DIR in .env} - volumes: - - certs:/certs - - ./Caddyfile:/etc/caddy/Caddyfile:ro - - /opt/kvitt/secrets/ha_ssh_key:/secrets/ha_ssh_key:ro - - /opt/kvitt/secrets/ha_known_hosts:/secrets/known_hosts:ro - healthcheck: - test: ["CMD-SHELL", "test -s /certs/fullchain.pem && test -s /certs/privkey.pem"] - interval: 30s - timeout: 5s - retries: 3 - start_period: 60s - logging: *logging - networks: [kvitt] - backup: image: ${KVITT_REGISTRY}/kvitt-backup:${KVITT_TAG} restart: unless-stopped @@ -142,8 +78,6 @@ services: volumes: data: - certs: - caddy_data: backups: networks: diff --git a/infra/pi/deploy.sh b/infra/pi/deploy.sh index 721f543..7893867 100755 --- a/infra/pi/deploy.sh +++ b/infra/pi/deploy.sh @@ -5,7 +5,8 @@ # 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, 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 +# docker compose up -d, waits for server to be healthy, prints GET /health on port 8080 and through Nginx +# Proxy Manager (D-120; a failure through NPM is a warning, the deploy itself succeeded) # 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 @@ -55,5 +56,6 @@ if ! wait_healthy server 180; then die "server is not healthy on $tag. Roll back: $KVITT_DIR/deploy.sh ${current:-} --skip-backup" fi compose ps -print_health || die "server is healthy but /health through Caddy failed (docker compose logs caddy cert-sync)" +print_health || die "server is healthy but GET /health on port 8080 failed (docker compose logs server)" +print_proxy_health || say "WARNING: /health through Nginx Proxy Manager failed: Kvitt is not reachable from outside (README, Troubleshooting)" say "Deployed $tag." diff --git a/infra/pi/install-apk.sh b/infra/pi/install-apk.sh index e45519a..a7ddfdb 100755 --- a/infra/pi/install-apk.sh +++ b/infra/pi/install-apk.sh @@ -37,7 +37,6 @@ compose run --rm --no-deps -T -v "$src:/incoming:ro" --entrypoint /bin/sh backup rm -f "/data/apk/kvitt-$v.apk"; echo "removed kvitt-$v.apk" done echo "latest.json installed"' -say "GET https://$PUBLIC_HOST:$PUBLIC_PORT/apk/latest (via this Pi):" -curl -fsS --max-time 15 --resolve "$PUBLIC_HOST:$PUBLIC_PORT:127.0.0.1" "https://$PUBLIC_HOST:$PUBLIC_PORT/apk/latest" \ - || die "GET /apk/latest failed" +say "GET $SERVER_URL/apk/latest:" +curl -fsS --max-time 15 "$SERVER_URL/apk/latest" || die "GET /apk/latest failed" echo diff --git a/infra/pi/install.sh b/infra/pi/install.sh index 0c09c6c..5ce26d5 100755 --- a/infra/pi/install.sh +++ b/infra/pi/install.sh @@ -1,15 +1,17 @@ #!/usr/bin/env bash # Kvitt Pi install (infra/README.md "Pi: what the owner does"). Run ON the Kvitt Pi, as the owner's user, from the # bundle folder copied from the Mac (infra/pi/): cd ~/kvitt-pi && ./install.sh -# Idempotent: safe to run again after changing .env, the secrets, compose.yaml or the Caddyfile. +# Idempotent: safe to run again after changing .env, the secrets or compose.yaml. # 1. checks Docker Engine and the compose plugin -# 2. checks .env (no CHANGE_ME left in the keys the stack reads) -# 3. creates /opt/kvitt/{secrets,import,apk} and installs the secret files from ./secrets/ with their modes +# 2. checks .env (no CHANGE_ME left in the keys the stack reads; KVITT_PUBLIC_HOST a host name, HA_HOST an IPv4 +# address) +# 3. creates /opt/kvitt/{secrets,import,apk}, installs the secret files from ./secrets/ with their modes and +# removes what the Caddy and cert-sync stack of before D-120 left in /opt/kvitt # 4. trusts the plain-HTTP registry in /etc/docker/daemon.json (insecure-registries) and logs in when credentials are set -# 5. copies compose.yaml, Caddyfile, .env and the operation scripts to /opt/kvitt; a KVITT_TAG that deploy.sh set +# 5. copies compose.yaml, .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 (digests recorded in /opt/kvitt/deployed-digests), gives the named volumes their owners, -# starts the stack, waits for /health +# starts the stack, waits for /health on port 8080, then tries /health through Nginx Proxy Manager # Uses sudo for /opt/kvitt, the secret files and the Docker daemon configuration. set -euo pipefail bundle="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -26,26 +28,33 @@ command -v python3 >/dev/null || die "python3 is missing (step 4 edits daemon.js # 2. .env env_src="$bundle/.env" [[ -f "$env_src" ]] || die "no .env next to install.sh: cp .env.example .env and fill it (README step 4)" -for key in KVITT_REGISTRY KVITT_TAG HA_HOST HA_PORT HA_USER HA_CERT_DIR RCLONE_REMOTE RCLONE_FOLDER_ID; do +for key in KVITT_REGISTRY KVITT_TAG KVITT_PUBLIC_HOST HA_HOST RCLONE_REMOTE RCLONE_FOLDER_ID; do value=$(env_value "$key" "$env_src") [[ -n "$value" ]] || die ".env: $key is empty" [[ "$value" != CHANGE_ME* ]] || die ".env: $key is still $value" done +# The server compares the TCP peer with KVITT_TRUSTED_PROXY (= HA_HOST). A host name would have to resolve inside +# the container to exactly NPM's LAN address (mDNS names such as homeassistant.local do not), so require the address. +ha_host=$(env_value HA_HOST "$env_src") +[[ "$ha_host" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] \ + || die ".env: HA_HOST must be the Home Assistant Pi's LAN IPv4 address (for example 192.168.1.20), not $ha_host" +public_host=$(env_value KVITT_PUBLIC_HOST "$env_src") +[[ "$public_host" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?\.[A-Za-z]{2,}$ ]] \ + || die ".env: KVITT_PUBLIC_HOST must be a host name such as kvitt.example.org (no scheme, no port), not $public_host" +[[ "$public_host" != icehack.asuscomm.com ]] \ + || die ".env: KVITT_PUBLIC_HOST cannot be icehack.asuscomm.com, NPM's proxy host for Home Assistant (D-120)" registry=$(env_value KVITT_REGISTRY "$env_src") 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, uid 10002 = caddy (SPEC §10.2, -# §10.7, D-023, D-105, D-117). +# 3. Directories and secrets. uid 10001 = server and backup (SPEC §10.2, §10.7, D-105). 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" # name : owner uid : group : mode secret_specs=( "fcm-service-account.json:0:10001:0440" - "ha_ssh_key:1000:1000:0600" - "ha_known_hosts:1000:1000:0600" "rclone.conf:10001:10001:0600" ) missing=() @@ -71,6 +80,13 @@ done if [[ -d "$bundle/secrets" ]]; then say " The copies in $bundle/secrets/ are no longer needed: rm -rf $bundle/secrets" fi +# Left by the Caddy and cert-sync stack of before D-120: the Home Assistant SSH key must not stay on this Pi. +for old in "$KVITT_DIR/secrets/ha_ssh_key" "$KVITT_DIR/secrets/ha_known_hosts" "$KVITT_DIR/Caddyfile"; do + if sudo test -e "$old"; then + sudo rm -rf "$old" + say " removed $old (not used since D-120)" + fi +done # 4. Registry trust (plain HTTP on the home Wi-Fi, SPEC §10.3) and optional login. daemon_json=/etc/docker/daemon.json @@ -113,7 +129,6 @@ installed_tag="" [[ -f "$KVITT_DIR/.env" ]] && installed_tag=$(env_value KVITT_TAG) bundle_tag=$(env_value KVITT_TAG "$env_src") install -m 0644 "$bundle/compose.yaml" "$KVITT_DIR/compose.yaml" -install -m 0644 "$bundle/Caddyfile" "$KVITT_DIR/Caddyfile" install -m 0600 "$env_src" "$KVITT_DIR/.env" if [[ -n "$installed_tag" && "$installed_tag" != "$bundle_tag" ]]; then env_set KVITT_TAG "$installed_tag" @@ -122,7 +137,7 @@ fi for script in lib.sh deploy.sh bootstrap.sh backup-now.sh install-apk.sh; do install -m 0755 "$bundle/$script" "$KVITT_DIR/$script" done -say "Installed compose.yaml, Caddyfile, .env and the scripts in $KVITT_DIR" +say "Installed compose.yaml, .env and the scripts in $KVITT_DIR" # 6. Pull, volume owners, start. compose config --quiet @@ -133,17 +148,24 @@ compose create # 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' -# /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' +# --remove-orphans also removes the caddy and cert-sync containers of a stack installed before D-120. compose up -d --remove-orphans +# Their volumes: certs held a copy of the certificate's private key. +for volume in kvitt_certs kvitt_caddy_data; do + if docker volume inspect "$volume" >/dev/null 2>&1; then + if docker volume rm "$volume" >/dev/null; then + say "Removed the volume $volume (not used since D-120)" + else + say "Could not remove the volume $volume (still in use?): docker volume rm $volume" + fi + fi +done ok=1 wait_healthy server 180 || ok=0 -wait_healthy cert-sync 120 || { ok=0; say "cert-sync has no certificate yet: docker compose logs cert-sync (README, Troubleshooting)"; } -wait_healthy caddy 120 || ok=0 compose ps print_health || ok=0 (( ok == 1 )) || die "the stack is not fully up yet; see the lines above" -say "Kvitt is running. Next: $KVITT_DIR/bootstrap.sh (first install only)." +if ! print_proxy_health; then + say "Not reachable through Nginx Proxy Manager yet: expected until its proxy host is set up (README step 7)." +fi +say "Kvitt is running. Next: README step 7 (NPM proxy host), then $KVITT_DIR/bootstrap.sh (first install only)." diff --git a/infra/pi/lib.sh b/infra/pi/lib.sh index 695db49..302701c 100755 --- a/infra/pi/lib.sh +++ b/infra/pi/lib.sh @@ -4,8 +4,10 @@ # shellcheck disable=SC2034 # variables are used by the scripts that source this file KVITT_DIR=${KVITT_DIR:-/opt/kvitt} -PUBLIC_HOST=icehack.asuscomm.com -PUBLIC_PORT=8443 +# The server's port, published on the Pi's LAN interfaces; Nginx Proxy Manager forwards to it (D-120). +SERVER_URL=http://localhost:8080 +# Nginx Proxy Manager's HTTPS port on the Home Assistant Pi (the router forwards external 8443 to it, D-120). +NPM_HTTPS_PORT=443 say() { printf '%s\n' "$*"; } die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } @@ -56,14 +58,29 @@ wait_healthy() { done } -# GET /health through Caddy on this Pi, with the real certificate check (no -k): proves TLS and the proxy path. +# GET /health from the server's published port on this Pi. print_health() { local out - if out=$(curl -fsS --max-time 15 --resolve "$PUBLIC_HOST:$PUBLIC_PORT:127.0.0.1" "https://$PUBLIC_HOST:$PUBLIC_PORT/health" 2>&1); then - say "GET https://$PUBLIC_HOST:$PUBLIC_PORT/health (via this Pi): $out" + if out=$(curl -fsS --max-time 15 "$SERVER_URL/health" 2>&1); then + say "GET $SERVER_URL/health: $out" return 0 fi - say "GET https://$PUBLIC_HOST:$PUBLIC_PORT/health (via this Pi) failed: $out" + say "GET $SERVER_URL/health failed: $out" + return 1 +} + +# GET /health through Nginx Proxy Manager on the Home Assistant Pi (HA_HOST in .env) for KVITT_PUBLIC_HOST, with +# the real certificate check (no -k): proves NPM's proxy host, its certificate and the LAN path to this Pi's port +# 8080. The router forward is not on this path. +print_proxy_health() { + local out ha_host host + ha_host=$(env_value HA_HOST) + host=$(env_value KVITT_PUBLIC_HOST) + if out=$(curl -fsS --max-time 15 --resolve "$host:$NPM_HTTPS_PORT:$ha_host" "https://$host:$NPM_HTTPS_PORT/health" 2>&1); then + say "GET https://$host:$NPM_HTTPS_PORT/health (via NPM at $ha_host): $out" + return 0 + fi + say "GET https://$host:$NPM_HTTPS_PORT/health (via NPM at $ha_host) failed: $out" return 1 }