279 lines
8.6 KiB
Bash
Executable File
279 lines
8.6 KiB
Bash
Executable File
#!/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 <archive_filename>"
|
|
have "$RCLONE_BIN" || die "rclone missing but --remote used"
|
|
else
|
|
ARCHIVE_PATH="${1:-}"
|
|
[[ -n "$ARCHIVE_PATH" ]] || die "Usage: $0 <archive_file.tar.zst|tar.gz> OR $0 --remote <archive_filename>"
|
|
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}"
|