2476667510
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
96 lines
5.1 KiB
Bash
Executable File
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
|