Files
Audiobuzz 1db3977238 fleet-manager: auto-renew the agent-plane server cert (step-ca sidecar + hot-reload)
The agent-plane server cert was issued by hand (server-pki.sh / docker exec
step ca certificate), which needs the step CLI on the host, re-specifies SANs
each time, and silently expires at the CA's 90-day cap — the exact failure that
just broke agent TLS (cert minted with a stale SAN, missing manager.fleet.lab).

Automate it:
- agent-cert-renewer sidecar (smallstep step-cli image, compose profile
  cert-auto): issues the cert once from the co-located fleet step-ca, then
  'step ca renew --daemon' rotates it before the cap. Renewal preserves the
  subject + SANs, so SAN drift cannot recur. Chowns to the manager's nonroot
  uid so the distroless manager reads the key. Opt-in + only valid where the CA
  is co-located; server-pki.sh stays as the manual/remote-CA path.
- manager hot-reloads via GetCertificate (certreload.go): a rotated cert on
  disk is picked up on the next handshake with no restart and no dropped agent
  streams; a half-written pair keeps the last good cert serving. Unit-tested.
- defaults.env documents the cert-auto knobs; DEPLOY.md §2a recommends the
  renewer and flags the mandatory manager.fleet.lab SAN.
2026-07-10 07:42:18 +00:00

99 lines
4.8 KiB
Bash
Executable File

#!/usr/bin/env bash
# server-pki.sh — issue the fleet-manager AGENT-plane server cert from the fleet
# CA, and stage the fleet root as the agent client-verify CA.
#
# The agent plane is gRPC + mTLS: enrolled hosts present their TPM identity cert
# (fleet CA), and they verify the MANAGER's server cert against the same fleet
# root they hold. So the manager's agent-plane server cert MUST be signed by the
# fleet CA — this script issues it via `step ca certificate` against the running
# fleet step-ca (serving/dot1x-bench/), the same CA that signs enrolment certs.
#
# NOTE: where the fleet step-ca is CO-LOCATED on this host, prefer the
# `agent-cert-renewer` compose sidecar (docker-compose.yml, profile cert-auto) —
# it does this issuance AND auto-renews before the CA's ~90-day cap, preserving
# SANs, with no `step` CLI on the host. This script is the manual / remote-CA path.
#
# This does NOT issue:
# - the ADMIN-plane UI cert -> owned by the Traefik ingress (Let's Encrypt
# DNS-01); the manager never terminates TLS. See DEPLOY.md §2c.
# - the manager's BROKER client cert -> ../enrol-broker/admin-pki.sh (admin CA):
# ../enrol-broker/admin-pki.sh ../dot1x-bench/pki/broker fleet-manager
#
# Usage: server-pki.sh
# env overrides:
# OUT=./pki/agent output dir
# AGENT_SANS=manager.fleet.lab,10.0.0.1 SANs hosts dial (DNS/IP, comma-sep)
# FLEET_CA_URL=https://ca.fleet.lab:8443
# FLEET_CA_ROOT=../dot1x-bench/pki/root_ca.crt
# STEP_PROVISIONER=enrol
# STEP_PROVISIONER_PASSWORD_FILE=../dot1x-bench/pki/stepca/secrets/password
# CERT_NOT_AFTER=8760h
set -euo pipefail
OUT=${OUT:-./pki/agent}
AGENT_SANS=${AGENT_SANS:-manager.fleet.lab}
FLEET_CA_URL=${FLEET_CA_URL:-https://ca.fleet.lab:8443}
FLEET_CA_ROOT=${FLEET_CA_ROOT:-../dot1x-bench/pki/root_ca.crt}
FLEET_CA_INTERMEDIATE=${FLEET_CA_INTERMEDIATE:-../dot1x-bench/pki/intermediate_ca.crt}
STEP_PROVISIONER=${STEP_PROVISIONER:-enrol}
STEP_PROVISIONER_PASSWORD_FILE=${STEP_PROVISIONER_PASSWORD_FILE:-../dot1x-bench/pki/stepca/secrets/password}
CERT_NOT_AFTER=${CERT_NOT_AFTER:-8760h}
command -v step >/dev/null || { echo "error: 'step' CLI not found (smallstep/cli)"; exit 1; }
[ -f "$FLEET_CA_ROOT" ] || { echo "error: fleet root not found at $FLEET_CA_ROOT (bootstrap the fleet CA first)"; exit 1; }
[ -f "$STEP_PROVISIONER_PASSWORD_FILE" ] || { echo "error: provisioner password not found at $STEP_PROVISIONER_PASSWORD_FILE"; exit 1; }
mkdir -p "$OUT"
# First SAN is the CN; every SAN (DNS name / IP) hosts might dial goes in --san.
primary=${AGENT_SANS%%,*}
san_args=()
IFS=',' read -ra sans <<<"$AGENT_SANS"
for s in "${sans[@]}"; do san_args+=(--san "$s"); done
echo "==> issuing agent-plane server cert (CN=$primary, SANs=$AGENT_SANS)"
step ca certificate "$primary" "$OUT/server.crt" "$OUT/server.key" \
--ca-url "$FLEET_CA_URL" \
--root "$FLEET_CA_ROOT" \
--provisioner "$STEP_PROVISIONER" \
--provisioner-password-file "$STEP_PROVISIONER_PASSWORD_FILE" \
--not-after "$CERT_NOT_AFTER" \
--force \
"${san_args[@]}"
chmod 600 "$OUT/server.key"
# fleet-ca.crt = the agent-plane CLIENT CA that verifies host TPM identity certs.
# Host certs are stored LEAF-ONLY in TPM NV and are signed by the step-ca
# INTERMEDIATE, so this MUST be root + intermediate. Root-only fails two ways:
# (1) the manager can't build leaf->intermediate->root, and (2) it advertises only
# the root in its TLS CertificateRequest, so a Go client whose leaf is issued by
# the intermediate sends NO cert at all ("remote error: tls: certificate
# required"). Prefer an explicit intermediate file; else pull it from the chain
# step wrote into the just-issued server.crt (every cert after the leaf).
intermediate=""
if [ -f "$FLEET_CA_INTERMEDIATE" ]; then
intermediate=$(cat "$FLEET_CA_INTERMEDIATE")
else
intermediate=$(awk 'BEGIN{c=0} /-----BEGIN CERTIFICATE-----/{c++} c>1' "$OUT/server.crt")
fi
{
[ -n "$intermediate" ] && printf '%s\n' "$intermediate"
cat "$FLEET_CA_ROOT"
} > "$OUT/fleet-ca.crt"
if [ -z "$intermediate" ]; then
echo "WARNING: no intermediate CA found (set FLEET_CA_INTERMEDIATE, or the CA has none) —" >&2
echo " fleet-ca.crt is ROOT-ONLY; leaf-only host certs issued by an intermediate" >&2
echo " will fail mTLS with 'tls: certificate required'." >&2
fi
echo
echo "done. staged in $OUT :"
echo " server.crt / server.key -> FLEET_MGR_AGENT_CERT / _KEY"
echo " fleet-ca.crt -> FLEET_MGR_AGENT_CLIENT_CA (verifies host TPM certs)"
echo
echo "next:"
echo " - admin UI cert : owned by the Traefik ingress (LE DNS-01) — DEPLOY.md §2c"
echo " - broker client : ../enrol-broker/admin-pki.sh ../dot1x-bench/pki/broker fleet-manager"
echo " then: cp -r ../dot1x-bench/pki/broker ./pki/broker"