Files
OpenBAO/scripts/offsite-backup.sh
Lutz Finsterle 055676c186 RESTORE.md: document the procedure a real restore drill exposed
Ran the documented restore end to end against an isolated copy (separate
compose project, volumes and network; live stack untouched throughout). It
worked, but only after two steps the docs never mentioned -- so the docs were
wrong in the way that matters: they would have stalled someone mid-outage.

1. A fresh node cannot accept a snapshot restore at all until it has been
   'operator init'-ed and unsealed with THROWAWAY keys. You restore over the
   top, and the throwaway keys stop working the instant the restore lands --
   from there only the original keys in this bundle open it. The old text said
   "restore its snapshot, then unseal it", skipping init entirely.
2. 'docker cp' preserves the snapshot's 0600 root ownership, but since 2.6.0
   the container runs as uid 100 and cannot read its own restore file. The
   error is misleadingly worded ("Error opening policy file: ... permission
   denied") and sends you looking at policies instead of file modes.

Also now documented: main needs a transit token minted on the RESTORED
unsealer for its seal stanza; the unsealer re-seals itself after its restore
and needs the original shamir key; and main then auto-unseals with no manual
step.

Drill results, for the record: Cluster ID of the restored main matched
production, the original root token authenticated, all 13 mounts / 14 policies
/ 4 auth methods came back, and the restored instance decrypted a transit
ciphertext the live instance had produced seconds earlier while issuing a
fresh cert from pki_int -- proving CA private keys and transit key material
survived, not merely the configs. Decrypted plaintext was shredded and the
drill stack removed with its volumes.

Verification guidance now recommends those last two checks specifically,
since data-identical is a weaker claim than operationally functional.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NFtVLA7VVqXL5G2S18c4Jk
2026-08-22 09:59:51 +02:00

212 lines
10 KiB
Bash
Executable File

#!/usr/bin/env bash
# Ship an ENCRYPTED OpenBAO disaster-recovery bundle to the NAS (fids2).
#
# Local snapshots live on the same Pi as OpenBAO, so a dead SD card takes both.
# This copies them off the host. Note fids2 is on the same LAN (192.168.0.234),
# so this is genuinely OFF-HOST but not off-site: it does not protect against
# fire, theft, or ransomware that reaches the whole LAN.
#
# WHY ENCRYPTED: the CIFS share is mounted uid=1000,file_mode=0664, so the
# root-only 0600 protection on /var/backups/openbao is LOST the moment a file
# lands there. The bundle is therefore GPG-encrypted to HOME_SECURE before it
# ever touches the share -- the NAS only ever holds ciphertext, which is also
# what makes it safe to include the unseal material.
#
# NOT encrypted with OpenBAO's transit engine, deliberately: you would need a
# working OpenBAO to decrypt the backup you are restoring because OpenBAO is
# broken. GPG keeps the decryption path independent of the thing being backed up.
#
# This script never talks to OpenBAO, so it still works while OpenBAO is down.
# It ships whatever the newest local snapshot is -- and FAILS LOUDLY if that
# snapshot is stale, which is the exact failure that went unnoticed for 24 days.
set -uo pipefail
SRC_ROOT="${SRC_ROOT:-/var/backups/openbao}"
PROJECT_DIR="/home/lutz/Projects/OpenBAO"
SHARE_ROOT="${SHARE_ROOT:-/home/lutz/nfs_projects}"
DEST_DIR="${DEST_DIR:-${SHARE_ROOT}/backups/openbao}"
RECIPIENT_KEY="/etc/openbao-backup-recipient.asc"
RECIPIENT="07E23DC55C0FCF76" # HOME_SECURE
KEEP="${KEEP:-30}" # encrypted bundles to retain on the NAS
MAX_SNAP_AGE_H="${MAX_SNAP_AGE_H:-48}" # fail if newest local snapshot older than this
STAMP="$(date '+%Y%m%d-%H%M%S')"
log() { printf '%s [offsite] %s\n' "$(date '+%F %T')" "$*"; }
die() { log "ERROR: $*"; exit 1; }
WORK=""
cleanup() {
[ -n "$WORK" ] && [ -d "$WORK" ] && rm -rf "$WORK"
}
trap cleanup EXIT INT TERM
command -v gpg >/dev/null || die "gpg not found"
[ -r "$RECIPIENT_KEY" ] || die "recipient public key $RECIPIENT_KEY not readable"
# --- 1. locate newest local snapshots, and refuse to ship stale ones --------
newest() { ls -1t "${SRC_ROOT}/$1"/openbao-"$1"-*.snap 2>/dev/null | head -1; }
MAIN_SNAP="$(newest main)"
UNSEALER_SNAP="$(newest unsealer)"
[ -n "$MAIN_SNAP" ] || die "no main snapshot found under ${SRC_ROOT}/main"
[ -n "$UNSEALER_SNAP" ] || die "no unsealer snapshot found under ${SRC_ROOT}/unsealer"
for s in "$MAIN_SNAP" "$UNSEALER_SNAP"; do
age_h=$(( ( $(date +%s) - $(stat -c %Y "$s") ) / 3600 ))
[ "$age_h" -le "$MAX_SNAP_AGE_H" ] \
|| die "newest snapshot $(basename "$s") is ${age_h}h old (limit ${MAX_SNAP_AGE_H}h) -- the LOCAL backup is broken; fix that first, shipping a stale DR copy would give false confidence"
done
log "local snapshots fresh: $(basename "$MAIN_SNAP"), $(basename "$UNSEALER_SNAP")"
# --- 2. assemble the DR set in a root-only workdir (never on the share) -----
WORK="$(mktemp -d /root/.openbao-offsite.XXXXXX)" || die "cannot create workdir"
chmod 0700 "$WORK"
BUNDLE="openbao-dr-${STAMP}"
STAGE="${WORK}/${BUNDLE}"
mkdir -p "$STAGE" || die "cannot create staging dir"
cp -p "$MAIN_SNAP" "$STAGE/" || die "cannot stage main snapshot"
cp -p "$UNSEALER_SNAP" "$STAGE/" || die "cannot stage unsealer snapshot"
for f in init-output.json unsealer-init.json; do
[ -r "${PROJECT_DIR}/${f}" ] || die "missing ${PROJECT_DIR}/${f} -- the DR set is incomplete without it"
cp -p "${PROJECT_DIR}/${f}" "$STAGE/" || die "cannot stage $f"
done
cat > "$STAGE/RESTORE.md" <<EOF
# OpenBAO disaster recovery — bundle ${BUNDLE}
Created: $(date -R) on $(hostname -s)
Decrypt with the HOME_SECURE GPG private key (${RECIPIENT}).
## Contents
- $(basename "$MAIN_SNAP") — raft snapshot, MAIN instance
- $(basename "$UNSEALER_SNAP") — raft snapshot, UNSEALER instance
- init-output.json — main: recovery keys + root token
- unsealer-init.json — unsealer: 1-of-1 unseal key + root token
All four are needed together. The main node is transit-auto-unsealed BY the
unsealer, so a main snapshot alone cannot be opened.
## Restore order (order matters)
This procedure was executed end to end as a drill on 2026-08-22 against an
isolated copy; the non-obvious steps below are the ones that drill exposed.
### Two gotchas that will bite you first
- A FRESH node cannot accept a snapshot restore until it has been initialised
and unsealed. You must 'operator init' it with THROWAWAY keys first, restore
over the top, and then unseal with the ORIGINAL keys from this bundle. The
throwaway keys stop working the moment the restore lands.
- 'docker cp' preserves the snapshot's 0600 root ownership, but since 2.6.0
the container runs as uid 100 (openbao) and cannot read it. Fix inside the
container: 'docker exec -u 0 <ctr> chmod 0644 /tmp/restore.snap'. The error
if you skip this is misleadingly worded:
"Error opening policy file: open /tmp/restore.snap: permission denied".
### 1. Unsealer first
a. Start the unsealer container on an empty volume.
b. bao operator init -key-shares=1 -key-threshold=1 (throwaway keys)
then unseal it with that throwaway key.
c. docker cp the unsealer .snap in, chmod 0644 it as root (see above), then:
bao operator raft snapshot restore -force /tmp/restore.snap
d. The node now SEALS itself: the barrier is the original one again.
Unseal it with the key from unsealer-init.json (1-of-1 shamir).
e. Confirm: bao list transit/keys -> must show 'autounseal'.
The original root token in unsealer-init.json works again from here.
### 2. Then main
a. Mint a transit token on the RESTORED unsealer for main's seal stanza:
bao token create -policy=autounseal -period=24h
(the original seal token is inside the restored data too, but minting a
fresh one avoids depending on a value you would have to go dig out)
b. Write config/seal.hcl pointing at the restored unsealer with that token,
mode 0644 so container uid 100 can read it (0600 breaks startup).
c. Start main. It comes up transit-sealed and uninitialised.
bao operator init -recovery-shares=1 -recovery-threshold=1 (throwaway)
d. docker cp the main .snap in, chmod 0644, then:
bao operator raft snapshot restore -force /tmp/restore.snap
e. Main AUTO-UNSEALS via the unsealer's transit key. No manual unseal.
### 3. Verify
- bao status: Sealed=false, Seal Type=transit, and the Cluster ID should match
the production cluster (proving you restored real data, not a fresh init).
- The original root token in init-output.json authenticates.
- Strongest check: have the restored instance decrypt a transit ciphertext
produced by the old instance, and issue a cert from pki_int -- that proves
the CA private keys and transit key material survived, not just the configs.
Note: OpenBAO does NOT support downgrading a raft data dir. Restore onto the
same version the snapshot came from (or newer), never older. These snapshots
came from OpenBAO $(docker exec openbao bao status 2>/dev/null | awk '/^Version/{print $2}' || echo '2.6.x').
## Verify integrity
Each .snap is a gzip tar: 'gzip -t' it, extract, then 'sha256sum -c SHA256SUMS'.
EOF
# --- 3. tar + encrypt in one pass; plaintext never hits disk unencrypted ----
GNUPGHOME_TMP="${WORK}/gnupg"
mkdir -p "$GNUPGHOME_TMP" && chmod 0700 "$GNUPGHOME_TMP"
GNUPGHOME="$GNUPGHOME_TMP" gpg --batch --quiet --import "$RECIPIENT_KEY" 2>/dev/null \
|| die "could not import recipient key"
ENC="${WORK}/${BUNDLE}.tar.gz.gpg"
tar -czf - -C "$WORK" "$BUNDLE" \
| GNUPGHOME="$GNUPGHOME_TMP" gpg --batch --quiet --trust-model always \
--recipient "$RECIPIENT" --encrypt --output "$ENC" \
|| die "tar/encrypt pipeline failed"
[ -s "$ENC" ] || die "encrypted bundle is empty"
# Plaintext staging is no longer needed — remove before touching the network.
rm -rf "$STAGE"
ENC_SIZE=$(stat -c %s "$ENC")
ENC_SHA=$(sha256sum "$ENC" | cut -d' ' -f1)
log "encrypted bundle built (${ENC_SIZE}B, sha256 ${ENC_SHA:0:16}...)"
# Sanity: it must actually be a GPG message, not a tar that slipped through.
head -c 3 "$ENC" | grep -q $'\x85\|\x84\|\x8c' 2>/dev/null || true
file_type="$(file -b "$ENC" 2>/dev/null || echo unknown)"
case "$file_type" in
*PGP*|*GPG*|*encrypted*) : ;;
*) die "refusing to upload: bundle does not look encrypted ($file_type)" ;;
esac
# --- 4. ship to the NAS ----------------------------------------------------
# Touch the automount first so the share is live before we probe it.
ls "$SHARE_ROOT" >/dev/null 2>&1
# CRITICAL: prove the destination really is the CIFS share before writing.
# Running as root, a bare `mkdir -p "$DEST_DIR"` SUCCEEDS even when the share
# is not mounted -- it just creates a local directory under the automount
# point, and every "offsite" backup silently lands on the same Pi we are
# trying to survive the loss of. So check the filesystem type of the deepest
# existing ancestor of DEST_DIR, not merely that some path is a mountpoint.
ancestor="$DEST_DIR"
while [ ! -d "$ancestor" ] && [ "$ancestor" != "/" ]; do
ancestor="$(dirname "$ancestor")"
done
fstype="$(stat -f -c %T "$ancestor" 2>/dev/null || echo unknown)"
case "$fstype" in
cifs|smb2|smb3) : ;;
*) die "destination $DEST_DIR resolves to a '$fstype' filesystem, not the CIFS share -- refusing to write a fake 'offsite' copy onto local disk (is the automount for $SHARE_ROOT down?)" ;;
esac
mkdir -p "$DEST_DIR" || die "cannot create $DEST_DIR on the share"
DEST="${DEST_DIR}/${BUNDLE}.tar.gz.gpg"
cp "$ENC" "${DEST}.part" || die "copy to NAS failed"
mv -f "${DEST}.part" "$DEST" || die "atomic rename on NAS failed"
# --- 5. verify the copy that actually landed -------------------------------
REMOTE_SHA=$(sha256sum "$DEST" | cut -d' ' -f1)
[ "$REMOTE_SHA" = "$ENC_SHA" ] \
|| die "checksum mismatch after upload (local ${ENC_SHA:0:16} vs NAS ${REMOTE_SHA:0:16})"
log "uploaded + verified: $DEST"
# --- 6. retention on the NAS ----------------------------------------------
ls -1t "${DEST_DIR}"/openbao-dr-*.tar.gz.gpg 2>/dev/null | tail -n +$((KEEP + 1)) | while read -r old; do
rm -f -- "$old" && log "pruned old offsite bundle $(basename "$old")"
done
COUNT=$(ls -1 "${DEST_DIR}"/openbao-dr-*.tar.gz.gpg 2>/dev/null | wc -l)
log "done; ${COUNT} encrypted bundle(s) offsite, retaining ${KEEP}"