#!/usr/bin/env bash set -euo pipefail # CASAN state backup / restore. Backups are self-verifying and restore only # into an explicit empty directory; production requires encryption at rest. # # state-backup.sh create [--out DIR] # state-backup.sh verify [--out DIR] # state-backup.sh restore --out DIR --to EMPTY_DIR # state-backup.sh drill [--out DIR] # # Env: CASAN_BACKUP_PASSPHRASE_FILE is required in prod and optional elsewhere. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/casan-paths.sh" MODE="${1:-}"; ID="${2:-}" shift 2 2>/dev/null || true OUT_ROOT="${CASAN_BACKUP_ROOT:-$CASAN_APP_ROOT/backups/casan-state}" TO="" while [[ "$#" -gt 0 ]]; do case "$1" in --out) OUT_ROOT="${2:-}"; shift 2 ;; --to) TO="${2:-}"; shift 2 ;; *) echo "STATE_BACKUP_FAIL reason=unknown_option option=$1" >&2; exit 64 ;; esac done fail() { echo "STATE_BACKUP_FAIL $*" >&2; exit 1; } [[ "$ID" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$ ]] || { echo "STATE_BACKUP_FAIL reason=invalid_backup_id" >&2; exit 64; } OUT_ROOT="$(cd "$(dirname "$OUT_ROOT")" 2>/dev/null && pwd)/$(basename "$OUT_ROOT")" PACK_DIR="$OUT_ROOT/$ID" ARCHIVE="$PACK_DIR/state.tar.gz" ENCRYPTED="$PACK_DIR/state.tar.gz.enc" MANIFEST="$PACK_DIR/manifest.json" passphrase_file() { local key="${CASAN_BACKUP_PASSPHRASE_FILE:-}" if [[ "${CASAN_PROFILE:-}" == "prod" && ( -z "$key" || ! -s "$key" ) ]]; then fail "reason=prod_encryption_key_required" fi [[ -z "$key" || -s "$key" ]] || fail "reason=backup_passphrase_file_missing" printf '%s' "$key" } write_manifest() { local payload="$1" encrypted="$2" files="$3" python3 - "$MANIFEST" "$ID" "$payload" "$encrypted" "$files" <<'PY' import hashlib, json, os, sys out, backup_id, payload, encrypted, files = sys.argv[1:] with open(payload, 'rb') as f: digest = hashlib.sha256(f.read()).hexdigest() data = {"schema_version":"1.0", "backup_id":backup_id, "payload":os.path.basename(payload), "encrypted":encrypted == "true", "sha256":digest, "state_files":int(files)} with open(out, 'w', encoding='utf8') as f: json.dump(data, f, sort_keys=True, indent=2); f.write('\n') PY } verify() { [[ -f "$MANIFEST" ]] || fail "reason=manifest_missing" python3 - "$MANIFEST" "$PACK_DIR" <<'PY' import hashlib, json, os, sys manifest, root = sys.argv[1:] data = json.load(open(manifest, encoding='utf8')) payload = data.get('payload', '') if not payload or os.path.basename(payload) != payload: raise SystemExit('payload_invalid') path = os.path.join(root, payload) if not os.path.isfile(path): raise SystemExit('payload_missing') with open(path, 'rb') as f: actual = hashlib.sha256(f.read()).hexdigest() if actual != data.get('sha256'): raise SystemExit('checksum_mismatch') print(json.dumps(data, sort_keys=True)) PY } case "$MODE" in create) [[ -d "$CASAN_STATE_ROOT" ]] || fail "reason=state_root_missing" [[ ! -e "$PACK_DIR" ]] || fail "reason=backup_already_exists" mkdir -p "$PACK_DIR" || fail "reason=backup_dir_create_failed" TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT # State is copied by relative path only. The archive cannot contain an # arbitrary absolute filesystem path even when CASAN_STATE_ROOT is overridden. tar -czf "$TMP/state.tar.gz" -C "$(dirname "$CASAN_STATE_ROOT")" "$(basename "$CASAN_STATE_ROOT")" COUNT="$(tar -tzf "$TMP/state.tar.gz" | wc -l | tr -d '[:space:]')" KEY="$(passphrase_file)" if [[ -n "$KEY" ]]; then command -v openssl >/dev/null 2>&1 || fail "reason=openssl_required_for_encryption" openssl enc -aes-256-cbc -pbkdf2 -salt -in "$TMP/state.tar.gz" -out "$ENCRYPTED" -pass "file:$KEY" write_manifest "$ENCRYPTED" true "$COUNT" rm -f "$TMP/state.tar.gz" else mv "$TMP/state.tar.gz" "$ARCHIVE" write_manifest "$ARCHIVE" false "$COUNT" fi chmod 700 "$PACK_DIR"; chmod 600 "$MANIFEST" "$PACK_DIR"/state.tar.gz* 2>/dev/null || true echo "STATE_BACKUP_CREATED id=$ID files=$COUNT encrypted=$([[ -n "$KEY" ]] && echo true || echo false) dir=$PACK_DIR" ;; verify) RESULT="$(verify)" || fail "reason=manifest_verification_failed" echo "STATE_BACKUP_VALID id=$ID $RESULT" ;; restore) [[ -n "$TO" ]] || { echo "STATE_BACKUP_FAIL reason=restore_target_required" >&2; exit 64; } RESULT="$(verify)" || fail "reason=manifest_verification_failed" [[ ! -e "$TO" || ( -d "$TO" && -z "$(find "$TO" -mindepth 1 -maxdepth 1 -print -quit)" ) ]] || fail "reason=restore_target_not_empty" mkdir -p "$TO" TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT ENCRYPTED_FLAG="$(python3 -c 'import json,sys; print(str(json.loads(sys.argv[1])["encrypted"]).lower())' "$RESULT")" if [[ "$ENCRYPTED_FLAG" == true ]]; then KEY="$(passphrase_file)"; [[ -n "$KEY" ]] || fail "reason=decrypt_key_required" openssl enc -d -aes-256-cbc -pbkdf2 -in "$ENCRYPTED" -out "$TMP/state.tar.gz" -pass "file:$KEY" || fail "reason=decrypt_failed" else cp "$ARCHIVE" "$TMP/state.tar.gz" fi tar -xzf "$TMP/state.tar.gz" -C "$TO" --no-same-owner echo "STATE_BACKUP_RESTORED id=$ID target=$TO" ;; drill) "$0" create "$ID" --out "$OUT_ROOT" "$0" verify "$ID" --out "$OUT_ROOT" TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT "$0" restore "$ID" --out "$OUT_ROOT" --to "$TMP/restore" ORIGINAL="$(tar -tzf "${ARCHIVE:-$PACK_DIR/state.tar.gz}" 2>/dev/null || true)" [[ -n "$ORIGINAL" || -f "$ENCRYPTED" ]] || fail "reason=drill_archive_missing" [[ -d "$TMP/restore/$(basename "$CASAN_STATE_ROOT")" ]] || fail "reason=drill_restore_shape_invalid" echo "STATE_BACKUP_DRILL_PASS id=$ID restored=true" ;; *) echo "Usage: state-backup.sh {create|verify|restore|drill} [--out DIR] [--to EMPTY_DIR]" >&2; exit 64 ;; esac