Files
kvitt/infra/cert-sync/sync.sh
T
jacobh2 2476667510 M2c-pi: Pi bundle (infra/pi), cert-sync, Drive backup upload, Gitea workflow
The owner copies infra/pi/ to the Kvitt Pi and runs it there (D-100); nothing
on the Mac connects to the Pis. compose stack of SPEC §10.2 with images
${KVITT_REGISTRY}/kvitt-{server,cert-sync,backup}, Caddyfile §10.5.2 verbatim,
.env.example with the OQ-3 values as CHANGE_ME placeholders, install.sh,
deploy.sh (backup before every deploy), bootstrap.sh, backup-now.sh,
install-apk.sh. cert-sync image and sync.sh (§10.5.1, tar stream for both
authorized_keys variants); backup.sh uploads with rclone copy --immutable and
deletes local files once both are confirmed on Drive (D-002); both sidecars
schedule with supercronic because busybox crond runs no jobs as non-root;
backup runs as uid 10001. .gitea/workflows/build-images.yaml from the owner's
example (not yet run); infra/build-images.sh and infra/publish-apk.sh (R8
mapping kept, version per §10.6) on the Mac; README checklist and RESTORE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-18 21:48:35 +02:00

96 lines
5.1 KiB
Bash
Executable File

#!/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 <cert dir> -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