244 lines
8.3 KiB
Bash
Executable File
244 lines
8.3 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
|
|
|
|
: "${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 <<EOF
|
|
Usage:
|
|
app-restore.sh --archive /path/to/appbackup_....tar.(zst|gz)
|
|
app-restore.sh --remote-file appbackup_....tar.(zst|gz)
|
|
Options:
|
|
--archive <path> Local archive path.
|
|
--remote-file <name> 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 <list> Comma-separated: wordpress,nextcloud,nextcloud-data,mail,db
|
|
--skip <list> 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}"
|