#!/usr/bin/env bash set -Eeuo pipefail umask 027 # ---------- Logging ---------- LOG_DIR="/var/log/app-backup" mkdir -p "$LOG_DIR" ts="$(date '+%Y-%m-%d_%H-%M-%S')" LOG_FILE="${LOG_DIR}/app-restore_${ts}.log" exec > >(tee -a "$LOG_FILE" | systemd-cat -t app-restore -p info) 2>&1 # ---------- Config ---------- CONFIG_FILE="/etc/app-backup/app-backup.conf" if [[ -r "$CONFIG_FILE" ]]; then # shellcheck disable=SC1090 source "$CONFIG_FILE" else echo "ERROR: Config not found/readable: $CONFIG_FILE" exit 2 fi # ---------- Defaults ---------- : "${WORKDIR:=/var/backups/app-backup}" : "${RESTORE_ROOT:=${WORKDIR}/restore}" : "${RCLONE_REMOTE:=onedrive:Sicherung}" : "${RCLONE_BIN:=rclone}" : "${DRY_RUN:=false}" # true = show what would happen : "${RESTORE_DB:=true}" # true/false : "${RESTORE_FILES:=true}" # true/false : "${RESTORE_STRICT_DELETE:=false}" # true = rsync --delete on restore : "${ENABLE_NEXTCLOUD_MAINTENANCE:=true}" : "${NC_OCC_USER:=apache}" : "${NC_FILES_SCAN_AFTER_RESTORE:=false}" die() { echo "ERROR: $*"; exit 1; } have() { command -v "$1" >/dev/null 2>&1; } run_cmd() { if [[ "${DRY_RUN}" == "true" ]]; then echo "[DRY_RUN] $*" else "$@" fi } # Nextcloud maintenance-mode safety trap NC_MAINTENANCE_ON=false nc_maintenance_off() { if [[ "${NC_MAINTENANCE_ON}" == "true" ]]; then echo "-- Nextcloud maintenance mode OFF (trap)..." run_cmd sudo -u "${NC_OCC_USER}" php "${NC_DIR}/occ" maintenance:mode --off || true NC_MAINTENANCE_ON=false fi } on_exit() { local exit_code=$? nc_maintenance_off exit "${exit_code}" } trap on_exit EXIT # ---------- Preconditions ---------- [[ $EUID -eq 0 ]] || die "Must run as root." for t in tar rsync flock df find stat; do have "$t" || die "Missing required tool: $t" done mkdir -p "$WORKDIR" "$RESTORE_ROOT" "$LOG_DIR" # ---------- Locking ---------- LOCKFILE="/run/app-backup.lock" exec 9>"$LOCKFILE" if ! flock -n 9; then die "Another backup/restore already running (lock: $LOCKFILE)" fi # ---------- Input ---------- # Usage: # app-restore.sh /path/to/appbackup_YYYY-mm-dd_HH-MM-SS.tar.zst # or # app-restore.sh /path/to/appbackup_YYYY-mm-dd_HH-MM-SS.tar.gz # or # app-restore.sh --remote appbackup_YYYY-mm-dd_HH-MM-SS.tar.zst # (copies from RCLONE_REMOTE to RESTORE_ROOT first) ARCHIVE_PATH="" REMOTE_NAME="" if [[ "${1:-}" == "--remote" ]]; then REMOTE_NAME="${2:-}" [[ -n "$REMOTE_NAME" ]] || die "Usage: $0 --remote " have "$RCLONE_BIN" || die "rclone missing but --remote used" else ARCHIVE_PATH="${1:-}" [[ -n "$ARCHIVE_PATH" ]] || die "Usage: $0 OR $0 --remote " fi echo "== app-restore start: ${ts} ==" echo "-- Config: ${CONFIG_FILE}" echo "-- Log: ${LOG_FILE}" echo "-- DRY_RUN: ${DRY_RUN}" echo "-- RESTORE_FILES: ${RESTORE_FILES}" echo "-- RESTORE_DB: ${RESTORE_DB}" echo "-- STRICT_DELETE: ${RESTORE_STRICT_DELETE}" # ---------- Fetch from remote if requested ---------- if [[ -n "$REMOTE_NAME" ]]; then ARCHIVE_PATH="${RESTORE_ROOT}/${REMOTE_NAME}" echo "-- Fetching from remote: ${RCLONE_REMOTE}/${REMOTE_NAME} -> ${ARCHIVE_PATH}" run_cmd "$RCLONE_BIN" copy "${RCLONE_REMOTE}/${REMOTE_NAME}" "${RESTORE_ROOT}" --checksum --log-level INFO fi [[ -f "$ARCHIVE_PATH" ]] || die "Archive not found: $ARCHIVE_PATH" # ---------- Detect compression ---------- ARCHIVE_BASENAME="$(basename "$ARCHIVE_PATH")" IS_ZSTD=false IS_GZIP=false case "$ARCHIVE_BASENAME" in *.tar.zst) IS_ZSTD=true ;; *.tar.gz) IS_GZIP=true ;; *) # fallback: try file(1) if have file; then ftype="$(file -b "$ARCHIVE_PATH" || true)" if echo "$ftype" | grep -qi zstd; then IS_ZSTD=true elif echo "$ftype" | grep -qi gzip; then IS_GZIP=true else die "Cannot detect archive compression for: $ARCHIVE_PATH" fi else die "Unknown archive extension and file(1) not available: $ARCHIVE_PATH" fi ;; esac if [[ "$IS_ZSTD" == "true" ]]; then have zstd || die "zstd archive but zstd missing" elif [[ "$IS_GZIP" == "true" ]]; then have gzip || die "gzip archive but gzip missing" fi # ---------- Extract ---------- RUN_DIR="${RESTORE_ROOT}/run_${ts}" STAGING_DIR="${RUN_DIR}/staging" mkdir -p "$STAGING_DIR" echo "-- Extracting archive to: ${STAGING_DIR}" if [[ "$IS_ZSTD" == "true" ]]; then run_cmd tar --zstd -xf "$ARCHIVE_PATH" -C "$STAGING_DIR" elif [[ "$IS_GZIP" == "true" ]]; then run_cmd tar -xzf "$ARCHIVE_PATH" -C "$STAGING_DIR" fi [[ -d "$STAGING_DIR/files" ]] || die "Invalid archive content: missing files/ in extracted staging" # ---------- Maintenance mode (Nextcloud) ---------- if [[ "${ENABLE_NEXTCLOUD}" == "true" && "${ENABLE_NEXTCLOUD_MAINTENANCE}" == "true" ]]; then if [[ -d "${NC_DIR}" && -f "${NC_DIR}/occ" ]]; then echo "-- Nextcloud maintenance mode ON..." run_cmd sudo -u "${NC_OCC_USER}" php "${NC_DIR}/occ" maintenance:mode --on NC_MAINTENANCE_ON=true else echo "WARN: Nextcloud directory/occ not found at NC_DIR=${NC_DIR} - cannot toggle maintenance mode" fi fi # ---------- Restore files ---------- rsync_restore_dir() { local src="$1" local dst="$2" [[ -d "$src" ]] || die "Restore source missing: $src" mkdir -p "$dst" local delete_flag=() if [[ "${RESTORE_STRICT_DELETE}" == "true" ]]; then delete_flag=(--delete) fi run_cmd rsync -aHAX --numeric-ids --info=stats2 \ "${delete_flag[@]}" \ "$src"/ "$dst"/ } if [[ "${RESTORE_FILES}" == "true" ]]; then echo "-- Restoring files..." if [[ "${ENABLE_WORDPRESS}" == "true" ]]; then echo "-- Restore WordPress (webroot) to: ${WP_DIR}" # Backup excluded nextcloud/ automatically, so this should not overwrite Nextcloud. rsync_restore_dir "$STAGING_DIR/files/wordpress" "${WP_DIR}" fi if [[ "${ENABLE_NEXTCLOUD}" == "true" ]]; then echo "-- Restore Nextcloud code to: ${NC_DIR}" rsync_restore_dir "$STAGING_DIR/files/nextcloud" "${NC_DIR}" if [[ "${ENABLE_NEXTCLOUD_DATA}" == "true" ]]; then echo "-- Restore Nextcloud data to: ${NC_DATA_DIR}" rsync_restore_dir "$STAGING_DIR/files/nextcloud-data" "${NC_DATA_DIR}" fi fi if [[ "${ENABLE_MAIL}" == "true" ]]; then echo "-- Restore mail files..." [[ -d "$STAGING_DIR/files/mail" && -n "${MAIL_DIR:-}" ]] && rsync_restore_dir "$STAGING_DIR/files/mail" "${MAIL_DIR}" || true [[ -d "$STAGING_DIR/files/postfix" && -n "${POSTFIX_DIR:-}" ]] && rsync_restore_dir "$STAGING_DIR/files/postfix" "${POSTFIX_DIR}" || true [[ -d "$STAGING_DIR/files/dovecot" && -n "${DOVECOT_DIR:-}" ]] && rsync_restore_dir "$STAGING_DIR/files/dovecot" "${DOVECOT_DIR}" || true fi else echo "-- RESTORE_FILES=false (skipping file restore)" fi # ---------- Restore databases ---------- mysql_restore_sql() { local cnf="$1" local db="$2" local sql_file="$3" [[ -r "$cnf" ]] || die "DB CNF not readable: $cnf" [[ -r "$sql_file" ]] || die "SQL file not readable: $sql_file" echo "-- Import DB: ${db} from ${sql_file}" run_cmd mysql --defaults-extra-file="$cnf" "$db" < "$sql_file" } if [[ "${RESTORE_DB}" == "true" ]]; then echo "-- Restoring databases..." if [[ -n "${WP_DB_NAME:-}" ]]; then wp_sql="$(ls -1 "$STAGING_DIR/db"/wordpress_*.sql 2>/dev/null | tail -n 1 || true)" if [[ -n "$wp_sql" ]]; then mysql_restore_sql "${WP_DB_CNF}" "${WP_DB_NAME}" "$wp_sql" else echo "WARN: No WordPress SQL dump found in archive." fi fi if [[ -n "${NC_DB_NAME:-}" ]]; then nc_sql="$(ls -1 "$STAGING_DIR/db"/nextcloud_*.sql 2>/dev/null | tail -n 1 || true)" if [[ -n "$nc_sql" ]]; then mysql_restore_sql "${NC_DB_CNF}" "${NC_DB_NAME}" "$nc_sql" else echo "WARN: No Nextcloud SQL dump found in archive." fi fi else echo "-- RESTORE_DB=false (skipping DB restore)" fi # ---------- Post-restore Nextcloud steps ---------- if [[ "${ENABLE_NEXTCLOUD}" == "true" && -d "${NC_DIR}" && -f "${NC_DIR}/occ" ]]; then echo "-- Nextcloud post-restore: maintenance:repair" run_cmd sudo -u "${NC_OCC_USER}" php "${NC_DIR}/occ" maintenance:repair || true echo "-- Nextcloud post-restore: files:scan (optional, can be slow)" if [[ "${NC_FILES_SCAN_AFTER_RESTORE}" == "true" ]]; then run_cmd sudo -u "${NC_OCC_USER}" php "${NC_DIR}/occ" files:scan --all || true else echo "-- Skipping files:scan (set NC_FILES_SCAN_AFTER_RESTORE=true to enable)" fi if [[ "${ENABLE_NEXTCLOUD_MAINTENANCE}" == "true" ]]; then echo "-- Nextcloud maintenance mode OFF..." run_cmd sudo -u "${NC_OCC_USER}" php "${NC_DIR}/occ" maintenance:mode --off NC_MAINTENANCE_ON=false fi fi echo "== app-restore done: ${ts} ==" echo "-- Extracted staging kept at: ${STAGING_DIR}" echo "-- Log: ${LOG_FILE}"