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