update storage-overview.tsx

This commit is contained in:
MacRimi
2026-04-13 14:49:48 +02:00
parent 44aefb5d3b
commit 2344935357
10 changed files with 4798 additions and 884 deletions

View File

@@ -0,0 +1,166 @@
#!/bin/bash
# ==========================================================
# ProxMenux - Apply Pending Restore On Boot
# ==========================================================
PENDING_BASE="${PMX_RESTORE_PENDING_BASE:-/var/lib/proxmenux/restore-pending}"
CURRENT_LINK="${PENDING_BASE}/current"
LOG_DIR="${PMX_RESTORE_LOG_DIR:-/var/log/proxmenux}"
DEST_PREFIX="${PMX_RESTORE_DEST_PREFIX:-/}"
PRE_BACKUP_BASE="${PMX_RESTORE_PRE_BACKUP_BASE:-/root/proxmenux-pre-restore}"
RECOVERY_BASE="${PMX_RESTORE_RECOVERY_BASE:-/root/proxmenux-recovery}"
mkdir -p "$LOG_DIR" "$PENDING_BASE/completed" >/dev/null 2>&1 || true
LOG_FILE="${LOG_DIR}/proxmenux-restore-onboot-$(date +%Y%m%d_%H%M%S).log"
exec >>"$LOG_FILE" 2>&1
echo "=== ProxMenux pending restore started at $(date -Iseconds) ==="
if [[ ! -e "$CURRENT_LINK" ]]; then
echo "No pending restore link found. Nothing to do."
exit 0
fi
PENDING_DIR="$(readlink -f "$CURRENT_LINK" 2>/dev/null || echo "$CURRENT_LINK")"
if [[ ! -d "$PENDING_DIR" ]]; then
echo "Pending restore directory not found: $PENDING_DIR"
rm -f "$CURRENT_LINK" >/dev/null 2>&1 || true
exit 0
fi
APPLY_LIST="${PENDING_DIR}/apply-on-boot.list"
PLAN_ENV="${PENDING_DIR}/plan.env"
STATE_FILE="${PENDING_DIR}/state"
if [[ -f "$PLAN_ENV" ]]; then
# shellcheck source=/dev/null
source "$PLAN_ENV"
fi
: "${HB_RESTORE_INCLUDE_ZFS:=0}"
if [[ ! -f "$APPLY_LIST" ]]; then
echo "Apply list missing: $APPLY_LIST"
echo "failed" >"$STATE_FILE"
exit 1
fi
echo "Pending dir: $PENDING_DIR"
echo "Apply list: $APPLY_LIST"
echo "Include ZFS: $HB_RESTORE_INCLUDE_ZFS"
echo "running" >"$STATE_FILE"
backup_root="${PRE_BACKUP_BASE}/$(date +%Y%m%d_%H%M%S)-onboot"
mkdir -p "$backup_root" >/dev/null 2>&1 || true
cluster_recovery_root=""
applied=0
skipped=0
failed=0
while IFS= read -r rel; do
[[ -z "$rel" ]] && continue
src="${PENDING_DIR}/rootfs/${rel}"
dst="${DEST_PREFIX%/}/${rel}"
if [[ ! -e "$src" ]]; then
((skipped++))
continue
fi
# Never restore cluster virtual filesystem data live.
if [[ "$rel" == etc/pve* ]] || [[ "$rel" == var/lib/pve-cluster* ]]; then
if [[ -z "$cluster_recovery_root" ]]; then
cluster_recovery_root="${RECOVERY_BASE}/$(date +%Y%m%d_%H%M%S)-onboot"
mkdir -p "$cluster_recovery_root" >/dev/null 2>&1 || true
fi
mkdir -p "$cluster_recovery_root/$(dirname "$rel")" >/dev/null 2>&1 || true
cp -a "$src" "$cluster_recovery_root/$rel" >/dev/null 2>&1 || true
((skipped++))
continue
fi
# /etc/zfs is opt-in.
if [[ "$rel" == etc/zfs || "$rel" == etc/zfs/* ]]; then
if [[ "$HB_RESTORE_INCLUDE_ZFS" != "1" ]]; then
((skipped++))
continue
fi
fi
if [[ -e "$dst" ]]; then
mkdir -p "$backup_root/$(dirname "$rel")" >/dev/null 2>&1 || true
cp -a "$dst" "$backup_root/$rel" >/dev/null 2>&1 || true
fi
if [[ -d "$src" ]]; then
mkdir -p "$dst" >/dev/null 2>&1 || true
if rsync -aAXH --delete "$src/" "$dst/" >/dev/null 2>&1; then
((applied++))
else
((failed++))
fi
else
mkdir -p "$(dirname "$dst")" >/dev/null 2>&1 || true
if cp -a "$src" "$dst" >/dev/null 2>&1; then
((applied++))
else
((failed++))
fi
fi
done <"$APPLY_LIST"
systemctl daemon-reload >/dev/null 2>&1 || true
command -v update-initramfs >/dev/null 2>&1 && update-initramfs -u -k all >/dev/null 2>&1 || true
command -v update-grub >/dev/null 2>&1 && update-grub >/dev/null 2>&1 || true
echo "Applied: $applied"
echo "Skipped: $skipped"
echo "Failed: $failed"
echo "Backup before restore: $backup_root"
if [[ -n "$cluster_recovery_root" ]]; then
helper="${cluster_recovery_root}/apply-cluster-restore.sh"
cat > "$helper" <<EOF
#!/bin/bash
set -euo pipefail
RECOVERY_ROOT="${cluster_recovery_root}"
echo "Cluster recovery helper"
echo "Source: \$RECOVERY_ROOT"
echo
echo "WARNING: run this only in a maintenance window."
echo
read -r -p "Type YES to continue: " ans
[[ "\$ans" == "YES" ]] || { echo "Aborted."; exit 1; }
systemctl stop pve-cluster || true
[[ -d "\$RECOVERY_ROOT/etc/pve" ]] && mkdir -p /etc/pve && cp -a "\$RECOVERY_ROOT/etc/pve/." /etc/pve/ || true
[[ -d "\$RECOVERY_ROOT/var/lib/pve-cluster" ]] && mkdir -p /var/lib/pve-cluster && cp -a "\$RECOVERY_ROOT/var/lib/pve-cluster/." /var/lib/pve-cluster/ || true
systemctl start pve-cluster || true
echo "Cluster recovery finished."
EOF
chmod +x "$helper" >/dev/null 2>&1 || true
echo "Cluster paths extracted to: $cluster_recovery_root"
echo "Cluster recovery helper: $helper"
fi
if [[ "$failed" -eq 0 ]]; then
echo "completed" >"$STATE_FILE"
else
echo "completed_with_errors" >"$STATE_FILE"
fi
restore_id="$(basename "$PENDING_DIR")"
mv "$PENDING_DIR" "${PENDING_BASE}/completed/${restore_id}" >/dev/null 2>&1 || true
rm -f "$CURRENT_LINK" >/dev/null 2>&1 || true
systemctl disable proxmenux-restore-onboot.service >/dev/null 2>&1 || true
echo "=== ProxMenux pending restore finished at $(date -Iseconds) ==="
echo "Log file: $LOG_FILE"
exit 0

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,387 @@
#!/bin/bash
# ==========================================================
# ProxMenux - Scheduled Backup Jobs
# ==========================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOCAL_SCRIPTS_LOCAL="$(cd "$SCRIPT_DIR/.." && pwd)"
LOCAL_SCRIPTS_DEFAULT="/usr/local/share/proxmenux/scripts"
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_DEFAULT"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
if [[ -f "$LOCAL_SCRIPTS_LOCAL/utils.sh" ]]; then
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_LOCAL"
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
elif [[ ! -f "$UTILS_FILE" ]]; then
UTILS_FILE="$BASE_DIR/utils.sh"
fi
if [[ -f "$UTILS_FILE" ]]; then
# shellcheck source=/dev/null
source "$UTILS_FILE"
else
echo "ERROR: utils.sh not found." >&2
exit 1
fi
LIB_FILE="$SCRIPT_DIR/lib_host_backup_common.sh"
[[ ! -f "$LIB_FILE" ]] && LIB_FILE="$LOCAL_SCRIPTS_DEFAULT/backup_restore/lib_host_backup_common.sh"
if [[ -f "$LIB_FILE" ]]; then
# shellcheck source=/dev/null
source "$LIB_FILE"
else
msg_error "$(translate "Cannot load backup library: lib_host_backup_common.sh")"
exit 1
fi
load_language
initialize_cache
JOBS_DIR="/var/lib/proxmenux/backup-jobs"
LOG_DIR="/var/log/proxmenux/backup-jobs"
mkdir -p "$JOBS_DIR" "$LOG_DIR" >/dev/null 2>&1 || true
_job_file() { echo "${JOBS_DIR}/$1.env"; }
_job_paths_file() { echo "${JOBS_DIR}/$1.paths"; }
_service_file() { echo "/etc/systemd/system/proxmenux-backup-$1.service"; }
_timer_file() { echo "/etc/systemd/system/proxmenux-backup-$1.timer"; }
_normalize_uint() {
local v="${1:-0}"
[[ "$v" =~ ^[0-9]+$ ]] || v=0
echo "$v"
}
_write_job_env() {
local file="$1"
shift
{
echo "# ProxMenux scheduled backup job"
local kv key val
for kv in "$@"; do
key="${kv%%=*}"
val="${kv#*=}"
printf '%s=%q\n' "$key" "$val"
done
} > "$file"
}
_list_jobs() {
local f
for f in "$JOBS_DIR"/*.env; do
[[ -f "$f" ]] || continue
basename "$f" .env
done | sort
}
_show_job_status() {
local id="$1"
local timer_state="disabled"
local service_state="unknown"
systemctl is-enabled --quiet "proxmenux-backup-${id}.timer" >/dev/null 2>&1 && timer_state="enabled"
service_state=$(systemctl is-active "proxmenux-backup-${id}.service" 2>/dev/null || echo "inactive")
echo "${timer_state}/${service_state}"
}
_write_job_units() {
local id="$1"
local on_calendar="$2"
local runner="$LOCAL_SCRIPTS/backup_restore/run_scheduled_backup.sh"
[[ ! -f "$runner" ]] && runner="$SCRIPT_DIR/run_scheduled_backup.sh"
cat > "$(_service_file "$id")" <<EOF
[Unit]
Description=ProxMenux Scheduled Backup Job (${id})
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=${runner} ${id}
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
EOF
cat > "$(_timer_file "$id")" <<EOF
[Unit]
Description=ProxMenux Scheduled Backup Timer (${id})
[Timer]
OnCalendar=${on_calendar}
Persistent=true
RandomizedDelaySec=120
Unit=proxmenux-backup-${id}.service
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload >/dev/null 2>&1 || true
}
_prompt_retention() {
local __out_var="$1"
local last hourly daily weekly monthly yearly
last=$(dialog --backtitle "ProxMenux" --title "$(translate "Retention")" \
--inputbox "$(translate "keep-last (0 disables)")" 9 60 "7" 3>&1 1>&2 2>&3) || return 1
hourly=$(dialog --backtitle "ProxMenux" --title "$(translate "Retention")" \
--inputbox "$(translate "keep-hourly (0 disables)")" 9 60 "0" 3>&1 1>&2 2>&3) || return 1
daily=$(dialog --backtitle "ProxMenux" --title "$(translate "Retention")" \
--inputbox "$(translate "keep-daily (0 disables)")" 9 60 "7" 3>&1 1>&2 2>&3) || return 1
weekly=$(dialog --backtitle "ProxMenux" --title "$(translate "Retention")" \
--inputbox "$(translate "keep-weekly (0 disables)")" 9 60 "4" 3>&1 1>&2 2>&3) || return 1
monthly=$(dialog --backtitle "ProxMenux" --title "$(translate "Retention")" \
--inputbox "$(translate "keep-monthly (0 disables)")" 9 60 "3" 3>&1 1>&2 2>&3) || return 1
yearly=$(dialog --backtitle "ProxMenux" --title "$(translate "Retention")" \
--inputbox "$(translate "keep-yearly (0 disables)")" 9 60 "0" 3>&1 1>&2 2>&3) || return 1
last=$(_normalize_uint "$last")
hourly=$(_normalize_uint "$hourly")
daily=$(_normalize_uint "$daily")
weekly=$(_normalize_uint "$weekly")
monthly=$(_normalize_uint "$monthly")
yearly=$(_normalize_uint "$yearly")
local -n out="$__out_var"
out=(
"KEEP_LAST=$last"
"KEEP_HOURLY=$hourly"
"KEEP_DAILY=$daily"
"KEEP_WEEKLY=$weekly"
"KEEP_MONTHLY=$monthly"
"KEEP_YEARLY=$yearly"
)
}
_create_job() {
local id backend on_calendar profile_mode
id=$(dialog --backtitle "ProxMenux" --title "$(translate "New backup job")" \
--inputbox "$(translate "Job ID (letters, numbers, - _)")" 9 68 "hostcfg-daily" 3>&1 1>&2 2>&3) || return 1
[[ -z "$id" ]] && return 1
id=$(echo "$id" | tr -cs '[:alnum:]_-' '-' | sed 's/^-*//; s/-*$//')
[[ -z "$id" ]] && return 1
[[ -f "$(_job_file "$id")" ]] && {
dialog --backtitle "ProxMenux" --title "$(translate "Error")" \
--msgbox "$(translate "A job with this ID already exists.")" 8 62
return 1
}
backend=$(dialog --backtitle "ProxMenux" --title "$(translate "Backend")" \
--menu "\n$(translate "Select backup backend:")" 14 70 6 \
"local" "Local archive" \
"borg" "Borg repository" \
"pbs" "Proxmox Backup Server" \
3>&1 1>&2 2>&3) || return 1
on_calendar=$(dialog --backtitle "ProxMenux" --title "$(translate "Schedule")" \
--inputbox "$(translate "systemd OnCalendar expression")"$'\n'"$(translate "Example: daily or Mon..Fri 03:00")" \
11 72 "daily" 3>&1 1>&2 2>&3) || return 1
[[ -z "$on_calendar" ]] && return 1
profile_mode=$(dialog --backtitle "ProxMenux" --title "$(translate "Profile")" \
--menu "\n$(translate "Select backup profile:")" 12 68 4 \
"default" "Default critical paths" \
"custom" "Custom selected paths" \
3>&1 1>&2 2>&3) || return 1
local -a paths=()
hb_select_profile_paths "$profile_mode" paths || return 1
local -a retention=()
_prompt_retention retention || return 1
local -a lines=(
"JOB_ID=$id"
"BACKEND=$backend"
"ON_CALENDAR=$on_calendar"
"PROFILE_MODE=$profile_mode"
"ENABLED=1"
)
lines+=("${retention[@]}")
case "$backend" in
local)
local dest_dir ext
dest_dir=$(hb_prompt_dest_dir) || return 1
ext=$(dialog --backtitle "ProxMenux" --title "$(translate "Archive format")" \
--menu "\n$(translate "Select local archive format:")" 12 62 4 \
"tar.zst" "tar + zstd (preferred)" \
"tar.gz" "tar + gzip" \
3>&1 1>&2 2>&3) || return 1
lines+=("LOCAL_DEST_DIR=$dest_dir" "LOCAL_ARCHIVE_EXT=$ext")
;;
borg)
local repo passphrase
hb_select_borg_repo repo || return 1
hb_prepare_borg_passphrase || return 1
passphrase="${BORG_PASSPHRASE:-}"
lines+=(
"BORG_REPO=$repo"
"BORG_PASSPHRASE=$passphrase"
"BORG_ENCRYPT_MODE=${BORG_ENCRYPT_MODE:-none}"
)
;;
pbs)
hb_select_pbs_repository || return 1
hb_ask_pbs_encryption
local bid
bid="hostcfg-$(hostname)"
bid=$(dialog --backtitle "ProxMenux" --title "PBS" \
--inputbox "$(translate "Backup ID for this job:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "$bid" 3>&1 1>&2 2>&3) || return 1
bid=$(echo "$bid" | tr -cs '[:alnum:]_-' '-' | sed 's/-*$//')
lines+=(
"PBS_REPOSITORY=${HB_PBS_REPOSITORY}"
"PBS_PASSWORD=${HB_PBS_SECRET}"
"PBS_BACKUP_ID=${bid}"
"PBS_KEYFILE=${HB_PBS_KEYFILE:-}"
"PBS_ENCRYPTION_PASSWORD=${HB_PBS_ENC_PASS:-}"
)
;;
esac
_write_job_env "$(_job_file "$id")" "${lines[@]}"
: > "$(_job_paths_file "$id")"
local p
for p in "${paths[@]}"; do
echo "$p" >> "$(_job_paths_file "$id")"
done
_write_job_units "$id" "$on_calendar"
systemctl enable --now "proxmenux-backup-${id}.timer" >/dev/null 2>&1 || true
show_proxmenux_logo
msg_title "$(translate "Scheduled backup job created")"
echo -e ""
echo -e "${TAB}${BGN}$(translate "Job ID:")${CL} ${BL}${id}${CL}"
echo -e "${TAB}${BGN}$(translate "Backend:")${CL} ${BL}${backend}${CL}"
echo -e "${TAB}${BGN}$(translate "Schedule:")${CL} ${BL}${on_calendar}${CL}"
echo -e "${TAB}${BGN}$(translate "Status:")${CL} ${BL}$(_show_job_status "$id")${CL}"
echo -e ""
msg_success "$(translate "Press Enter to continue...")"
read -r
return 0
}
_pick_job() {
local title="$1"
local __out_var="$2"
local -a ids=()
mapfile -t ids < <(_list_jobs)
if [[ ${#ids[@]} -eq 0 ]]; then
dialog --backtitle "ProxMenux" --title "$(translate "No jobs")" \
--msgbox "$(translate "No scheduled backup jobs found.")" 8 62
return 1
fi
local -a menu=()
local i=1 id
for id in "${ids[@]}"; do
menu+=("$i" "$id [$(_show_job_status "$id")]")
((i++))
done
local sel
sel=$(dialog --backtitle "ProxMenux" --title "$title" \
--menu "\n$(translate "Select a job:")" "$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
"${menu[@]}" 3>&1 1>&2 2>&3) || return 1
local picked="${ids[$((sel-1))]}"
local -n out="$__out_var"
out="$picked"
return 0
}
_job_run_now() {
local id=""
_pick_job "$(translate "Run job now")" id || return 1
local runner="$LOCAL_SCRIPTS/backup_restore/run_scheduled_backup.sh"
[[ ! -f "$runner" ]] && runner="$SCRIPT_DIR/run_scheduled_backup.sh"
if "$runner" "$id"; then
msg_ok "$(translate "Job executed successfully.")"
else
msg_warn "$(translate "Job execution finished with errors. Check logs.")"
fi
msg_success "$(translate "Press Enter to continue...")"
read -r
}
_job_toggle() {
local id=""
_pick_job "$(translate "Enable/Disable job")" id || return 1
if systemctl is-enabled --quiet "proxmenux-backup-${id}.timer" >/dev/null 2>&1; then
systemctl disable --now "proxmenux-backup-${id}.timer" >/dev/null 2>&1 || true
msg_warn "$(translate "Job timer disabled:") $id"
else
systemctl enable --now "proxmenux-backup-${id}.timer" >/dev/null 2>&1 || true
msg_ok "$(translate "Job timer enabled:") $id"
fi
msg_success "$(translate "Press Enter to continue...")"
read -r
}
_job_delete() {
local id=""
_pick_job "$(translate "Delete job")" id || return 1
if ! whiptail --title "$(translate "Confirm delete")" \
--yesno "$(translate "Delete scheduled backup job?")"$'\n\n'"ID: ${id}" 10 66; then
return 1
fi
systemctl disable --now "proxmenux-backup-${id}.timer" >/dev/null 2>&1 || true
rm -f "$(_service_file "$id")" "$(_timer_file "$id")" "$(_job_file "$id")" "$(_job_paths_file "$id")"
systemctl daemon-reload >/dev/null 2>&1 || true
msg_ok "$(translate "Job deleted:") $id"
msg_success "$(translate "Press Enter to continue...")"
read -r
}
_show_jobs() {
local tmp
tmp=$(mktemp) || return
{
echo "=== $(translate "Scheduled backup jobs") ==="
echo ""
local id
while IFS= read -r id; do
[[ -z "$id" ]] && continue
echo "$id [$(_show_job_status "$id")]"
if [[ -f "${LOG_DIR}/${id}-last.status" ]]; then
sed 's/^/ /' "${LOG_DIR}/${id}-last.status"
fi
echo ""
done < <(_list_jobs)
} > "$tmp"
dialog --backtitle "ProxMenux" --title "$(translate "Scheduled backup jobs")" \
--textbox "$tmp" 28 100 || true
rm -f "$tmp"
}
main_menu() {
while true; do
local choice
choice=$(dialog --backtitle "ProxMenux" \
--title "$(translate "Backup scheduler and retention")" \
--menu "\n$(translate "Choose action:")" "$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
1 "$(translate "Create scheduled backup job")" \
2 "$(translate "Show jobs and last run status")" \
3 "$(translate "Run a job now")" \
4 "$(translate "Enable / disable job timer")" \
5 "$(translate "Delete job")" \
0 "$(translate "Return")" \
3>&1 1>&2 2>&3) || return 0
case "$choice" in
1) _create_job ;;
2) _show_jobs ;;
3) _job_run_now ;;
4) _job_toggle ;;
5) _job_delete ;;
0) return 0 ;;
esac
done
}
main_menu

View File

@@ -0,0 +1,770 @@
#!/bin/bash
# ==========================================================
# ProxMenux - Host Config Backup/Restore - Shared Library
# ==========================================================
# Author : MacRimi
# Copyright : (c) 2024 MacRimi
# License : MIT
# Version : 1.0
# Last Updated: 08/04/2026
# ==========================================================
# Do not execute directly — source from backup_host.sh
# Library guard
[[ "${BASH_SOURCE[0]}" == "$0" ]] && {
echo "This file is a library. Source it, do not run it directly." >&2; exit 1
}
HB_STATE_DIR="/usr/local/share/proxmenux"
HB_BORG_VERSION="1.2.8"
HB_BORG_LINUX64_SHA256="cfa50fb704a93d3a4fa258120966345fddb394f960dca7c47fcb774d0172f40b"
HB_BORG_LINUX64_URL="https://github.com/borgbackup/borg/releases/download/${HB_BORG_VERSION}/borg-linux64"
# Translation wrapper — safe fallback if translate not yet loaded
hb_translate() {
declare -f translate >/dev/null 2>&1 && translate "$1" || echo "$1"
}
# ==========================================================
# UI SIZE CONSTANTS
# ==========================================================
HB_UI_MENU_H=22
HB_UI_MENU_W=84
HB_UI_MENU_LIST=10
HB_UI_INPUT_H=10
HB_UI_INPUT_W=72
HB_UI_PASS_H=10
HB_UI_PASS_W=72
HB_UI_YESNO_H=10
HB_UI_YESNO_W=78
# ==========================================================
# DEFAULT PROFILE PATHS
# ==========================================================
hb_default_profile_paths() {
local paths=(
"/etc/pve"
"/etc/network"
"/etc/hosts"
"/etc/hostname"
"/etc/ssh"
"/etc/systemd/system"
"/etc/modules"
"/etc/modules-load.d"
"/etc/modprobe.d"
"/etc/udev/rules.d"
"/etc/default/grub"
"/etc/fstab"
"/etc/kernel"
"/etc/apt"
"/etc/vzdump.conf"
"/etc/postfix"
"/etc/resolv.conf"
"/etc/timezone"
"/etc/iscsi"
"/etc/multipath"
"/usr/local/bin"
"/usr/local/share/proxmenux"
"/root"
"/etc/cron.d"
"/etc/cron.daily"
"/etc/cron.hourly"
"/etc/cron.weekly"
"/etc/cron.monthly"
"/etc/cron.allow"
"/etc/cron.deny"
"/var/spool/cron/crontabs"
"/var/lib/pve-cluster"
)
if [[ -d /etc/zfs ]] || command -v zpool >/dev/null 2>&1; then
paths+=("/etc/zfs")
fi
printf '%s\n' "${paths[@]}"
}
# ==========================================================
# PATH CLASSIFICATION (restore safety)
# Returns: dangerous | reboot | hot
# ==========================================================
hb_classify_path() {
local rel="$1" # without leading /
case "$rel" in
etc/pve|etc/pve/*|\
var/lib/pve-cluster|var/lib/pve-cluster/*|\
etc/network|etc/network/*)
echo "dangerous" ;;
etc/modules|etc/modules/*|\
etc/modules-load.d|etc/modules-load.d/*|\
etc/modprobe.d|etc/modprobe.d/*|\
etc/udev/rules.d|etc/udev/rules.d/*|\
etc/default/grub|\
etc/fstab|\
etc/kernel|etc/kernel/*|\
etc/iscsi|etc/iscsi/*|\
etc/multipath|etc/multipath/*|\
etc/zfs|etc/zfs/*)
echo "reboot" ;;
*)
echo "hot" ;;
esac
}
hb_path_warning() {
local rel="$1"
case "$rel" in
etc/pve|etc/pve/*)
hb_translate "/etc/pve is managed by pmxcfs (cluster filesystem). Applying this on a running node can corrupt cluster state. Use 'Export to file' and apply it manually during a maintenance window." ;;
var/lib/pve-cluster|var/lib/pve-cluster/*)
hb_translate "/var/lib/pve-cluster is live cluster data. Never restore this while the node is running. Use 'Export to file' for manual recovery only." ;;
etc/network|etc/network/*)
hb_translate "/etc/network controls active interfaces. Applying may immediately change or drop network connectivity, including active SSH sessions." ;;
esac
}
# ==========================================================
# PROFILE PATH SELECTION
# ==========================================================
hb_select_profile_paths() {
local mode="$1"
local __out_var="$2"
local -n __out_ref="$__out_var"
mapfile -t __defaults < <(hb_default_profile_paths)
if [[ "$mode" == "default" ]]; then
__out_ref=("${__defaults[@]}")
return 0
fi
local options=() idx=1 path
for path in "${__defaults[@]}"; do
options+=("$idx" "$path" "off")
((idx++))
done
local selected
selected=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Custom backup profile")" \
--separate-output --checklist \
"$(hb_translate "Select paths to include:")" \
26 86 18 "${options[@]}" 3>&1 1>&2 2>&3) || return 1
__out_ref=()
local choice
while read -r choice; do
[[ -z "$choice" ]] && continue
__out_ref+=("${__defaults[$((choice-1))]}")
done <<< "$selected"
if [[ ${#__out_ref[@]} -eq 0 ]]; then
dialog --backtitle "ProxMenux" --title "$(hb_translate "Error")" \
--msgbox "$(hb_translate "No paths selected. Select at least one path.")" 8 60
return 1
fi
}
# ==========================================================
# STAGING OPERATIONS
# ==========================================================
hb_prepare_staging() {
local staging_root="$1"; shift
local paths=("$@")
rm -rf "$staging_root"
mkdir -p "$staging_root/rootfs" "$staging_root/metadata"
local selected_file="$staging_root/metadata/selected_paths.txt"
local missing_file="$staging_root/metadata/missing_paths.txt"
: > "$selected_file"
: > "$missing_file"
local p rel target
for p in "${paths[@]}"; do
rel="${p#/}"
echo "$rel" >> "$selected_file"
[[ -e "$p" ]] || { echo "$p" >> "$missing_file"; continue; }
target="$staging_root/rootfs/$rel"
if [[ -d "$p" ]]; then
mkdir -p "$target"
local -a rsync_opts=(
-aAXH --numeric-ids
--exclude "images/"
--exclude "dump/"
--exclude "tmp/"
--exclude "*.log"
)
# /root is included by default for easier recovery, but avoid volatile/sensitive noise.
if [[ "$rel" == "root" || "$rel" == "root/"* ]]; then
rsync_opts+=(
--exclude ".bash_history"
--exclude ".cache/"
--exclude "tmp/"
--exclude ".local/share/Trash/"
)
fi
# Runtime pending-restore data belongs in /var/lib/proxmenux, never in app code tree.
if [[ "$rel" == "usr/local/share/proxmenux" || "$rel" == "usr/local/share/proxmenux/"* ]]; then
rsync_opts+=(
--exclude "restore-pending/"
)
fi
rsync "${rsync_opts[@]}" "$p/" "$target/" 2>/dev/null || true
else
mkdir -p "$(dirname "$target")"
cp -a "$p" "$target" 2>/dev/null || true
fi
done
# Metadata snapshot
local meta="$staging_root/metadata"
{
echo "generated_at=$(date -Iseconds)"
echo "hostname=$(hostname)"
echo "kernel=$(uname -r)"
} > "$meta/run_info.env"
command -v pveversion >/dev/null 2>&1 && pveversion -v > "$meta/pveversion.txt" 2>&1 || true
command -v lsblk >/dev/null 2>&1 && lsblk -f > "$meta/lsblk.txt" 2>&1 || true
command -v qm >/dev/null 2>&1 && qm list > "$meta/qm-list.txt" 2>&1 || true
command -v pct >/dev/null 2>&1 && pct list > "$meta/pct-list.txt" 2>&1 || true
command -v zpool >/dev/null 2>&1 && zpool status > "$meta/zpool.txt" 2>&1 || true
# Manifest + checksums
(
cd "$staging_root/rootfs" || return 1
find . -mindepth 1 -print | sort > "$meta/manifest.txt"
find . -type f -print0 | sort -z | xargs -0 sha256sum 2>/dev/null \
> "$meta/checksums.sha256" || true
)
}
hb_load_restore_paths() {
local restore_root="$1"
local __out_var="$2"
local -n __out="$__out_var"
__out=()
local selected="$restore_root/metadata/selected_paths.txt"
if [[ -f "$selected" ]]; then
while IFS= read -r line; do
[[ -n "$line" ]] && __out+=("$line")
done < "$selected"
fi
# Fallback: scan rootfs
if [[ ${#__out[@]} -eq 0 ]]; then
local p
while IFS= read -r p; do
[[ -n "$p" && -e "$restore_root/rootfs/${p#/}" ]] && __out+=("${p#/}")
done < <(hb_default_profile_paths)
fi
}
# ==========================================================
# PBS CONFIG — auto-detect from storage.cfg + manual
# ==========================================================
hb_collect_pbs_configs() {
HB_PBS_NAMES=()
HB_PBS_REPOS=()
HB_PBS_SECRETS=()
HB_PBS_SOURCES=()
if [[ -f /etc/pve/storage.cfg ]]; then
local current="" server="" datastore="" username="" pw_file pw_val
while IFS= read -r line; do
line="${line%%#*}"
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[[ -z "$line" ]] && continue
if [[ $line =~ ^pbs:[[:space:]]*(.+)$ ]]; then
if [[ -n "$current" && -n "$server" && -n "$datastore" && -n "$username" ]]; then
pw_file="/etc/pve/priv/storage/${current}.pw"
pw_val="$([[ -f "$pw_file" ]] && cat "$pw_file" || echo "")"
HB_PBS_NAMES+=("$current")
HB_PBS_REPOS+=("${username}@${server}:${datastore}")
HB_PBS_SECRETS+=("$pw_val")
HB_PBS_SOURCES+=("proxmox")
fi
current="${BASH_REMATCH[1]}"; server="" datastore="" username=""
elif [[ -n "$current" ]]; then
[[ $line =~ ^[[:space:]]+server[[:space:]]+(.+)$ ]] && server="${BASH_REMATCH[1]}"
[[ $line =~ ^[[:space:]]+datastore[[:space:]]+(.+)$ ]] && datastore="${BASH_REMATCH[1]}"
[[ $line =~ ^[[:space:]]+username[[:space:]]+(.+)$ ]] && username="${BASH_REMATCH[1]}"
if [[ $line =~ ^[a-zA-Z]+:[[:space:]] &&
-n "$server" && -n "$datastore" && -n "$username" ]]; then
pw_file="/etc/pve/priv/storage/${current}.pw"
pw_val="$([[ -f "$pw_file" ]] && cat "$pw_file" || echo "")"
HB_PBS_NAMES+=("$current")
HB_PBS_REPOS+=("${username}@${server}:${datastore}")
HB_PBS_SECRETS+=("$pw_val")
HB_PBS_SOURCES+=("proxmox")
current="" server="" datastore="" username=""
fi
fi
done < /etc/pve/storage.cfg
# Last stanza
if [[ -n "$current" && -n "$server" && -n "$datastore" && -n "$username" ]]; then
pw_file="/etc/pve/priv/storage/${current}.pw"
pw_val="$([[ -f "$pw_file" ]] && cat "$pw_file" || echo "")"
HB_PBS_NAMES+=("$current")
HB_PBS_REPOS+=("${username}@${server}:${datastore}")
HB_PBS_SECRETS+=("$pw_val")
HB_PBS_SOURCES+=("proxmox")
fi
fi
# Manual configs
local manual_cfg="$HB_STATE_DIR/pbs-manual-configs.txt"
if [[ -f "$manual_cfg" ]]; then
local line name repo sf
while IFS= read -r line; do
line="${line%%#*}"
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[[ -z "$line" ]] && continue
name="${line%%|*}"; repo="${line##*|}"
sf="$HB_STATE_DIR/pbs-pass-${name}.txt"
HB_PBS_NAMES+=("$name"); HB_PBS_REPOS+=("$repo")
HB_PBS_SECRETS+=("$([[ -f "$sf" ]] && cat "$sf" || echo "")")
HB_PBS_SOURCES+=("manual")
done < "$manual_cfg"
fi
}
hb_configure_pbs_manual() {
local name user host datastore repo secret
name=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Add PBS")" \
--inputbox "$(hb_translate "Configuration name:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "PBS-$(date +%m%d)" 3>&1 1>&2 2>&3) || return 1
[[ -z "$name" ]] && return 1
user=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Add PBS")" \
--inputbox "$(hb_translate "Username (e.g. root@pam or user@pbs!token):")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "root@pam" 3>&1 1>&2 2>&3) || return 1
host=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Add PBS")" \
--inputbox "$(hb_translate "PBS host or IP address:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "" 3>&1 1>&2 2>&3) || return 1
[[ -z "$host" ]] && return 1
datastore=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Add PBS")" \
--inputbox "$(hb_translate "Datastore name:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "" 3>&1 1>&2 2>&3) || return 1
[[ -z "$datastore" ]] && return 1
secret=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Add PBS")" \
--insecure --passwordbox "$(hb_translate "Password or API token secret:")" \
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 1
repo="${user}@${host}:${datastore}"
mkdir -p "$HB_STATE_DIR"
local cfg_line="${name}|${repo}"
local manual_cfg="$HB_STATE_DIR/pbs-manual-configs.txt"
touch "$manual_cfg"
grep -Fxq "$cfg_line" "$manual_cfg" || echo "$cfg_line" >> "$manual_cfg"
printf '%s' "$secret" > "$HB_STATE_DIR/pbs-pass-${name}.txt"
chmod 600 "$HB_STATE_DIR/pbs-pass-${name}.txt"
HB_PBS_NAME="$name"; HB_PBS_REPOSITORY="$repo"; HB_PBS_SECRET="$secret"
}
hb_select_pbs_repository() {
hb_collect_pbs_configs
local menu=() i=1 idx
for idx in "${!HB_PBS_NAMES[@]}"; do
local src="${HB_PBS_SOURCES[$idx]}"
local label="${HB_PBS_NAMES[$idx]}${HB_PBS_REPOS[$idx]} [$src]"
[[ -z "${HB_PBS_SECRETS[$idx]}" ]] && label+="$(hb_translate "no password")"
menu+=("$i" "$label"); ((i++))
done
menu+=("$i" "$(hb_translate "+ Add new PBS manually")")
local choice
choice=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Select PBS repository")" \
--menu "\n$(hb_translate "Available PBS repositories:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${menu[@]}" 3>&1 1>&2 2>&3) || return 1
if [[ "$choice" == "$i" ]]; then
hb_configure_pbs_manual || return 1
else
local sel=$((choice-1))
HB_PBS_NAME="${HB_PBS_NAMES[$sel]}"
export HB_PBS_REPOSITORY="${HB_PBS_REPOS[$sel]}"
HB_PBS_SECRET="${HB_PBS_SECRETS[$sel]}"
if [[ -z "$HB_PBS_SECRET" ]]; then
HB_PBS_SECRET=$(dialog --backtitle "ProxMenux" --title "PBS" \
--insecure --passwordbox \
"$(hb_translate "Password for:") $HB_PBS_NAME" \
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 1
mkdir -p "$HB_STATE_DIR"
printf '%s' "$HB_PBS_SECRET" > "$HB_STATE_DIR/pbs-pass-${HB_PBS_NAME}.txt"
chmod 600 "$HB_STATE_DIR/pbs-pass-${HB_PBS_NAME}.txt"
fi
fi
}
hb_ask_pbs_encryption() {
local key_file="$HB_STATE_DIR/pbs-key.conf"
local enc_pass_file="$HB_STATE_DIR/pbs-encryption-pass.txt"
export HB_PBS_KEYFILE_OPT=""
export HB_PBS_ENC_PASS=""
dialog --backtitle "ProxMenux" --title "$(hb_translate "Encryption")" \
--yesno "$(hb_translate "Encrypt this backup with a keyfile?")" \
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W" || return 0
if [[ -f "$key_file" ]]; then
export HB_PBS_KEYFILE_OPT="--keyfile $key_file"
if [[ -f "$enc_pass_file" ]]; then
HB_PBS_ENC_PASS="$(<"$enc_pass_file")"
export HB_PBS_ENC_PASS
fi
msg_ok "$(hb_translate "Using existing encryption key:") $key_file"
return 0
fi
# No key — offer to create one
dialog --backtitle "ProxMenux" --title "$(hb_translate "Encryption")" \
--yesno "$(hb_translate "No encryption key found. Create one now?")" \
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W" || return 0
local pass1 pass2
while true; do
pass1=$(dialog --backtitle "ProxMenux" --insecure --passwordbox \
"$(hb_translate "Encryption passphrase (separate from PBS password):")" \
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 0
pass2=$(dialog --backtitle "ProxMenux" --insecure --passwordbox \
"$(hb_translate "Confirm encryption passphrase:")" \
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 0
[[ "$pass1" == "$pass2" ]] && break
dialog --backtitle "ProxMenux" \
--msgbox "$(hb_translate "Passphrases do not match. Try again.")" 8 50
done
msg_info "$(hb_translate "Creating PBS encryption key...")"
if PBS_ENCRYPTION_PASSWORD="$pass1" \
proxmox-backup-client key create "$key_file" >/dev/null 2>&1; then
printf '%s' "$pass1" > "$enc_pass_file"
chmod 600 "$enc_pass_file"
msg_ok "$(hb_translate "Encryption key created:") $key_file"
HB_PBS_KEYFILE_OPT="--keyfile $key_file"
HB_PBS_ENC_PASS="$pass1"
local key_warn_msg
key_warn_msg="$(hb_translate "IMPORTANT: Back up this key file. Without it the backup cannot be restored.")"$'\n\n'"$(hb_translate "Key:") $key_file"
dialog --backtitle "ProxMenux" --msgbox \
"$key_warn_msg" \
10 74
else
msg_error "$(hb_translate "Failed to create encryption key. Backup will proceed without encryption.")"
fi
}
# ==========================================================
# BORG
# ==========================================================
hb_ensure_borg() {
command -v borg >/dev/null 2>&1 && { echo "borg"; return 0; }
local appimage="$HB_STATE_DIR/borg"
local tmp_file
[[ -x "$appimage" ]] && { echo "$appimage"; return 0; }
command -v sha256sum >/dev/null 2>&1 || {
msg_error "$(hb_translate "sha256sum not found. Cannot verify Borg binary.")"
return 1
}
msg_info "$(hb_translate "Borg not found. Downloading borg") ${HB_BORG_VERSION}..."
mkdir -p "$HB_STATE_DIR"
tmp_file=$(mktemp "$HB_STATE_DIR/.borg-download.XXXXXX") || return 1
if wget -qO "$tmp_file" "$HB_BORG_LINUX64_URL"; then
if echo "${HB_BORG_LINUX64_SHA256} $tmp_file" | sha256sum -c - >/dev/null 2>&1; then
mv -f "$tmp_file" "$appimage"
else
rm -f "$tmp_file"
msg_error "$(hb_translate "Borg binary checksum verification failed.")"
return 1
fi
chmod +x "$appimage"
msg_ok "$(hb_translate "Borg ready.")"
echo "$appimage"; return 0
fi
rm -f "$tmp_file"
msg_error "$(hb_translate "Failed to download Borg.")"
return 1
}
hb_borg_init_if_needed() {
local borg_bin="$1" repo="$2" encrypt_mode="$3"
"$borg_bin" list "$repo" >/dev/null 2>&1 && return 0
if "$borg_bin" help repo-create >/dev/null 2>&1; then
"$borg_bin" repo-create -e "$encrypt_mode" "$repo"
else
"$borg_bin" init --encryption="$encrypt_mode" "$repo"
fi
}
hb_prepare_borg_passphrase() {
local pass_file="$HB_STATE_DIR/borg-pass.txt"
BORG_ENCRYPT_MODE="none"
unset BORG_PASSPHRASE
if [[ -f "$pass_file" ]]; then
export BORG_PASSPHRASE
BORG_PASSPHRASE="$(<"$pass_file")"
BORG_ENCRYPT_MODE="repokey"
return 0
fi
dialog --backtitle "ProxMenux" --title "$(hb_translate "Borg encryption")" \
--yesno "$(hb_translate "Encrypt this Borg repository?")" \
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W" || return 0
local pass1 pass2
while true; do
pass1=$(dialog --backtitle "ProxMenux" --insecure --passwordbox \
"$(hb_translate "Borg passphrase:")" \
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 1
pass2=$(dialog --backtitle "ProxMenux" --insecure --passwordbox \
"$(hb_translate "Confirm Borg passphrase:")" \
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 1
[[ "$pass1" == "$pass2" ]] && break
dialog --backtitle "ProxMenux" \
--msgbox "$(hb_translate "Passphrases do not match.")" 8 50
done
mkdir -p "$HB_STATE_DIR"
printf '%s' "$pass1" > "$pass_file"
chmod 600 "$pass_file"
export BORG_PASSPHRASE="$pass1"
export BORG_ENCRYPT_MODE="repokey"
}
hb_select_borg_repo() {
local _borg_repo_var="$1"
local -n _borg_repo_ref="$_borg_repo_var"
local type
type=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Borg repository location")" \
--menu "\n$(hb_translate "Select repository destination:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
"local" "$(hb_translate 'Local directory')" \
"usb" "$(hb_translate 'Mounted external disk')" \
"remote" "$(hb_translate 'Remote server via SSH')" \
3>&1 1>&2 2>&3) || return 1
unset BORG_RSH
case "$type" in
local)
_borg_repo_ref=$(dialog --backtitle "ProxMenux" \
--inputbox "$(hb_translate "Borg repository path:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/backup/borgbackup" \
3>&1 1>&2 2>&3) || return 1
mkdir -p "$_borg_repo_ref" 2>/dev/null || true
;;
usb)
local mnt
mnt=$(hb_prompt_mounted_path "/mnt/backup") || return 1
_borg_repo_ref="$mnt/borgbackup"
mkdir -p "$_borg_repo_ref" 2>/dev/null || true
;;
remote)
local user host rpath ssh_key
user=$(dialog --backtitle "ProxMenux" --inputbox "$(hb_translate "SSH user:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "root" 3>&1 1>&2 2>&3) || return 1
host=$(dialog --backtitle "ProxMenux" --inputbox "$(hb_translate "SSH host or IP:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "" 3>&1 1>&2 2>&3) || return 1
rpath=$(dialog --backtitle "ProxMenux" \
--inputbox "$(hb_translate "Remote repository path:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/backup/borgbackup" \
3>&1 1>&2 2>&3) || return 1
if dialog --backtitle "ProxMenux" \
--yesno "$(hb_translate "Use a custom SSH key?")" \
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W"; then
ssh_key=$(dialog --backtitle "ProxMenux" \
--fselect "$HOME/.ssh/" 12 70 3>&1 1>&2 2>&3) || return 1
export BORG_RSH="ssh -i $ssh_key -o StrictHostKeyChecking=accept-new"
fi
_borg_repo_ref="ssh://$user@$host/$rpath"
;;
esac
}
# ==========================================================
# COMMON PROMPTS
# ==========================================================
hb_trim_dialog_value() {
local value="$1"
value="${value//$'\r'/}"
value="${value//$'\n'/}"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf '%s' "$value"
}
hb_prompt_mounted_path() {
local default_path="${1:-/mnt/backup}"
local out
out=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Mounted disk path")" \
--inputbox "$(hb_translate "Path where the external disk is mounted:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "$default_path" 3>&1 1>&2 2>&3) || return 1
out=$(hb_trim_dialog_value "$out")
[[ -n "$out" && -d "$out" ]] || { msg_error "$(hb_translate "Path does not exist.")"; return 1; }
if ! mountpoint -q "$out" 2>/dev/null; then
dialog --backtitle "ProxMenux" --title "$(hb_translate "Warning")" \
--yesno "$(hb_translate "This path is not a registered mount point. Use it anyway?")" \
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W" || return 1
fi
echo "$out"
}
hb_prompt_dest_dir() {
local selection out
selection=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Select destination")" \
--menu "\n$(hb_translate "Choose where to save the backup:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
"vzdump" "$(hb_translate '/var/lib/vz/dump (Proxmox default vzdump path)')" \
"backup" "$(hb_translate '/backup')" \
"local" "$(hb_translate 'Custom local directory')" \
"usb" "$(hb_translate 'Mounted external disk')" \
3>&1 1>&2 2>&3) || return 1
case "$selection" in
vzdump) out="/var/lib/vz/dump" ;;
backup) out="/backup" ;;
local)
out=$(dialog --backtitle "ProxMenux" \
--inputbox "$(hb_translate "Enter directory path:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/backup" 3>&1 1>&2 2>&3) || return 1
;;
usb) out=$(hb_prompt_mounted_path "/mnt/backup") || return 1 ;;
esac
out=$(hb_trim_dialog_value "$out")
[[ -n "$out" ]] || return 1
mkdir -p "$out" || { msg_error "$(hb_translate "Cannot create:") $out"; return 1; }
echo "$out"
}
hb_prompt_restore_source_dir() {
local choice out
choice=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Restore source location")" \
--menu "\n$(hb_translate "Where are the backup archives stored?")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
"vzdump" "$(hb_translate '/var/lib/vz/dump (Proxmox default)')" \
"backup" "$(hb_translate '/backup')" \
"usb" "$(hb_translate 'Mounted external disk')" \
"custom" "$(hb_translate 'Custom path')" \
3>&1 1>&2 2>&3) || return 1
case "$choice" in
vzdump) out="/var/lib/vz/dump" ;;
backup) out="/backup" ;;
usb) out=$(hb_prompt_mounted_path "/mnt/backup") || return 1 ;;
custom)
out=$(dialog --backtitle "ProxMenux" \
--inputbox "$(hb_translate "Enter path:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/backup" 3>&1 1>&2 2>&3) || return 1
;;
esac
out=$(hb_trim_dialog_value "$out")
[[ -n "$out" && -d "$out" ]] || {
msg_error "$(hb_translate "Directory does not exist.")"
return 1
}
echo "$out"
}
hb_prompt_local_archive() {
local base_dir="$1"
local title="${2:-$(hb_translate "Select backup archive")}"
local -a rows=() files=() menu=()
# Single find pass using -printf: no per-file stat subprocesses.
# maxdepth 6 catches nested backup layouts commonly used in /var/lib/vz/dump.
mapfile -t rows < <(
find "$base_dir" -maxdepth 6 -type f \
\( -name '*.tar.zst' -o -name '*.tar.gz' -o -name '*.tar' \) \
-printf '%T@|%s|%p\n' 2>/dev/null \
| sort -t'|' -k1,1nr \
| head -200
)
if [[ ${#rows[@]} -eq 0 ]]; then
local no_backups_msg
no_backups_msg="$(hb_translate "No backup archives were found in:") $base_dir"$'\n\n'"$(hb_translate "Select another source path and try again.")"
dialog --backtitle "ProxMenux" \
--title "$(hb_translate "No backups found")" \
--msgbox "$no_backups_msg" \
10 78 || true
return 1
fi
local i=1 row epoch size path date_str size_str label
for row in "${rows[@]}"; do
epoch="${row%%|*}"; row="${row#*|}"
size="${row%%|*}"; path="${row#*|}"
epoch="${epoch%%.*}" # drop sub-second fraction from %T@
date_str=$(date -d "@$epoch" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "-")
size_str=$(numfmt --to=iec-i --suffix=B "$size" 2>/dev/null || echo "${size}B")
label="${path#$base_dir/} $date_str $size_str"
files+=("$path"); menu+=("$i" "$label"); ((i++))
done
local choice
choice=$(dialog --backtitle "ProxMenux" --title "$title" \
--menu "\n$(hb_translate "Detected backups — newest first:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${menu[@]}" 3>&1 1>&2 2>&3) || return 1
echo "${files[$((choice-1))]}"
}
# ==========================================================
# UTILITIES
# ==========================================================
hb_human_elapsed() {
local secs="$1"
if (( secs < 60 )); then printf '%ds' "$secs"
elif (( secs < 3600 )); then printf '%dm %ds' "$((secs/60))" "$((secs%60))"
else printf '%dh %dm' "$((secs/3600))" "$(( (secs%3600)/60 ))"
fi
}
hb_file_size() {
local path="$1"
if [[ -f "$path" ]]; then
numfmt --to=iec-i --suffix=B "$(stat -c %s "$path" 2>/dev/null || echo 0)" 2>/dev/null \
|| du -sh "$path" 2>/dev/null | awk '{print $1}'
elif [[ -d "$path" ]]; then
du -sh "$path" 2>/dev/null | awk '{print $1}'
else
echo "-"
fi
}
hb_show_log() {
local logfile="$1" title="${2:-$(hb_translate "Operation log")}"
[[ -f "$logfile" && -s "$logfile" ]] || return 0
dialog --backtitle "ProxMenux" --exit-label "OK" \
--title "$title" --textbox "$logfile" 26 110 || true
}
hb_require_cmd() {
local cmd="$1" pkg="${2:-$1}"
command -v "$cmd" >/dev/null 2>&1 && return 0
if command -v apt-get >/dev/null 2>&1; then
msg_warn "$(hb_translate "Installing dependency:") $pkg"
apt-get update -qq >/dev/null 2>&1 && apt-get install -y "$pkg" >/dev/null 2>&1
fi
command -v "$cmd" >/dev/null 2>&1
}

View File

@@ -0,0 +1,243 @@
#!/bin/bash
# ==========================================================
# ProxMenux - Run Scheduled Host Backup Job
# ==========================================================
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOCAL_SCRIPTS_LOCAL="$(cd "$SCRIPT_DIR/.." && pwd)"
LOCAL_SCRIPTS_DEFAULT="/usr/local/share/proxmenux/scripts"
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_DEFAULT"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
if [[ -f "$LOCAL_SCRIPTS_LOCAL/utils.sh" ]]; then
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_LOCAL"
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
elif [[ ! -f "$UTILS_FILE" ]]; then
UTILS_FILE="$BASE_DIR/utils.sh"
fi
if [[ -f "$UTILS_FILE" ]]; then
# shellcheck source=/dev/null
source "$UTILS_FILE"
else
echo "ERROR: utils.sh not found" >&2
exit 1
fi
LIB_FILE="$SCRIPT_DIR/lib_host_backup_common.sh"
[[ ! -f "$LIB_FILE" ]] && LIB_FILE="$LOCAL_SCRIPTS_DEFAULT/backup_restore/lib_host_backup_common.sh"
if [[ -f "$LIB_FILE" ]]; then
# shellcheck source=/dev/null
source "$LIB_FILE"
else
echo "ERROR: lib_host_backup_common.sh not found" >&2
exit 1
fi
JOBS_DIR="${PMX_BACKUP_JOBS_DIR:-/var/lib/proxmenux/backup-jobs}"
LOG_DIR="${PMX_BACKUP_LOG_DIR:-/var/log/proxmenux/backup-jobs}"
LOCK_DIR="${PMX_BACKUP_LOCK_DIR:-/var/lock}"
mkdir -p "$JOBS_DIR" "$LOG_DIR" >/dev/null 2>&1 || true
_sb_prune_local() {
local job_id="$1"
local dest_dir="$2"
local ext="$3" # tar.zst or tar.gz
local keep_last="${KEEP_LAST:-0}"
local -a files=()
mapfile -t files < <(find "$dest_dir" -maxdepth 1 -type f -name "${job_id}-*.${ext}" | sort -r)
[[ ${#files[@]} -eq 0 ]] && return 0
if [[ "$keep_last" =~ ^[0-9]+$ ]] && (( keep_last > 0 )); then
local idx=0
for f in "${files[@]}"; do
idx=$((idx+1))
(( idx <= keep_last )) && continue
rm -f "$f" || true
done
fi
}
_sb_run_local() {
local stage_root="$1"
local job_id="$2"
local ts="$3"
local dest_dir="$4"
local archive_ext="${LOCAL_ARCHIVE_EXT:-tar.zst}"
local archive="${dest_dir}/${job_id}-${ts}.${archive_ext}"
mkdir -p "$dest_dir" || return 1
if [[ "$archive_ext" == "tar.zst" ]] && command -v zstd >/dev/null 2>&1; then
tar --zstd -cf "$archive" -C "$stage_root" . >/dev/null 2>&1 || return 1
else
archive="${dest_dir}/${job_id}-${ts}.tar.gz"
tar -czf "$archive" -C "$stage_root" . >/dev/null 2>&1 || return 1
archive_ext="tar.gz"
fi
_sb_prune_local "$job_id" "$dest_dir" "$archive_ext"
echo "LOCAL_ARCHIVE=$archive"
return 0
}
_sb_run_borg() {
local stage_root="$1"
local archive_name="$2"
local borg_bin repo passphrase
borg_bin=$(hb_ensure_borg) || return 1
repo="${BORG_REPO:-}"
passphrase="${BORG_PASSPHRASE:-}"
[[ -z "$repo" || -z "$passphrase" ]] && return 1
export BORG_PASSPHRASE="$passphrase"
if ! hb_borg_init_if_needed "$borg_bin" "$repo" "${BORG_ENCRYPT_MODE:-none}" >/dev/null 2>&1; then
return 1
fi
(cd "$stage_root" && "$borg_bin" create --stats \
"${repo}::${archive_name}" rootfs metadata) >/dev/null 2>&1 || return 1
"$borg_bin" prune -v --list "$repo" \
${KEEP_LAST:+--keep-last "$KEEP_LAST"} \
${KEEP_HOURLY:+--keep-hourly "$KEEP_HOURLY"} \
${KEEP_DAILY:+--keep-daily "$KEEP_DAILY"} \
${KEEP_WEEKLY:+--keep-weekly "$KEEP_WEEKLY"} \
${KEEP_MONTHLY:+--keep-monthly "$KEEP_MONTHLY"} \
${KEEP_YEARLY:+--keep-yearly "$KEEP_YEARLY"} \
>/dev/null 2>&1 || true
echo "BORG_ARCHIVE=${archive_name}"
return 0
}
_sb_run_pbs() {
local stage_root="$1"
local backup_id="$2"
local epoch="$3"
local -a cmd=(
proxmox-backup-client backup
"hostcfg.pxar:${stage_root}/rootfs"
--repository "$PBS_REPOSITORY"
--backup-type host
--backup-id "$backup_id"
--backup-time "$epoch"
)
[[ -z "${PBS_REPOSITORY:-}" || -z "${PBS_PASSWORD:-}" ]] && return 1
if [[ -n "${PBS_KEYFILE:-}" ]]; then
cmd+=(--keyfile "$PBS_KEYFILE")
fi
env PBS_PASSWORD="$PBS_PASSWORD" PBS_ENCRYPTION_PASSWORD="${PBS_ENCRYPTION_PASSWORD:-}" \
"${cmd[@]}" >/dev/null 2>&1 || return 1
# Best effort prune for PBS group.
proxmox-backup-client prune "host/${backup_id}" --repository "$PBS_REPOSITORY" \
${KEEP_LAST:+--keep-last "$KEEP_LAST"} \
${KEEP_HOURLY:+--keep-hourly "$KEEP_HOURLY"} \
${KEEP_DAILY:+--keep-daily "$KEEP_DAILY"} \
${KEEP_WEEKLY:+--keep-weekly "$KEEP_WEEKLY"} \
${KEEP_MONTHLY:+--keep-monthly "$KEEP_MONTHLY"} \
${KEEP_YEARLY:+--keep-yearly "$KEEP_YEARLY"} \
>/dev/null 2>&1 || true
echo "PBS_SNAPSHOT=host/${backup_id}/${epoch}"
return 0
}
main() {
local job_id="${1:-}"
[[ -z "$job_id" ]] && { echo "Usage: $0 <job_id>" >&2; exit 1; }
local job_file="${JOBS_DIR}/${job_id}.env"
[[ -f "$job_file" ]] || { echo "Job not found: $job_id" >&2; exit 1; }
# shellcheck source=/dev/null
source "$job_file"
local lock_file="${LOCK_DIR}/proxmenux-backup-${job_id}.lock"
if command -v flock >/dev/null 2>&1; then
exec 9>"$lock_file" || exit 1
if ! flock -n 9; then
echo "Another run is active for job ${job_id}" >&2
exit 1
fi
fi
local ts log_file stage_root summary_file
ts="$(date +%Y%m%d_%H%M%S)"
log_file="${LOG_DIR}/${job_id}-${ts}.log"
summary_file="${LOG_DIR}/${job_id}-last.status"
stage_root="$(mktemp -d /tmp/proxmenux-sched-stage.XXXXXX)"
{
echo "JOB_ID=${job_id}"
echo "RUN_AT=$(date -Iseconds)"
echo "BACKEND=${BACKEND:-}"
echo "PROFILE_MODE=${PROFILE_MODE:-default}"
} >"$summary_file"
{
echo "=== Scheduled backup job ${job_id} started at $(date -Iseconds) ==="
echo "Backend: ${BACKEND:-}"
} >"$log_file"
local -a paths=()
if [[ "${PROFILE_MODE:-default}" == "custom" && -f "${JOBS_DIR}/${job_id}.paths" ]]; then
mapfile -t paths < "${JOBS_DIR}/${job_id}.paths"
else
mapfile -t paths < <(hb_default_profile_paths)
fi
if [[ ${#paths[@]} -eq 0 ]]; then
echo "No paths configured for job" >>"$log_file"
echo "RESULT=failed" >>"$summary_file"
rm -rf "$stage_root"
exit 1
fi
hb_prepare_staging "$stage_root" "${paths[@]}" >>"$log_file" 2>&1
local rc=1
case "${BACKEND:-}" in
local)
_sb_run_local "$stage_root" "$job_id" "$ts" "${LOCAL_DEST_DIR:-/var/lib/vz/dump}" >>"$log_file" 2>&1
rc=$?
;;
borg)
_sb_run_borg "$stage_root" "${job_id}-${ts}" >>"$log_file" 2>&1
rc=$?
;;
pbs)
_sb_run_pbs "$stage_root" "${PBS_BACKUP_ID:-hostcfg-$(hostname)}" "$(date +%s)" >>"$log_file" 2>&1
rc=$?
;;
*)
echo "Unknown backend: ${BACKEND:-}" >>"$log_file"
rc=1
;;
esac
rm -rf "$stage_root"
if [[ $rc -eq 0 ]]; then
echo "RESULT=ok" >>"$summary_file"
echo "LOG_FILE=${log_file}" >>"$summary_file"
echo "=== Job finished OK at $(date -Iseconds) ===" >>"$log_file"
exit 0
else
echo "RESULT=failed" >>"$summary_file"
echo "LOG_FILE=${log_file}" >>"$summary_file"
echo "=== Job finished with errors at $(date -Iseconds) ===" >>"$log_file"
exit 1
fi
}
main "$@"

View File

@@ -0,0 +1,284 @@
#!/bin/bash
# ==========================================================
# ProxMenux - Backup/Restore Test Matrix (non-destructive)
# ==========================================================
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RUNNER="${SCRIPT_DIR}/run_scheduled_backup.sh"
APPLY_ONBOOT="${SCRIPT_DIR}/apply_pending_restore.sh"
HOST_SCRIPT="${SCRIPT_DIR}/backup_host.sh"
LIB_SCRIPT="${SCRIPT_DIR}/lib_host_backup_common.sh"
SCHED_SCRIPT="${SCRIPT_DIR}/backup_scheduler.sh"
KEEP_TMP=0
if [[ "${1:-}" == "--keep-tmp" ]]; then
KEEP_TMP=1
fi
TMP_ROOT="$(mktemp -d /tmp/proxmenux-brtest.XXXXXX)"
REPORT_FILE="/tmp/proxmenux-backup-restore-test-$(date +%Y%m%d_%H%M%S).log"
PASS=0
FAIL=0
SKIP=0
log() {
echo "$*" | tee -a "$REPORT_FILE"
}
pass() {
PASS=$((PASS + 1))
log "[PASS] $*"
}
fail() {
FAIL=$((FAIL + 1))
log "[FAIL] $*"
}
skip() {
SKIP=$((SKIP + 1))
log "[SKIP] $*"
}
cleanup() {
if [[ "$KEEP_TMP" -eq 0 ]]; then
rm -rf "$TMP_ROOT"
else
log "[INFO] Temp root preserved: $TMP_ROOT"
fi
}
trap cleanup EXIT
assert_file_contains() {
local file="$1"
local needle="$2"
if [[ -f "$file" ]] && grep -q "$needle" "$file"; then
return 0
fi
return 1
}
run_cmd_expect_ok() {
local desc="$1"
shift
if "$@" >>"$REPORT_FILE" 2>&1; then
pass "$desc"
return 0
fi
fail "$desc"
return 1
}
run_cmd_expect_fail() {
local desc="$1"
shift
if "$@" >>"$REPORT_FILE" 2>&1; then
fail "$desc"
return 1
fi
pass "$desc"
return 0
}
syntax_tests() {
log "\n=== Syntax checks ==="
run_cmd_expect_ok "bash -n backup_host.sh" bash -n "$HOST_SCRIPT"
run_cmd_expect_ok "bash -n lib_host_backup_common.sh" bash -n "$LIB_SCRIPT"
run_cmd_expect_ok "bash -n backup_scheduler.sh" bash -n "$SCHED_SCRIPT"
run_cmd_expect_ok "bash -n run_scheduled_backup.sh" bash -n "$RUNNER"
run_cmd_expect_ok "bash -n apply_pending_restore.sh" bash -n "$APPLY_ONBOOT"
}
scheduler_e2e_tests() {
log "\n=== Scheduler E2E (sandbox) ==="
if ! help mapfile >/dev/null 2>&1; then
skip "Scheduler E2E skipped: current bash does not provide mapfile (requires bash >= 4)."
return
fi
local jobs_dir="$TMP_ROOT/backup-jobs"
local logs_dir="$TMP_ROOT/backup-jobs-logs"
local lock_dir="$TMP_ROOT/locks"
local archives_dir="$TMP_ROOT/archives"
mkdir -p "$jobs_dir" "$logs_dir" "$lock_dir" "$archives_dir"
cat > "$jobs_dir/t1.env" <<EOJ
JOB_ID=t1
BACKEND=local
PROFILE_MODE=custom
LOCAL_DEST_DIR=${archives_dir}
LOCAL_ARCHIVE_EXT=tar.gz
KEEP_LAST=2
KEEP_HOURLY=0
KEEP_DAILY=0
KEEP_WEEKLY=0
KEEP_MONTHLY=0
KEEP_YEARLY=0
EOJ
cat > "$jobs_dir/t1.paths" <<EOP
/etc/hosts
/etc/resolv.conf
EOP
local i
for i in 1 2 3; do
if PMX_BACKUP_JOBS_DIR="$jobs_dir" PMX_BACKUP_LOG_DIR="$logs_dir" PMX_BACKUP_LOCK_DIR="$lock_dir" \
bash "$RUNNER" t1 >>"$REPORT_FILE" 2>&1; then
:
else
fail "Runner execution #$i for t1"
return
fi
sleep 1
done
local archive_count
archive_count="$(find "$archives_dir" -maxdepth 1 -type f -name 't1-*.tar.gz' | wc -l | tr -d ' ')"
if [[ "$archive_count" == "2" ]]; then
pass "Retention KEEP_LAST=2 keeps exactly 2 archives"
else
fail "Retention expected 2 archives, got $archive_count"
fi
if assert_file_contains "$logs_dir/t1-last.status" "RESULT=ok"; then
pass "t1-last.status reports RESULT=ok"
else
fail "t1-last.status does not report RESULT=ok"
fi
cat > "$jobs_dir/tbad.env" <<EOJ
JOB_ID=tbad
BACKEND=invalid
PROFILE_MODE=custom
KEEP_LAST=1
EOJ
echo "/etc/hosts" > "$jobs_dir/tbad.paths"
run_cmd_expect_fail "Invalid backend fails" \
env PMX_BACKUP_JOBS_DIR="$jobs_dir" PMX_BACKUP_LOG_DIR="$logs_dir" PMX_BACKUP_LOCK_DIR="$lock_dir" \
bash "$RUNNER" tbad
if assert_file_contains "$logs_dir/tbad-last.status" "RESULT=failed"; then
pass "tbad-last.status reports RESULT=failed"
else
fail "tbad-last.status does not report RESULT=failed"
fi
cat > "$jobs_dir/tempty.env" <<EOJ
JOB_ID=tempty
BACKEND=local
PROFILE_MODE=custom
LOCAL_DEST_DIR=${archives_dir}
LOCAL_ARCHIVE_EXT=tar.gz
KEEP_LAST=1
EOJ
: > "$jobs_dir/tempty.paths"
run_cmd_expect_fail "Empty paths fails" \
env PMX_BACKUP_JOBS_DIR="$jobs_dir" PMX_BACKUP_LOG_DIR="$logs_dir" PMX_BACKUP_LOCK_DIR="$lock_dir" \
bash "$RUNNER" tempty
if assert_file_contains "$logs_dir/tempty-last.status" "RESULT=failed"; then
pass "tempty-last.status reports RESULT=failed"
else
fail "tempty-last.status does not report RESULT=failed"
fi
}
pending_restore_tests() {
log "\n=== Pending restore E2E (sandbox) ==="
local pending_base="$TMP_ROOT/restore-pending"
local logs_dir="$TMP_ROOT/restore-logs"
local target_root="$TMP_ROOT/target"
local pre_backup_base="$TMP_ROOT/pre-restore"
local recovery_base="$TMP_ROOT/recovery"
mkdir -p "$pending_base/r1/rootfs/etc/pve" "$pending_base/r1/rootfs/etc/zfs" "$pending_base/r1/rootfs/etc" "$target_root/etc"
echo "new-value" > "$pending_base/r1/rootfs/etc/test.conf"
echo "cluster-data" > "$pending_base/r1/rootfs/etc/pve/cluster.cfg"
echo "zfs-data" > "$pending_base/r1/rootfs/etc/zfs/zpool.cache"
echo "old-value" > "$target_root/etc/test.conf"
cat > "$pending_base/r1/apply-on-boot.list" <<EOL
etc/test.conf
etc/pve/cluster.cfg
etc/zfs/zpool.cache
EOL
cat > "$pending_base/r1/plan.env" <<EOP
HB_RESTORE_INCLUDE_ZFS=0
EOP
ln -sfn "$pending_base/r1" "$pending_base/current"
if PMX_RESTORE_PENDING_BASE="$pending_base" PMX_RESTORE_LOG_DIR="$logs_dir" \
PMX_RESTORE_DEST_PREFIX="$target_root" PMX_RESTORE_PRE_BACKUP_BASE="$pre_backup_base" \
PMX_RESTORE_RECOVERY_BASE="$recovery_base" \
bash "$APPLY_ONBOOT" >>"$REPORT_FILE" 2>&1; then
pass "apply_pending_restore completes"
else
fail "apply_pending_restore completes"
return
fi
if assert_file_contains "$target_root/etc/test.conf" "new-value"; then
pass "Regular file restored into target prefix"
else
fail "Regular file was not restored"
fi
if [[ -e "$target_root/etc/pve/cluster.cfg" ]]; then
fail "Cluster file should not be restored live"
else
pass "Cluster file skipped from live restore"
fi
if find "$recovery_base" -type f -name cluster.cfg 2>/dev/null | grep -q .; then
pass "Cluster file extracted to recovery directory"
else
fail "Cluster file not found in recovery directory"
fi
if assert_file_contains "$pending_base/completed/r1/state" "completed"; then
pass "Pending restore state marked completed"
else
fail "Pending restore state not marked completed"
fi
if [[ -e "$pending_base/current" ]]; then
fail "current symlink should be removed"
else
pass "current symlink removed"
fi
}
main() {
log "ProxMenux backup/restore test matrix"
log "Report: $REPORT_FILE"
log "Temp root: $TMP_ROOT"
syntax_tests
scheduler_e2e_tests
pending_restore_tests
log "\n=== Summary ==="
log "PASS=$PASS"
log "FAIL=$FAIL"
log "SKIP=$SKIP"
if [[ "$FAIL" -eq 0 ]]; then
log "RESULT=OK"
exit 0
else
log "RESULT=FAILED"
exit 1
fi
}
main "$@"