Files
app-backup/usr-local-sbin/app-restore.sh
T

187 lines
8.6 KiB
Bash
Executable File

#!/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
rc copy "${RCLONE_REMOTE_BASE%/}/$REMOTE_RUN" "$INPUT"
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
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
# 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.'