#!/usr/bin/env bash # Restore is intentionally two-phase: verify/extract everything, then touch targets. set -Eeuo pipefail SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) LIB_DIR="$SCRIPT_DIR/../lib/app-backup" [[ -f $LIB_DIR/common.sh ]] || LIB_DIR="$SCRIPT_DIR/../lib" # shellcheck source=lib/common.sh source "$LIB_DIR/common.sh" CONFIG_FILE=/etc/app-backup/app-backup.conf LOCAL_RUN=''; REMOTE_RUN=''; MODE=plan; YES=false; LEGACY=false FILES=true; DATABASES=true; COMPONENTS='' while (( $# )); do case $1 in --config) CONFIG_FILE=${2:?Missing config}; shift 2 ;; --local-run) LOCAL_RUN=${2:?Missing local run}; shift 2 ;; --remote-run) REMOTE_RUN=${2:?Missing remote run}; shift 2 ;; --verify-only) MODE=verify; shift ;; --extract-only) MODE=extract; shift ;; --dry-run) MODE=plan; shift ;; --apply) MODE=apply; shift ;; --yes) YES=true; shift ;; --legacy-unverified) LEGACY=true; shift ;; --no-db) DATABASES=false; shift ;; --no-files) FILES=false; shift ;; --components) COMPONENTS=${2:?Missing comma-separated components}; shift 2 ;; --help) echo 'Usage: app-restore.sh [--config FILE] (--local-run DIR | --remote-run NAME)' echo ' [--dry-run | --verify-only | --extract-only | --apply --yes]' echo ' [--no-db] [--no-files] [--components wordpress,db-wordpress,...]' echo ' Legacy archives: --legacy-unverified --extract-only' exit 0 ;; *) die "Unknown argument: $1" ;; esac done [[ -n $LOCAL_RUN && -z $REMOTE_RUN || -z $LOCAL_RUN && -n $REMOTE_RUN ]] || die 'Choose exactly one backup source' [[ $MODE != apply || $YES == true ]] || die 'Applying a restore requires --apply --yes (overwrites files and database objects)' [[ $LEGACY == false || $MODE == extract ]] || die 'Unverified legacy backups support --extract-only exclusively' OPERATION=restore load_config init_runtime check_space "$RESTORE_ROOT" STAGING_DIR=$(mktemp -d "$RESTORE_ROOT/${RUN_ID}.XXXXXX") INPUT="$STAGING_DIR/input" mkdir -m 700 "$INPUT" if [[ -n $REMOTE_RUN ]]; then [[ $REMOTE_RUN =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]+$ && $REMOTE_RUN == "$ARCHIVE_PREFIX"_* ]] || die 'Unsafe remote run name' check_remote echo "-- Downloading remote backup for restore verification: $REMOTE_RUN" rc copy "${RCLONE_REMOTE_BASE%/}/$REMOTE_RUN" "$INPUT" echo '-- Remote backup downloaded; validating manifest, hashes and archives' else [[ -d $LOCAL_RUN && ! -L $LOCAL_RUN ]] || die 'Local run must be an existing directory' # Resolve overlap separately: archive sources are allowed under WORKDIR, but # recursive copies into the freshly created restore workspace are forbidden. input_real=$(realpath -e "$LOCAL_RUN") stage_real=$(realpath -e "$STAGING_DIR") [[ $stage_real != "$input_real" && $stage_real != "$input_real/"* ]] || die 'Backup source contains restore workspace' rsync -a -- "$LOCAL_RUN/" "$INPUT/" 8>&- 9>&- fi if [[ $LEGACY == true ]]; then echo 'WARNING: Legacy backup has no trusted manifest/completeness proof.' shopt -s nullglob legacy=("$INPUT"/*.tar.gz "$INPUT"/*.tar.zst) (( ${#legacy[@]} )) || die 'No legacy archives found' # Validate the entire batch BEFORE extracting the first file. Legacy roots differ. for archive in "${legacy[@]}"; do [[ ! -L $archive ]] || die 'Symlink archive refused' base=$(basename "$archive") [[ $base =~ ^${ARCHIVE_PREFIX}_[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}-[0-9]{2}-[0-9]{2}_(meta|db|wordpress|nextcloud|nextcloud-data|gitea|gitea-etc|mail|mail-home|postfix|dovecot)\.tar\.(gz|zst)$ ]] || die "Unknown legacy filename: $base" label=${BASH_REMATCH[1]} case $label in meta|db) root=$label ;; gitea) root=files/gitea-data ;; *) root=files/$label ;; esac helper tar "$archive" "$root" done mkdir "$STAGING_DIR/extracted" for archive in "${legacy[@]}"; do legacy_dest="$STAGING_DIR/extracted/$(basename "$archive")" mkdir "$legacy_dest" tar --extract --file "$archive" --acls --xattrs --selinux --numeric-owner --same-owner -C "$legacy_dest" done echo "Legacy extraction retained for MANUAL review: $STAGING_DIR/extracted" STAGING_DIR='' exit 0 fi helper verify "$INPUT" if [[ $MODE == verify ]]; then echo 'Verification passed; no application changes.'; exit 0; fi helper list "$INPUT" > "$STAGING_DIR/archive-list.tsv" if [[ $MODE == extract ]]; then mkdir "$STAGING_DIR/extracted" while IFS=$'\t' read -r label file _; do mkdir "$STAGING_DIR/extracted/$label" tar --extract --file "$INPUT/$file" --acls --xattrs --selinux --numeric-owner --same-owner -C "$STAGING_DIR/extracted/$label" done < "$STAGING_DIR/archive-list.tsv" echo "Verified extraction retained: $STAGING_DIR/extracted" STAGING_DIR='' exit 0 fi build_plan # Selection is validated against both the local configuration and the backup set. SELECTED=(); SELECTED_ROOTS=(); TARGETS=(); IS_DB=(); CNFS=() selected() { [[ -z $COMPONENTS || ",$COMPONENTS," == *",$1,"* ]]; } for i in "${!LABELS[@]}"; do if [[ $FILES == true ]] && selected "${LABELS[i]}"; then SELECTED+=("${LABELS[i]}"); SELECTED_ROOTS+=("${ROOTS[i]}") TARGETS+=("${SOURCES[i]}"); IS_DB+=(false); CNFS+=('') fi done for i in "${!DB_LABELS[@]}"; do if [[ $DATABASES == true ]] && selected "${DB_LABELS[i]}"; then SELECTED+=("${DB_LABELS[i]}"); SELECTED_ROOTS+=("db/${DB_LABELS[i]#db-}.sql") TARGETS+=("${DB_NAMES[i]}"); IS_DB+=(true); CNFS+=("${DB_CNFS[i]}") fi done (( ${#SELECTED[@]} )) || die 'No components selected' if [[ -n $COMPONENTS ]]; then IFS=, read -r -a requested <<< "$COMPONENTS" for label in "${requested[@]}"; do [[ " ${SELECTED[*]} " == *" $label "* ]] || die "Unknown, disabled or excluded component: $label" done fi for i in "${!SELECTED[@]}"; do label=${SELECTED[i]} awk -F '\t' -v label="$label" '$1==label {found=1} END {exit !found}' "$STAGING_DIR/archive-list.tsv" || die "Required component absent: $label" # Indexed paths must retain their meaning when lists in config are edited. if [[ $label == extra-* || $label == mail-home-* ]]; then python3 - "$INPUT/manifest.json" "$label" "${TARGETS[i]}" <<'PY' import json,sys manifest,label,target=sys.argv[1:] source=next(a['source'] for a in json.load(open(manifest))['archives'] if a['label']==label) if source != target: sys.exit('Indexed source path differs from configuration; restore via --extract-only and review mapping') PY fi echo "RESTORE $label -> ${TARGETS[i]} (database=${IS_DB[i]})" done if [[ $MODE == plan ]]; then echo "Verified restore plan. Strict deletion: $RESTORE_STRICT_DELETE. Use --apply --yes to execute." exit 0 fi mkdir "$STAGING_DIR/extracted" # Each component gets an independent extraction root, so links in one component # cannot redirect extraction of a later component into another tree. while IFS=$'\t' read -r label file _; do mkdir "$STAGING_DIR/extracted/$label" tar --extract --file "$INPUT/$file" --acls --xattrs --selinux --numeric-owner --same-owner -C "$STAGING_DIR/extracted/$label" done < "$STAGING_DIR/archive-list.tsv" if [[ $MODE == extract ]]; then echo "Verified extraction retained: $STAGING_DIR/extracted" STAGING_DIR='' exit 0 fi # Validate every destination and DB connection before stopping a single service. for i in "${!SELECTED[@]}"; do if [[ ${IS_DB[i]} == true ]]; then secure_file "${CNFS[i]}" (( (8#$(stat -c %a "${CNFS[i]}") & 8#077) == 0 )) || die 'Restore credentials require chmod 600' need "$DB_CLIENT_BIN" "$DB_CLIENT_BIN" --defaults-extra-file="${CNFS[i]}" --database="${TARGETS[i]}" --execute='SELECT 1' >/dev/null else check_source_path "${TARGETS[i]}" fi done if [[ $ENABLE_NEXTCLOUD == true && $ENABLE_NEXTCLOUD_MAINTENANCE != true ]]; then die 'Applying a restore with Nextcloud enabled requires maintenance-mode management' fi quiesce MUTATION_STARTED=true save_recovery for i in "${!SELECTED[@]}"; do assert_services_quiescent label=${SELECTED[i]} source_path="$STAGING_DIR/extracted/$label/${SELECTED_ROOTS[i]}" if [[ ${IS_DB[i]} == true ]]; then [[ -f $source_path && ! -L $source_path && -s $source_path ]] || die 'SQL dump missing/empty' "$DB_CLIENT_BIN" --defaults-extra-file="${CNFS[i]}" --default-character-set=utf8mb4 --database="${TARGETS[i]}" < "$source_path" else exclusions "$label" if [[ $RESTORE_STRICT_DELETE == true ]]; then EXCLUDES+=(--delete-delay); fi rsync -aHAX --numeric-ids "${EXCLUDES[@]}" -- "$source_path/" "${TARGETS[i]}/" 8>&- 9>&- fi done assert_services_quiescent # A backup captured maintenance=true. Enforce the recorded pre-restore state even # if the existing installation already was in maintenance mode. if [[ " ${SELECTED[*]} " == *' nextcloud '* || " ${SELECTED[*]} " == *' db-nextcloud '* ]]; then occ maintenance:mode --on occ maintenance:data-fingerprint fi resume_services || die 'Restore applied, but service recovery failed' MUTATION_STARTED=false echo 'SUCCESS: selected components restored. Perform application-level smoke tests.'