#!/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 : "${WORKDIR:=/var/backups/app-backup}" : "${STAGING_ROOT:=${WORKDIR}/restore-staging}" : "${RCLONE_REMOTE:=onedrive:Sicherung}" : "${RCLONE_BIN:=rclone}" : "${NC_OCC_USER:=apache}" ARCHIVE_PATH="" REMOTE_NAME="" DRY_RUN=false FORCE=false DO_WORDPRESS=true DO_NEXTCLOUD=true DO_NEXTCLOUD_DATA=true DO_MAIL=true DO_DB=true usage() { cat < Local archive path. --remote-file Download that file name from rclone remote (${RCLONE_REMOTE}) into ${WORKDIR}/restore-downloads/. --dry-run No writes (rsync --dry-run; DB import skipped; services not restarted). --force Skip some safety checks. --only Comma-separated: wordpress,nextcloud,nextcloud-data,mail,db --skip Comma-separated: wordpress,nextcloud,nextcloud-data,mail,db -h|--help Help. EOF } set_all_false() { DO_WORDPRESS=false; DO_NEXTCLOUD=false; DO_NEXTCLOUD_DATA=false; DO_MAIL=false; DO_DB=false; } parse_list_flags() { local mode="$1" list="$2" IFS=',' read -r -a items <<< "$list" for it in "${items[@]}"; do it="$(echo "$it" | tr '[:upper:]' '[:lower:]' | xargs)" case "$it" in wordpress) [[ "$mode" == "only" ]] && DO_WORDPRESS=true || DO_WORDPRESS=false ;; nextcloud) [[ "$mode" == "only" ]] && DO_NEXTCLOUD=true || DO_NEXTCLOUD=false ;; nextcloud-data) [[ "$mode" == "only" ]] && DO_NEXTCLOUD_DATA=true || DO_NEXTCLOUD_DATA=false ;; mail) [[ "$mode" == "only" ]] && DO_MAIL=true || DO_MAIL=false ;; db) [[ "$mode" == "only" ]] && DO_DB=true || DO_DB=false ;; "" ) : ;; *) echo "WARN: unknown component: $it" ;; esac done } while [[ $# -gt 0 ]]; do case "$1" in --archive) ARCHIVE_PATH="${2:-}"; shift 2 ;; --remote-file) REMOTE_NAME="${2:-}"; shift 2 ;; --dry-run) DRY_RUN=true; shift ;; --force) FORCE=true; shift ;; --only) set_all_false; parse_list_flags "only" "${2:-}"; shift 2 ;; --skip) parse_list_flags "skip" "${2:-}"; shift 2 ;; -h|--help) usage; exit 0 ;; *) echo "ERROR: unknown arg: $1"; usage; exit 2 ;; esac done die(){ echo "ERROR: $*"; exit 1; } have(){ command -v "$1" >/dev/null 2>&1; } service_is_active(){ systemctl is-active --quiet "$1"; } STOPPED_SERVICES=() stop_service_if_active() { local s="$1" if service_is_active "$s"; then echo "-- Stopping service: $s" [[ "$DRY_RUN" == "true" ]] || systemctl stop "$s" STOPPED_SERVICES+=("$s") fi } start_stopped_services() { if [[ "$DRY_RUN" == "true" ]]; then echo "-- DRY RUN: not starting services" return 0 fi for s in "${STOPPED_SERVICES[@]}"; do echo "-- Starting service: $s" systemctl start "$s" || echo "WARN: failed to start $s" done } integrity_check() { local f="$1" if [[ "$f" == *.zst ]]; then have zstd || die "zstd missing but archive is .zst" zstd -t "$f" elif [[ "$f" == *.gz ]]; then have gzip || die "gzip missing but archive is .gz" gzip -t "$f" else die "Unsupported archive extension: $f" fi } extract_archive() { local f="$1" dest="$2" mkdir -p "$dest" if [[ "$f" == *.zst ]]; then tar --use-compress-program=unzstd -xf "$f" -C "$dest" else tar -xzf "$f" -C "$dest" fi } rsync_restore_dir() { local src="$1" dst="$2" [[ -d "$src" ]] || die "Staging source missing: $src" mkdir -p "$dst" local args=(-aHAX --numeric-ids --delete) [[ "$DRY_RUN" == "true" ]] && args+=(--dry-run) echo "-- Restoring: $src => $dst" rsync "${args[@]}" "$src"/ "$dst"/ } mysql_import() { local cnf="$1" db="$2" sql="$3" [[ -r "$cnf" ]] || die "DB cnf not readable: $cnf" [[ -r "$sql" ]] || die "SQL dump not readable: $sql" have mysql || die "mysql client missing" if [[ "$DRY_RUN" == "true" ]]; then echo "-- DRY RUN: skipping DB import for $db" return 0 fi mysql --defaults-extra-file="$cnf" -e "CREATE DATABASE IF NOT EXISTS \`${db}\`;" mysql --defaults-extra-file="$cnf" "$db" < "$sql" } NC_MAINTENANCE_ON=false nc_maintenance_on() { if [[ "$DRY_RUN" == "true" ]]; then echo "-- DRY RUN: not toggling Nextcloud maintenance mode" return 0 fi if [[ -f "${NC_DIR}/occ" ]]; then sudo -u "${NC_OCC_USER}" php "${NC_DIR}/occ" maintenance:mode --on NC_MAINTENANCE_ON=true fi } nc_maintenance_off() { if [[ "${NC_MAINTENANCE_ON}" == "true" ]]; then sudo -u "${NC_OCC_USER}" php "${NC_DIR}/occ" maintenance:mode --off || true NC_MAINTENANCE_ON=false fi } echo "== app-restore start: ${ts} ==" echo "-- Log: ${LOG_FILE}" [[ $EUID -eq 0 ]] || die "Must run as root." LOCKFILE="/run/app-restore.lock" exec 9>"$LOCKFILE" flock -n 9 || die "Another restore is already running (lock: $LOCKFILE)" DOWNLOAD_DIR="${WORKDIR}/restore-downloads" mkdir -p "$DOWNLOAD_DIR" if [[ -n "$REMOTE_NAME" ]]; then have "$RCLONE_BIN" || die "rclone required for --remote-file" ARCHIVE_PATH="${DOWNLOAD_DIR}/${REMOTE_NAME}" if [[ "$DRY_RUN" == "true" ]]; then echo "-- DRY RUN: would download ${RCLONE_REMOTE}/${REMOTE_NAME} to ${ARCHIVE_PATH}" else "$RCLONE_BIN" copy "${RCLONE_REMOTE}/${REMOTE_NAME}" "$DOWNLOAD_DIR" --log-level INFO fi fi [[ -n "$ARCHIVE_PATH" ]] || { usage; die "Provide --archive or --remote-file"; } [[ -f "$ARCHIVE_PATH" ]] || die "Archive not found: $ARCHIVE_PATH" echo "-- Selected archive: $ARCHIVE_PATH" integrity_check "$ARCHIVE_PATH" STAGING_DIR="${STAGING_ROOT}/run_${ts}" trap 'nc_maintenance_off; start_stopped_services; rm -rf "${STAGING_DIR:?}"' EXIT extract_archive "$ARCHIVE_PATH" "$STAGING_DIR" [[ -d "$STAGING_DIR/files" ]] || die "Invalid archive: missing 'files' dir" # stop services to prevent writes if systemctl list-unit-files | grep -q '^httpd\.service'; then stop_service_if_active httpd; fi if [[ "$DO_MAIL" == "true" ]]; then if systemctl list-unit-files | grep -q '^postfix\.service'; then stop_service_if_active postfix; fi if systemctl list-unit-files | grep -q '^dovecot\.service'; then stop_service_if_active dovecot; fi fi # nextcloud maintenance for restore if [[ "$DO_NEXTCLOUD" == "true" || "$DO_NEXTCLOUD_DATA" == "true" || "$DO_DB" == "true" ]]; then nc_maintenance_on fi # restore files [[ "$DO_WORDPRESS" == "true" && -d "$STAGING_DIR/files/wordpress" ]] && rsync_restore_dir "$STAGING_DIR/files/wordpress" "$WP_DIR" || true [[ "$DO_NEXTCLOUD" == "true" && -d "$STAGING_DIR/files/nextcloud" ]] && rsync_restore_dir "$STAGING_DIR/files/nextcloud" "$NC_DIR" || true [[ "$DO_NEXTCLOUD_DATA" == "true" && -d "$STAGING_DIR/files/nextcloud-data" ]] && rsync_restore_dir "$STAGING_DIR/files/nextcloud-data" "$NC_DATA_DIR" || true if [[ "$DO_MAIL" == "true" ]]; then [[ -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 # restore DBs if [[ "$DO_DB" == "true" && "${ENABLE_DB_DUMPS:-false}" == "true" ]]; then WP_SQL="$(ls -1 "$STAGING_DIR/db"/wordpress_*.sql 2>/dev/null | tail -n 1 || true)" NC_SQL="$(ls -1 "$STAGING_DIR/db"/nextcloud_*.sql 2>/dev/null | tail -n 1 || true)" [[ "$DO_WORDPRESS" == "true" && -n "${WP_DB_NAME:-}" && -n "$WP_SQL" ]] && mysql_import "$WP_DB_CNF" "$WP_DB_NAME" "$WP_SQL" || true [[ ( "$DO_NEXTCLOUD" == "true" || "$DO_NEXTCLOUD_DATA" == "true" ) && -n "${NC_DB_NAME:-}" && -n "$NC_SQL" ]] && mysql_import "$NC_DB_CNF" "$NC_DB_NAME" "$NC_SQL" || true fi nc_maintenance_off start_stopped_services echo "== app-restore done: ${ts} ==" echo "-- Log: ${LOG_FILE}"