Files
rollcall/deploy/rollcall-intermediate-renew.sh
Ilker ManapandClaude Opus 5.5 05861bd413
CI / go (push) Successful in 51s
CI / agent (push) Successful in 3s
CI / site (push) Successful in 3s
Intermediates renew themselves through the region
The region keeps every intermediate it signs: its name constraints,
certificate, the root that signed it and a renewal token (stored hashed,
printed once). `rollcall ca intermediate` goes through the API now (so it
works from any admin client) and writes NAME.token beside NAME.pem;
`rollcall ca intermediates` lists them.

The host with the intermediate's key runs deploy/rollcall-intermediate-
renew.sh daily (rollcall-intermediate-renew@NAME.timer): it posts a CSR
from the same key with the token to /api/v1/ca/intermediates/NAME/renew.
The region signs again in the last third of the certificate's life, or
at once when another root signs since a rotation's switch, always with
the constraints it was first given; otherwise 204. The script checks the
answer (a CA certificate for its key, from a root it holds), keeps the
old file, keeps the region's roots and the active root current from
/ca-bundle.pem, and reloads (RELOAD).

Attention: an intermediate under 30 days from its end, whose last renewal
failed or whose host has not asked for three days shows in rollcall
doctor, on the web UI's overview, and one from the old root in the
rotation status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 17:33:31 +01:00

58 lines
3.4 KiB
Bash

#!/bin/sh
# rollcall-intermediate-renew [-force] NAME: keep a delegated intermediate CA
# current. Runs daily on the host that holds the intermediate's key (an ACME
# server: Caddy, step-ca), from /etc/rollcall-intermediate/NAME.env:
# URL=https://region.example the region (HTTPS)
# TOKEN_FILE=/etc/rollcall-intermediate/NAME.token from `rollcall ca intermediate`
# CERT=/etc/caddy/pki/int.pem the intermediate's certificate (replaced)
# KEY=/etc/caddy/pki/int-key.pem its key (stays; the CSR is made from it)
# ROOT=/etc/caddy/pki/root.pem optional: kept to the active root
# CA=/etc/rollcall-intermediate/NAME-bundle.pem the region's roots (seeded from ROOT, kept current)
# RELOAD='systemctl restart caddy' optional: after a new certificate
# The region decides whether a new certificate is due (the last third of its
# life, or a root rotation) and signs with the constraints it was given.
set -eu
force=
[ "${1:-}" = -force ] && { force=1; shift; }
NAME=${1:?usage: rollcall-intermediate-renew [-force] NAME}
. "/etc/rollcall-intermediate/$NAME.env"
: "${URL:?}" "${TOKEN_FILE:?}" "${CERT:?}" "${KEY:?}"
CA=${CA:-/etc/rollcall-intermediate/$NAME-bundle.pem}
if [ ! -s "$CA" ]; then
[ -n "${ROOT:-}" ] && [ -s "$ROOT" ] || { echo "rollcall: $CA missing and no ROOT to start it from" >&2; exit 1; }
cp "$ROOT" "$CA"
fi
T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
# the region's roots, checked against the ones held: a rotation reaches here during prepare
if curl -fsS --max-time 30 --cacert "$CA" -o "$T/bundle" "$URL/ca-bundle.pem" && grep -q 'BEGIN CERTIFICATE' "$T/bundle"; then
cmp -s "$T/bundle" "$CA" || { cat "$T/bundle" > "$CA"; echo "rollcall: $NAME: the region's roots changed; $CA updated"; }
fi
openssl req -new -key "$KEY" -subj "/CN=$NAME" -out "$T/csr" 2>/dev/null
q=; [ -n "$force" ] && q='?force=1'
code=$(curl -sS --max-time 60 --cacert "$CA" -o "$T/cert" -w '%{http_code}' -H "Authorization: Bearer $(cat "$TOKEN_FILE")" \
-H 'Accept: application/x-pem-file' --data-binary @"$T/csr" "$URL/api/v1/ca/intermediates/$NAME/renew$q") || { echo "rollcall: $NAME: the region did not answer" >&2; exit 1; }
case $code in
204) exit 0 ;; # nothing due
200) ;;
*) echo "rollcall: $NAME: renewal refused (HTTP $code): $(cat "$T/cert")" >&2; exit 1 ;;
esac
# what came back: a CA certificate for this key, from a root held here
openssl x509 -in "$T/cert" -noout -text | grep -q 'CA:TRUE' || { echo "rollcall: $NAME: the answer is not a CA certificate" >&2; exit 1; }
[ "$(openssl x509 -in "$T/cert" -noout -pubkey)" = "$(openssl pkey -in "$KEY" -pubout)" ] || { echo "rollcall: $NAME: the certificate is not for $KEY" >&2; exit 1; }
openssl verify -CAfile "$CA" "$T/cert" >/dev/null || { echo "rollcall: $NAME: the certificate does not verify against $CA" >&2; exit 1; }
put() { # $1 new content file, $2 target: same owner and mode, the old one kept as .prev
[ -f "$2" ] && cp -p "$2" "$2.prev"
if [ -f "$2" ]; then cat "$1" > "$2"; else install -m 0644 "$1" "$2"; fi
}
put "$T/cert" "$CERT"
if [ -n "${ROOT:-}" ]; then # the active root: the first certificate of the bundle
awk '/-----BEGIN CERTIFICATE-----/{n++} n==1{print} /-----END CERTIFICATE-----/ && n==1{exit}' "$CA" > "$T/root"
cmp -s "$T/root" "$ROOT" || put "$T/root" "$ROOT"
fi
echo "rollcall: $NAME renewed: $(openssl x509 -in "$CERT" -noout -enddate)"
[ -n "${RELOAD:-}" ] && sh -c "$RELOAD"
exit 0