Add unsealer, internal CA/TLS, auto-unseal, and automated cert+backup
Build out the home-lab OpenBAO deployment beyond the basic node: - docker-compose: add openbao-unsealer sidecar; main node now transit auto-unseals against it (seal config kept in gitignored config/seal.hcl) - policies/admin.hcl: non-root admin policy; per-engine rules for ssh/pki/pki_int/totp/transit - Internal two-tier CA (pki/ root + pki_int/ intermediate) issues the openbao.famfi.home leaf Traefik serves; root CA published under ca/ - scripts/ + systemd/: daily cert renewal and Raft snapshot backups (both instances), with scoped tokens stored outside the repo - README: full runbook (auto-unseal, PKI, renewal, backups, DR/restore) Secrets (init/unsealer keys, tokens, seal stanza) stay gitignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
55
scripts/backup-raft-snapshots.sh
Executable file
55
scripts/backup-raft-snapshots.sh
Executable file
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# Take Raft snapshots of BOTH OpenBAO instances (main + unsealer) and prune old
|
||||
# ones. Run as root (reads the root-only backup tokens, writes /var/backups).
|
||||
#
|
||||
# DR note: a restored MAIN snapshot can only be unsealed with the unsealer's
|
||||
# transit key — so the unsealer snapshot (+ its unseal key in unsealer-init.json
|
||||
# + the recovery keys in init-output.json) are part of the same recovery set.
|
||||
#
|
||||
# Installed as a systemd timer (openbao-backup.timer). Manual run:
|
||||
# sudo /home/lutz/Projects/OpenBAO/scripts/backup-raft-snapshots.sh
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_DIR="/home/lutz/Projects/OpenBAO"
|
||||
BACKUP_ROOT="${BACKUP_ROOT:-/var/backups/openbao}"
|
||||
KEEP="${KEEP:-14}" # how many snapshots to retain per instance
|
||||
STAMP="$(date '+%Y%m%d-%H%M%S')"
|
||||
|
||||
log() { printf '%s [backup] %s\n' "$(date '+%F %T')" "$*"; }
|
||||
die() { log "ERROR: $*"; exit 1; }
|
||||
|
||||
cd "$PROJECT_DIR" || die "cannot cd to $PROJECT_DIR"
|
||||
|
||||
# snapshot <service> <token-file> <label>
|
||||
snapshot() {
|
||||
local svc="$1" tokfile="$2" label="$3"
|
||||
[ -r "$tokfile" ] || die "token file $tokfile not readable (run as root?)"
|
||||
local tok dir out incontainer="/tmp/${label}-${STAMP}.snap"
|
||||
tok="$(cat "$tokfile")"
|
||||
dir="${BACKUP_ROOT}/${label}"
|
||||
out="${dir}/openbao-${label}-${STAMP}.snap"
|
||||
install -d -o root -g root -m 0700 "$dir"
|
||||
|
||||
docker compose exec -T -e BAO_TOKEN="$tok" "$svc" \
|
||||
bao operator raft snapshot save "$incontainer" >/dev/null \
|
||||
|| die "snapshot save failed for $svc"
|
||||
docker compose cp "${svc}:${incontainer}" "$out" >/dev/null \
|
||||
|| die "copy-out failed for $svc"
|
||||
docker compose exec -T "$svc" rm -f "$incontainer" >/dev/null 2>&1 || true
|
||||
chmod 0600 "$out"
|
||||
|
||||
# Validate: non-trivial size + gzip integrity (a raft snapshot is a gzip archive)
|
||||
local size; size=$(stat -c %s "$out")
|
||||
[ "$size" -ge 1024 ] || die "snapshot $out suspiciously small (${size}B)"
|
||||
gzip -t "$out" 2>/dev/null && log "OK ${label}: ${out} (${size}B, gzip-valid)" \
|
||||
|| die "snapshot $out failed gzip integrity check"
|
||||
|
||||
# Retention: keep newest $KEEP, delete the rest
|
||||
ls -1t "${dir}"/openbao-${label}-*.snap 2>/dev/null | tail -n +$((KEEP + 1)) | while read -r old; do
|
||||
rm -f -- "$old"; log "pruned old snapshot $(basename "$old")"
|
||||
done
|
||||
}
|
||||
|
||||
snapshot "openbao" "/etc/openbao-backup.token" "main"
|
||||
snapshot "openbao-unsealer" "/etc/openbao-unsealer-backup.token" "unsealer"
|
||||
log "done; retained up to ${KEEP} snapshots per instance under ${BACKUP_ROOT}"
|
||||
75
scripts/renew-openbao-cert.sh
Executable file
75
scripts/renew-openbao-cert.sh
Executable file
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
# Renew the openbao.famfi.home leaf cert from OpenBAO's pki_int engine and
|
||||
# install it where Traefik serves it. Idempotent: only renews when the current
|
||||
# cert expires within $RENEW_WINDOW_DAYS. Run as root (writes Traefik's tls dir).
|
||||
#
|
||||
# Installed as a systemd timer (openbao-cert-renew.timer). Manual run:
|
||||
# sudo /home/lutz/Projects/OpenBAO/scripts/renew-openbao-cert.sh [--force]
|
||||
set -euo pipefail
|
||||
|
||||
ADDR="${BAO_ADDR:-http://127.0.0.1:8200}"
|
||||
TOKEN_FILE="/etc/openbao-cert-renew.token"
|
||||
ROLE="pki_int/issue/famfi-home"
|
||||
CN="openbao.famfi.home"
|
||||
TTL="2160h" # 90 days
|
||||
DEST="/srv/TRAEFIK/etc/traefik/tls/openbao"
|
||||
DYN="/srv/TRAEFIK/etc/traefik/traefik.d/tls-openbao.yml"
|
||||
RENEW_WINDOW_DAYS="${RENEW_WINDOW_DAYS:-21}" # renew when <= this many days left
|
||||
FORCE="${1:-}"
|
||||
|
||||
log() { printf '%s [renew-cert] %s\n' "$(date '+%F %T')" "$*"; }
|
||||
die() { log "ERROR: $*"; exit 1; }
|
||||
|
||||
[ -r "$TOKEN_FILE" ] || die "token file $TOKEN_FILE not readable (run as root?)"
|
||||
TOKEN="$(cat "$TOKEN_FILE")"
|
||||
|
||||
# Skip if the current cert is still good (unless --force)
|
||||
if [ "$FORCE" != "--force" ] && [ -f "$DEST/fullchain.pem" ]; then
|
||||
end="$(openssl x509 -in "$DEST/fullchain.pem" -noout -enddate | cut -d= -f2)"
|
||||
days_left=$(( ( $(date -d "$end" +%s) - $(date +%s) ) / 86400 ))
|
||||
if [ "$days_left" -gt "$RENEW_WINDOW_DAYS" ]; then
|
||||
log "cert valid ${days_left}d (> ${RENEW_WINDOW_DAYS}d) — nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
log "cert has ${days_left}d left (<= ${RENEW_WINDOW_DAYS}d) — renewing"
|
||||
fi
|
||||
|
||||
# Issue a fresh cert
|
||||
resp="$(curl -sS --fail-with-body --max-time 15 \
|
||||
-H "X-Vault-Token: ${TOKEN}" \
|
||||
--data "{\"common_name\":\"${CN}\",\"ttl\":\"${TTL}\"}" \
|
||||
"${ADDR}/v1/${ROLE}")" || die "issue request failed"
|
||||
|
||||
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
|
||||
export RESP="$resp"
|
||||
python3 - "$tmp" <<PY
|
||||
import json, sys, os
|
||||
d = json.loads(os.environ["RESP"])["data"]
|
||||
t = sys.argv[1]
|
||||
open(f"{t}/fullchain.pem", "w").write(d["certificate"] + "\n" + "\n".join(d.get("ca_chain", [])) + "\n")
|
||||
open(f"{t}/privkey.pem", "w").write(d["private_key"] + "\n")
|
||||
PY
|
||||
|
||||
# Validate cert/key match before installing
|
||||
cmod="$(openssl x509 -in "$tmp/fullchain.pem" -noout -modulus | openssl md5)"
|
||||
kmod="$(openssl rsa -in "$tmp/privkey.pem" -noout -modulus 2>/dev/null | openssl md5)"
|
||||
[ "$cmod" = "$kmod" ] || die "cert/key modulus mismatch — refusing to install"
|
||||
|
||||
install -d -o root -g root -m 0755 "$DEST"
|
||||
install -o root -g root -m 0644 "$tmp/fullchain.pem" "$DEST/fullchain.pem"
|
||||
install -o root -g root -m 0600 "$tmp/privkey.pem" "$DEST/privkey.pem"
|
||||
|
||||
# Rewrite the watched dynamic config (with a fresh timestamp) so Traefik's file
|
||||
# provider reparses and reloads the cert from disk — changing the cert file
|
||||
# alone does NOT trigger a reload.
|
||||
cat > "$DYN" <<YML
|
||||
# AUTO-MANAGED by renew-openbao-cert.sh — last renewed $(date -Iseconds).
|
||||
# Loads OpenBAO's leaf cert for openbao.famfi.home; the openbao router uses
|
||||
# \`tls: {}\` and serves this by SNI.
|
||||
tls:
|
||||
certificates:
|
||||
- certFile: /etc/traefik/tls/openbao/fullchain.pem
|
||||
keyFile: /etc/traefik/tls/openbao/privkey.pem
|
||||
YML
|
||||
chmod 0644 "$DYN"
|
||||
log "installed new cert ($(openssl x509 -in "$DEST/fullchain.pem" -noout -enddate | cut -d= -f2)); Traefik reload triggered"
|
||||
Reference in New Issue
Block a user