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