#!/usr/bin/env bash set -Eeuo pipefail umask 027 # ============================================================================== # app-restore.sh # - Restore from a "run folder" (local dir or rclone remote folder) # - Applies archives per component (meta/db/wordpress/nextcloud/nextcloud-data/gitea...) # ============================================================================== 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_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}" : "${RESTORE_ROOT:=${WORKDIR}/restore}" : "${ARCHIVE_PREFIX:=appbackup}" : "${RCLONE_BIN:=rclone}" : "${RCLONE_REMOTE_BASE:=OneDrive:Sicherung/JRITServerBackups/$(hostname -s)}" : "${DRY_RUN:=false}" : "${RESTORE_DB:=true}" : "${RESTORE_FILES:=true}" : "${RESTORE_STRICT_DELETE:=false}" : "${ENABLE_NEXTCLOUD_MAINTENANCE:=true}" : "${NC_OCC_USER:=apache}" : "${NC_FILES_SCAN_AFTER_RESTORE:=false}" : "${ENABLE_GITEA_SERVICE_STOP:=true}" : "${GITEA_SERVICE_NAME:=gitea}" : "${ENABLE_HTTPD_STOP:=false}" : "${HTTPD_SERVICE_NAME:=httpd}" : "${ENABLE_PHPFPM_STOP:=false}" : "${PHPFPM_SERVICE_NAME:=php-fpm}" die() { echo "ERROR: $*"; exit 1; } have() { command -v "$1" >/dev/null 2>&1; } run_cmd() { [[ "${DRY_RUN}" == "true" ]] && echo "[DRY_RUN] $*" || "$@"; } 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 } GITEA_WAS_STOPPED=false HTTPD_WAS_STOPPED=false PHPFPM_WAS_STOPPED=false gitea_start() { [[ "${GITEA_WAS_STOPPED}" == "true" ]] && { echo "-- Starting gitea (trap)"; run_cmd systemctl start "${GITEA_SERVICE_NAME}" || true; GITEA_WAS_STOPPED=false; }; } httpd_start() { [[ "${HTTPD_WAS_STOPPED}" == "true" ]] && { echo "-- Starting httpd (trap)"; run_cmd systemctl start "${HTTPD_SERVICE_NAME}" || true; HTTPD_WAS_STOPPED=false; }; } phpfpm_start(){ [[ "${PHPFPM_WAS_STOPPED}" == "true" ]] && { echo "-- Starting php-fpm (trap)"; run_cmd systemctl start "${PHPFPM_SERVICE_NAME}" || true; PHPFPM_WAS_STOPPED=false; }; } on_exit() { local ec=$?; nc_maintenance_off; gitea_start; httpd_start; phpfpm_start; exit "${ec}"; } trap on_exit EXIT [[ $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" LOCKFILE="/run/app-backup.lock" exec 9>"$LOCKFILE" flock -n 9 || die "Another backup/restore already running (lock: $LOCKFILE)" usage() { cat < # e.g. ${ARCHIVE_PREFIX}_2026-02-11_02-31-28 $0 --local-run # directory containing archives Options: --dry-run --no-db --no-files EOF } REMOTE_RUN="" LOCAL_RUN="" while [[ $# -gt 0 ]]; do case "$1" in --remote-run) REMOTE_RUN="${2:-}"; shift 2;; --local-run) LOCAL_RUN="${2:-}"; shift 2;; --dry-run) DRY_RUN=true; shift;; --no-db) RESTORE_DB=false; shift;; --no-files) RESTORE_FILES=false; shift;; -h|--help) usage; exit 0;; *) die "Unknown arg: $1";; esac done [[ -z "${REMOTE_RUN}" && -z "${LOCAL_RUN}" ]] && { usage; exit 2; } RUN_DIR="${RESTORE_ROOT}/run_${ts}" DOWNLOAD_DIR="${RUN_DIR}/downloads" EXTRACT_DIR="${RUN_DIR}/extract" mkdir -p "$DOWNLOAD_DIR" "$EXTRACT_DIR" if [[ -n "${REMOTE_RUN}" ]]; then have "$RCLONE_BIN" || die "rclone missing but --remote-run used" remote_path="${RCLONE_REMOTE_BASE}/${REMOTE_RUN}" echo "-- Fetching archives from remote: ${remote_path} -> ${DOWNLOAD_DIR}" run_cmd "$RCLONE_BIN" copy "${remote_path}" "${DOWNLOAD_DIR}" --checksum --log-level INFO SRC_DIR="${DOWNLOAD_DIR}" else [[ -d "${LOCAL_RUN}" ]] || die "Local run dir not found: ${LOCAL_RUN}" SRC_DIR="${LOCAL_RUN}" fi echo "== app-restore start: ${ts} ==" echo "-- Source dir: ${SRC_DIR}" echo "-- DRY_RUN: ${DRY_RUN}" detect_tar_flags() { case "$1" in *.tar.zst) echo "--zstd" ;; *.tar.gz) echo "-z" ;; *) die "Unsupported archive: $1" ;; esac; } extract_archive() { local f="$1" flags; flags="$(detect_tar_flags "$f")" echo "-- Extract: $(basename "$f") -> ${EXTRACT_DIR}" [[ "${DRY_RUN}" == "true" ]] && echo "[DRY_RUN] tar ${flags} -xf $f -C ${EXTRACT_DIR}" || tar ${flags} -xf "$f" -C "$EXTRACT_DIR" } pick_one() { ls -1 "${SRC_DIR}"/$1 2>/dev/null | sort | tail -n 1 || true; } # stop services (optional) if [[ "${ENABLE_HTTPD_STOP}" == "true" ]] && systemctl is-active --quiet "${HTTPD_SERVICE_NAME}"; then echo "-- Stopping httpd for restore: ${HTTPD_SERVICE_NAME}" run_cmd systemctl stop "${HTTPD_SERVICE_NAME}"; HTTPD_WAS_STOPPED=true fi if [[ "${ENABLE_PHPFPM_STOP}" == "true" ]] && systemctl is-active --quiet "${PHPFPM_SERVICE_NAME}"; then echo "-- Stopping php-fpm for restore: ${PHPFPM_SERVICE_NAME}" run_cmd systemctl stop "${PHPFPM_SERVICE_NAME}"; PHPFPM_WAS_STOPPED=true fi if [[ "${ENABLE_GITEA:-false}" == "true" && "${ENABLE_GITEA_SERVICE_STOP}" == "true" ]] && systemctl is-active --quiet "${GITEA_SERVICE_NAME}"; then echo "-- Stopping gitea for restore: ${GITEA_SERVICE_NAME}" run_cmd systemctl stop "${GITEA_SERVICE_NAME}"; GITEA_WAS_STOPPED=true fi # nextcloud maintenance if [[ "${ENABLE_NEXTCLOUD:-false}" == "true" && "${ENABLE_NEXTCLOUD_MAINTENANCE}" == "true" ]] && [[ -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 fi # extract archives meta_arc="$(pick_one "${ARCHIVE_PREFIX}_*_meta.tar.*")"; [[ -n "$meta_arc" ]] && extract_archive "$meta_arc" || true db_arc="$(pick_one "${ARCHIVE_PREFIX}_*_db.tar.*")" wp_arc="$(pick_one "${ARCHIVE_PREFIX}_*_wordpress.tar.*")" nc_arc="$(pick_one "${ARCHIVE_PREFIX}_*_nextcloud.tar.*")" ncd_arc="$(pick_one "${ARCHIVE_PREFIX}_*_nextcloud-data.tar.*")" g_arc="$(pick_one "${ARCHIVE_PREFIX}_*_gitea.tar.*")" g_etc_arc="$(pick_one "${ARCHIVE_PREFIX}_*_gitea-etc.tar.*")" [[ -n "$db_arc" ]] && extract_archive "$db_arc" || true [[ -n "$wp_arc" && "${RESTORE_FILES}" == "true" ]] && extract_archive "$wp_arc" || true [[ -n "$nc_arc" && "${RESTORE_FILES}" == "true" ]] && extract_archive "$nc_arc" || true [[ -n "$ncd_arc" && "${RESTORE_FILES}" == "true" ]] && extract_archive "$ncd_arc" || true [[ -n "$g_arc" && "${RESTORE_FILES}" == "true" ]] && extract_archive "$g_arc" || true [[ -n "$g_etc_arc" && "${RESTORE_FILES}" == "true" ]] && extract_archive "$g_etc_arc" || true rsync_restore_dir() { local src="$1" dst="$2" [[ -d "$src" ]] || die "Restore source missing: $src" mkdir -p "$dst" local del=(); [[ "${RESTORE_STRICT_DELETE}" == "true" ]] && del=(--delete) run_cmd rsync -aHAX --numeric-ids --info=stats2 "${del[@]}" "$src"/ "$dst"/ } if [[ "${RESTORE_FILES}" == "true" ]]; then echo "-- Restoring files..." if [[ -d "${EXTRACT_DIR}/files/wordpress" && "${ENABLE_WORDPRESS:-false}" == "true" ]]; then echo "-- WordPress -> ${WP_DIR}" rsync_restore_dir "${EXTRACT_DIR}/files/wordpress" "${WP_DIR}" fi if [[ "${ENABLE_NEXTCLOUD:-false}" == "true" ]]; then [[ -d "${EXTRACT_DIR}/files/nextcloud" ]] && { echo "-- Nextcloud code -> ${NC_DIR}"; rsync_restore_dir "${EXTRACT_DIR}/files/nextcloud" "${NC_DIR}"; } : "${NC_DATA_DIR:=${NC_DIR%/}/data}" [[ -d "${EXTRACT_DIR}/files/nextcloud-data" && "${ENABLE_NEXTCLOUD_DATA:-true}" == "true" ]] && { echo "-- Nextcloud data -> ${NC_DATA_DIR}"; rsync_restore_dir "${EXTRACT_DIR}/files/nextcloud-data" "${NC_DATA_DIR}"; } fi if [[ "${ENABLE_GITEA:-false}" == "true" ]]; then : "${GITEA_DATA_DIR:=/var/lib/gitea/data}" [[ -d "${EXTRACT_DIR}/files/gitea-data" ]] && { echo "-- Gitea data -> ${GITEA_DATA_DIR}"; rsync_restore_dir "${EXTRACT_DIR}/files/gitea-data" "${GITEA_DATA_DIR}"; } : "${GITEA_ETC_DIR:=/etc/gitea}" [[ -d "${EXTRACT_DIR}/files/gitea-etc" && -n "${GITEA_ETC_DIR:-}" ]] && { echo "-- Gitea etc -> ${GITEA_ETC_DIR}"; rsync_restore_dir "${EXTRACT_DIR}/files/gitea-etc" "${GITEA_ETC_DIR}"; } fi else echo "-- RESTORE_FILES=false (skipping)" fi mysql_restore_sql() { local cnf="$1" db="$2" sql="$3" [[ -r "$cnf" ]] || die "DB CNF not readable: $cnf" [[ -r "$sql" ]] || die "SQL not readable: $sql" have mysql || die "mysql client missing" echo "-- Import MySQL/MariaDB DB: ${db} from $(basename "$sql")" run_cmd mysql --defaults-extra-file="$cnf" "$db" < "$sql" } if [[ "${RESTORE_DB}" == "true" && -d "${EXTRACT_DIR}/db" ]]; then echo "-- Restoring databases..." wp_sql="$(ls -1 "${EXTRACT_DIR}/db"/wordpress_*.sql 2>/dev/null | sort | tail -n 1 || true)" nc_sql="$(ls -1 "${EXTRACT_DIR}/db"/nextcloud_*.sql 2>/dev/null | sort | tail -n 1 || true)" g_sql="$(ls -1 "${EXTRACT_DIR}/db"/gitea_*.sql 2>/dev/null | sort | tail -n 1 || true)" [[ -n "${WP_DB_NAME:-}" && -n "$wp_sql" ]] && mysql_restore_sql "${WP_DB_CNF}" "${WP_DB_NAME}" "$wp_sql" || echo "WARN: WP DB dump missing" [[ -n "${NC_DB_NAME:-}" && -n "$nc_sql" ]] && mysql_restore_sql "${NC_DB_CNF}" "${NC_DB_NAME}" "$nc_sql" || echo "WARN: NC DB dump missing" [[ "${ENABLE_GITEA:-false}" == "true" && -n "${GITEA_DB_NAME:-}" && -n "$g_sql" ]] && mysql_restore_sql "${GITEA_DB_CNF}" "${GITEA_DB_NAME}" "$g_sql" || true else echo "-- RESTORE_DB=false or no db dump present (skipping)" fi if [[ "${ENABLE_NEXTCLOUD:-false}" == "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 if [[ "${NC_FILES_SCAN_AFTER_RESTORE}" == "true" ]]; then echo "-- Nextcloud post-restore: files:scan --all" run_cmd sudo -u "${NC_OCC_USER}" php "${NC_DIR}/occ" files:scan --all || true 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 gitea_start phpfpm_start httpd_start echo "== app-restore done: ${ts} ==" echo "-- Working dir: ${RUN_DIR}" echo "-- Log: ${LOG_FILE}"