Files
gniza4cp/bin/gniza
shuki 0eb480489e Add per-schedule toggle to skip suspended cPanel accounts
Adds SKIP_SUSPENDED config key and --skip-suspended CLI flag that
excludes suspended accounts (detected via /var/cpanel/suspended/)
from backups. Follows the same pattern as the existing SYSBACKUP
toggle across all layers: config, schedule loader, cron builder,
CLI flag parsing, and WHM UI (table toggle, AJAX handler, form card).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 19:10:18 +02:00

1612 lines
57 KiB
Bash
Executable File

#!/usr/bin/env bash
# gniza — cPanel Backup, Restore & Disaster Recovery
# CLI entrypoint and command routing
set -euo pipefail
# Resolve install directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ -L "${BASH_SOURCE[0]}" ]]; then
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
fi
BASE_DIR="$(dirname "$SCRIPT_DIR")"
LIB_DIR="$BASE_DIR/lib"
# Source libraries
source "$LIB_DIR/constants.sh"
source "$LIB_DIR/utils.sh"
source "$LIB_DIR/logging.sh"
source "$LIB_DIR/config.sh"
source "$LIB_DIR/locking.sh"
source "$LIB_DIR/ssh.sh"
source "$LIB_DIR/accounts.sh"
source "$LIB_DIR/pkgacct.sh"
source "$LIB_DIR/snapshot.sh"
source "$LIB_DIR/transfer.sh"
source "$LIB_DIR/retention.sh"
source "$LIB_DIR/verify.sh"
source "$LIB_DIR/notify.sh"
source "$LIB_DIR/restore.sh"
source "$LIB_DIR/remotes.sh"
source "$LIB_DIR/rclone.sh"
source "$LIB_DIR/schedule.sh"
source "$LIB_DIR/sysbackup.sh"
source "$LIB_DIR/sysrestore.sh"
# ── Argument parsing helpers ───────────────────────────────────
get_opt() {
local name="$1"; shift
for arg in "$@"; do
case "$arg" in
--${name}=*) echo "${arg#*=}"; return 0 ;;
esac
done
return 1
}
has_flag() {
local name="$1"; shift
for arg in "$@"; do
[[ "$arg" == "--$name" ]] && return 0
done
return 1
}
# ── Commands ───────────────────────────────────────────────────
# Transfer + finalize + retention for a single account on the current remote.
# Globals REMOTE_* must already be set via load_remote().
# Returns 0 on success, 1 on failure.
_backup_to_current_remote() {
local user="$1"
local ts="$2"
local remote_label="$CURRENT_REMOTE_NAME"
# Clean any leftover partials
clean_partial_snapshots "$user"
# Find previous snapshot for hardlinking
local prev; prev=$(get_latest_snapshot "$user") || prev=""
# Transfer pkgacct data
if ! transfer_pkgacct "$user" "$ts" "$prev"; then
log_error "[$remote_label] Transfer pkgacct failed for $user"
return 1
fi
# Transfer homedir
if ! transfer_homedir "$user" "$ts" "$prev"; then
log_error "[$remote_label] Transfer homedir failed for $user"
return 1
fi
# Finalize snapshot
if ! finalize_snapshot "$user" "$ts"; then
log_error "[$remote_label] Finalize failed for $user"
return 1
fi
# Enforce retention
enforce_retention "$user"
return 0
}
cmd_backup() {
require_root
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file"
validate_config || die "Invalid configuration"
init_logging
local dry_run=false
has_flag dry-run "$@" && dry_run=true
local run_sysbackup=false
has_flag sysbackup "$@" && run_sysbackup=true
local skip_suspended=false
has_flag skip-suspended "$@" && skip_suspended=true
local single_account=""
single_account=$(get_opt account "$@" 2>/dev/null) || true
local remote_flag=""
remote_flag=$(get_opt remote "$@" 2>/dev/null) || true
acquire_lock
trap 'cleanup_all_temp; release_lock' EXIT
# Resolve target remotes
local remotes=""
remotes=$(get_target_remotes "$remote_flag") || die "Invalid remote specification"
# Save original globals (will be restored after each load_remote)
_save_remote_globals
# Test connectivity to all targets upfront
while IFS= read -r rname; do
[[ -z "$rname" ]] && continue
load_remote "$rname" || die "Failed to load remote: $rname"
if _is_rclone_mode; then
test_rclone_connection || die "Cannot connect to remote '$rname' (${REMOTE_TYPE})"
else
test_ssh_connection || die "Cannot connect to remote '$rname' (${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PORT})"
fi
done <<< "$remotes"
_restore_remote_globals
local start_time; start_time=$(date +%s)
local ts; ts=$(timestamp)
# Determine accounts
local accounts
if [[ -n "$single_account" ]]; then
account_exists "$single_account" || die "Account does not exist: $single_account"
accounts="$single_account"
else
accounts=$(get_backup_accounts "$skip_suspended")
fi
if [[ -z "$accounts" ]]; then
die "No accounts found to backup"
fi
local total=0 succeeded=0 failed=0
local failed_accounts=""
while IFS= read -r user; do
[[ -z "$user" ]] && continue
((total++)) || true
log_info "=== Backing up account: $user ($total) ==="
if [[ "$dry_run" == "true" ]]; then
log_info "[DRY RUN] Would backup account: $user"
log_info "[DRY RUN] pkgacct --nocomp --skiphomedir -> temp"
log_info "[DRY RUN] gzip SQL files"
while IFS= read -r rname; do
[[ -z "$rname" ]] && continue
load_remote "$rname"
local prev; prev=$(get_latest_snapshot "$user") || prev=""
log_info "[DRY RUN] [$rname] rsync pkgacct to ${REMOTE_HOST} (link-dest: ${prev:-none})"
log_info "[DRY RUN] [$rname] rsync homedir to ${REMOTE_HOST} (link-dest: ${prev:-none})"
log_info "[DRY RUN] [$rname] finalize snapshot: $ts"
log_info "[DRY RUN] [$rname] enforce retention: keep $RETENTION_COUNT"
done <<< "$remotes"
_restore_remote_globals
((succeeded++)) || true
continue
fi
# Run pkgacct ONCE
if ! run_pkgacct "$user"; then
log_error "Backup failed for $user: pkgacct error"
((failed++)) || true
failed_accounts+=" - $user (pkgacct failed)"$'\n'
cleanup_pkgacct "$user"
continue
fi
# Gzip SQL files ONCE
if ! gzip_sql_files "$user"; then
log_warn "SQL gzip had issues for $user, continuing..."
fi
# Transfer to each remote
local user_failed=false
while IFS= read -r rname; do
[[ -z "$rname" ]] && continue
load_remote "$rname"
log_info "--- Transferring $user to remote '$rname' ---"
if ! _backup_to_current_remote "$user" "$ts"; then
log_error "Backup to remote '$rname' failed for $user"
failed_accounts+=" - $user ($rname: transfer failed)"$'\n'
user_failed=true
fi
done <<< "$remotes"
_restore_remote_globals
if [[ "$user_failed" == "true" ]]; then
((failed++)) || true
else
((succeeded++)) || true
log_info "Backup completed for $user (all remotes)"
fi
# Cleanup local temp
cleanup_pkgacct "$user"
done <<< "$accounts"
local end_time; end_time=$(date +%s)
local duration=$(( end_time - start_time ))
# Print summary
echo ""
echo "============================================"
echo "Backup Summary"
echo "============================================"
echo "Timestamp: $ts"
echo "Duration: $(human_duration $duration)"
echo "Remotes: $(echo "$remotes" | tr '\n' ' ')"
echo "Total: $total"
echo "Succeeded: ${C_GREEN}${succeeded}${C_RESET}"
if (( failed > 0 )); then
echo "Failed: ${C_RED}${failed}${C_RESET}"
echo ""
echo "Failed accounts:"
echo "$failed_accounts"
else
echo "Failed: 0"
fi
echo "============================================"
# Run system backup if --sysbackup was requested
if [[ "$run_sysbackup" == "true" ]]; then
echo ""
log_info "=== Running system backup (--sysbackup) ==="
# Release lock so sysbackup can acquire its own
release_lock
local sysbackup_args=()
[[ -n "$remote_flag" ]] && sysbackup_args+=(--remote="$remote_flag")
[[ "$dry_run" == "true" ]] && sysbackup_args+=(--dry-run)
# Run as subprocess so its exit doesn't kill our process
/usr/local/bin/gniza sysbackup "${sysbackup_args[@]}" || log_error "System backup failed"
acquire_lock
fi
# Send notification
send_backup_report "$total" "$succeeded" "$failed" "$duration" "$failed_accounts"
if (( failed > 0 && succeeded > 0 )); then
exit "$EXIT_PARTIAL"
elif (( failed > 0 )); then
exit "$EXIT_FATAL"
fi
exit "$EXIT_OK"
}
# Helper: load remote context for restore commands.
# --remote=NAME is always required for restore.
_restore_load_remote() {
local remote_flag="$1"
[[ -z "$remote_flag" ]] && die "Specify --remote=NAME for restore."$'\n'"Available remotes: $(list_remotes | tr '\n' ' ')"
_save_remote_globals
load_remote "$remote_flag" || die "Failed to load remote: $remote_flag"
}
# Helper: test connection to current remote (SSH or rclone).
_test_connection() {
if _is_rclone_mode; then
test_rclone_connection || die "Cannot connect to remote (${REMOTE_TYPE})"
else
test_ssh_connection || die "Cannot connect to remote server"
fi
}
cmd_restore() {
require_root
local subcommand="${1:-}"
shift 2>/dev/null || true
case "$subcommand" in
account)
local name="${1:-}"
shift 2>/dev/null || true
[[ -z "$name" ]] && die "Usage: gniza restore account <name> [--remote=NAME] [--timestamp=TS] [--strategy=merge|terminate] [--force]"
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file"
validate_config || die "Invalid configuration"
init_logging
local remote_flag; remote_flag=$(get_opt remote "$@" 2>/dev/null) || remote_flag=""
_restore_load_remote "$remote_flag"
local timestamp; timestamp=$(get_opt timestamp "$@" 2>/dev/null) || timestamp=""
local strategy; strategy=$(get_opt strategy "$@" 2>/dev/null) || strategy=""
local exclude; exclude=$(get_opt exclude "$@" 2>/dev/null) || exclude=""
# Backward compat: --force maps to strategy=merge
if [[ -z "$strategy" ]] && has_flag force "$@"; then
strategy="merge"
fi
if [[ -n "$strategy" ]] && [[ "$strategy" != "merge" ]] && [[ "$strategy" != "terminate" ]]; then
die "Invalid strategy: $strategy (must be 'merge' or 'terminate')"
fi
_test_connection
restore_full_account "$name" "$timestamp" "$strategy" "$exclude"
;;
files)
local name="${1:-}"
shift 2>/dev/null || true
[[ -z "$name" ]] && die "Usage: gniza restore files <name> [--remote=NAME] [--path=subpath] [--timestamp=TS]"
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file"
validate_config || die "Invalid configuration"
init_logging
local remote_flag; remote_flag=$(get_opt remote "$@" 2>/dev/null) || remote_flag=""
_restore_load_remote "$remote_flag"
local subpath; subpath=$(get_opt path "$@" 2>/dev/null) || subpath=""
local timestamp; timestamp=$(get_opt timestamp "$@" 2>/dev/null) || timestamp=""
local exclude; exclude=$(get_opt exclude "$@" 2>/dev/null) || exclude=""
_test_connection
restore_files "$name" "$subpath" "$timestamp" "$exclude"
;;
database)
local name="${1:-}"
local dbname="${2:-}"
[[ -z "$name" ]] && die "Usage: gniza restore database <name> [<dbname>] [--remote=NAME] [--timestamp=TS]"
shift 2>/dev/null || true
# If dbname looks like a flag, it's not a dbname
if [[ -n "$dbname" && "$dbname" != --* ]]; then
shift 2>/dev/null || true
else
dbname=""
fi
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file"
validate_config || die "Invalid configuration"
init_logging
local remote_flag; remote_flag=$(get_opt remote "$@" 2>/dev/null) || remote_flag=""
_restore_load_remote "$remote_flag"
local timestamp; timestamp=$(get_opt timestamp "$@" 2>/dev/null) || timestamp=""
_test_connection
if [[ -n "$dbname" ]]; then
restore_database "$name" "$dbname" "$timestamp"
else
restore_all_databases "$name" "$timestamp"
fi
;;
mailbox)
local name="${1:-}"
local email="${2:-}"
[[ -z "$name" ]] && die "Usage: gniza restore mailbox <name> [<email@domain>] [--remote=NAME] [--timestamp=TS]"
shift 2>/dev/null || true
# If email looks like a flag, it's not an email
if [[ -n "$email" && "$email" != --* ]]; then
shift 2>/dev/null || true
else
email=""
fi
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file"
validate_config || die "Invalid configuration"
init_logging
local remote_flag; remote_flag=$(get_opt remote "$@" 2>/dev/null) || remote_flag=""
_restore_load_remote "$remote_flag"
local timestamp; timestamp=$(get_opt timestamp "$@" 2>/dev/null) || timestamp=""
_test_connection
if [[ -n "$email" ]]; then
restore_mailbox "$name" "$email" "$timestamp"
else
restore_all_mailboxes "$name" "$timestamp"
fi
;;
list-databases)
local name="${1:-}"
shift 2>/dev/null || true
[[ -z "$name" ]] && die "Usage: gniza restore list-databases <name> [--remote=NAME] [--timestamp=TS]"
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file"
validate_config || die "Invalid configuration"
init_logging
local remote_flag; remote_flag=$(get_opt remote "$@" 2>/dev/null) || remote_flag=""
_restore_load_remote "$remote_flag"
local timestamp; timestamp=$(get_opt timestamp "$@" 2>/dev/null) || timestamp=""
_test_connection
list_snapshot_databases "$name" "$timestamp"
;;
list-mailboxes)
local name="${1:-}"
shift 2>/dev/null || true
[[ -z "$name" ]] && die "Usage: gniza restore list-mailboxes <name> [--remote=NAME] [--timestamp=TS]"
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file"
validate_config || die "Invalid configuration"
init_logging
local remote_flag; remote_flag=$(get_opt remote "$@" 2>/dev/null) || remote_flag=""
_restore_load_remote "$remote_flag"
local timestamp; timestamp=$(get_opt timestamp "$@" 2>/dev/null) || timestamp=""
_test_connection
list_snapshot_mailboxes "$name" "$timestamp"
;;
list-files)
local name="${1:-}"
shift 2>/dev/null || true
[[ -z "$name" ]] && die "Usage: gniza restore list-files <name> [--remote=NAME] [--timestamp=TS] [--path=subdir]"
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file"
validate_config || die "Invalid configuration"
init_logging
local remote_flag; remote_flag=$(get_opt remote "$@" 2>/dev/null) || remote_flag=""
_restore_load_remote "$remote_flag"
local timestamp; timestamp=$(get_opt timestamp "$@" 2>/dev/null) || timestamp=""
local subpath; subpath=$(get_opt path "$@" 2>/dev/null) || subpath=""
_test_connection
list_snapshot_files "$name" "$timestamp" "$subpath"
;;
list-dbusers)
local name="${1:-}"
shift 2>/dev/null || true
[[ -z "$name" ]] && die "Usage: gniza restore list-dbusers <name> [--remote=NAME] [--timestamp=TS]"
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file"
validate_config || die "Invalid configuration"
init_logging
local remote_flag; remote_flag=$(get_opt remote "$@" 2>/dev/null) || remote_flag=""
_restore_load_remote "$remote_flag"
local timestamp; timestamp=$(get_opt timestamp "$@" 2>/dev/null) || timestamp=""
_test_connection
list_snapshot_dbusers "$name" "$timestamp"
;;
list-cron)
local name="${1:-}"
shift 2>/dev/null || true
[[ -z "$name" ]] && die "Usage: gniza restore list-cron <name> [--remote=NAME] [--timestamp=TS]"
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file"
validate_config || die "Invalid configuration"
init_logging
local remote_flag; remote_flag=$(get_opt remote "$@" 2>/dev/null) || remote_flag=""
_restore_load_remote "$remote_flag"
local timestamp; timestamp=$(get_opt timestamp "$@" 2>/dev/null) || timestamp=""
_test_connection
list_snapshot_cron "$name" "$timestamp"
;;
list-dns)
local name="${1:-}"
shift 2>/dev/null || true
[[ -z "$name" ]] && die "Usage: gniza restore list-dns <name> [--remote=NAME] [--timestamp=TS]"
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file"
validate_config || die "Invalid configuration"
init_logging
local remote_flag; remote_flag=$(get_opt remote "$@" 2>/dev/null) || remote_flag=""
_restore_load_remote "$remote_flag"
local timestamp; timestamp=$(get_opt timestamp "$@" 2>/dev/null) || timestamp=""
_test_connection
list_snapshot_dns "$name" "$timestamp"
;;
list-ssl)
local name="${1:-}"
shift 2>/dev/null || true
[[ -z "$name" ]] && die "Usage: gniza restore list-ssl <name> [--remote=NAME] [--timestamp=TS]"
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file"
validate_config || die "Invalid configuration"
init_logging
local remote_flag; remote_flag=$(get_opt remote "$@" 2>/dev/null) || remote_flag=""
_restore_load_remote "$remote_flag"
local timestamp; timestamp=$(get_opt timestamp "$@" 2>/dev/null) || timestamp=""
_test_connection
list_snapshot_ssl "$name" "$timestamp"
;;
cron)
local name="${1:-}"
shift 2>/dev/null || true
[[ -z "$name" ]] && die "Usage: gniza restore cron <name> [--remote=NAME] [--timestamp=TS]"
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file"
validate_config || die "Invalid configuration"
init_logging
local remote_flag; remote_flag=$(get_opt remote "$@" 2>/dev/null) || remote_flag=""
_restore_load_remote "$remote_flag"
local timestamp; timestamp=$(get_opt timestamp "$@" 2>/dev/null) || timestamp=""
_test_connection
restore_cron "$name" "$timestamp"
;;
dbusers)
local name="${1:-}"
local specific_dbuser="${2:-}"
[[ -z "$name" ]] && die "Usage: gniza restore dbusers <name> [<dbuser>] [--remote=NAME] [--timestamp=TS]"
shift 2>/dev/null || true
if [[ -n "$specific_dbuser" && "$specific_dbuser" != --* ]]; then
shift 2>/dev/null || true
else
specific_dbuser=""
fi
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file"
validate_config || die "Invalid configuration"
init_logging
local remote_flag; remote_flag=$(get_opt remote "$@" 2>/dev/null) || remote_flag=""
_restore_load_remote "$remote_flag"
local timestamp; timestamp=$(get_opt timestamp "$@" 2>/dev/null) || timestamp=""
_test_connection
restore_dbusers "$name" "$specific_dbuser" "$timestamp"
;;
cpconfig)
local name="${1:-}"
shift 2>/dev/null || true
[[ -z "$name" ]] && die "Usage: gniza restore cpconfig <name> [--remote=NAME] [--timestamp=TS]"
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file"
validate_config || die "Invalid configuration"
init_logging
local remote_flag; remote_flag=$(get_opt remote "$@" 2>/dev/null) || remote_flag=""
_restore_load_remote "$remote_flag"
local timestamp; timestamp=$(get_opt timestamp "$@" 2>/dev/null) || timestamp=""
_test_connection
restore_cpconfig "$name" "$timestamp"
;;
domains)
local name="${1:-}"
local specific_domain="${2:-}"
[[ -z "$name" ]] && die "Usage: gniza restore domains <name> [<domain>] [--remote=NAME] [--timestamp=TS]"
shift 2>/dev/null || true
if [[ -n "$specific_domain" && "$specific_domain" != --* ]]; then
shift 2>/dev/null || true
else
specific_domain=""
fi
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file"
validate_config || die "Invalid configuration"
init_logging
local remote_flag; remote_flag=$(get_opt remote "$@" 2>/dev/null) || remote_flag=""
_restore_load_remote "$remote_flag"
local timestamp; timestamp=$(get_opt timestamp "$@" 2>/dev/null) || timestamp=""
_test_connection
restore_domains "$name" "$specific_domain" "$timestamp"
;;
ssl)
local name="${1:-}"
local specific_cert="${2:-}"
[[ -z "$name" ]] && die "Usage: gniza restore ssl <name> [<cert>] [--remote=NAME] [--timestamp=TS]"
shift 2>/dev/null || true
if [[ -n "$specific_cert" && "$specific_cert" != --* ]]; then
shift 2>/dev/null || true
else
specific_cert=""
fi
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file"
validate_config || die "Invalid configuration"
init_logging
local remote_flag; remote_flag=$(get_opt remote "$@" 2>/dev/null) || remote_flag=""
_restore_load_remote "$remote_flag"
local timestamp; timestamp=$(get_opt timestamp "$@" 2>/dev/null) || timestamp=""
_test_connection
restore_ssl "$name" "$specific_cert" "$timestamp"
;;
server)
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file"
validate_config || die "Invalid configuration"
init_logging
local remote_flag; remote_flag=$(get_opt remote "$@" 2>/dev/null) || remote_flag=""
_restore_load_remote "$remote_flag"
local timestamp; timestamp=$(get_opt timestamp "$@" 2>/dev/null) || timestamp=""
_test_connection
restore_server "$timestamp"
;;
*)
die "Unknown restore subcommand: $subcommand"$'\n'"Usage: gniza restore {account|files|database|mailbox|cron|dbusers|cpconfig|domains|ssl|list-databases|list-mailboxes|list-files|list-dbusers|list-cron|list-dns|list-ssl|server}"
;;
esac
}
# Display listing for the current remote context (globals already set).
_list_current_remote() {
local single_account="$1"
local hostname; hostname=$(hostname -f)
if [[ -n "$single_account" ]]; then
local snapshots; snapshots=$(list_remote_snapshots "$single_account")
if [[ -z "$snapshots" ]]; then
echo " (none)"
else
while IFS= read -r snap; do
local snap_dir; snap_dir=$(get_snapshot_dir "$single_account")
local size; size=$(remote_exec "du -sb '$snap_dir/$snap' 2>/dev/null | cut -f1" 2>/dev/null)
printf " %-25s %s\n" "$snap" "$(human_size "${size:-0}")"
done <<< "$snapshots"
fi
else
local accounts; accounts=$(list_remote_accounts)
if [[ -z "$accounts" ]]; then
echo " (no backups)"
return 0
fi
printf " %-20s %-10s %-25s\n" "ACCOUNT" "SNAPSHOTS" "LATEST"
printf " %-20s %-10s %-25s\n" "-------" "---------" "------"
while IFS= read -r user; do
[[ -z "$user" ]] && continue
local count; count=$(list_remote_snapshots "$user" | wc -l)
local latest; latest=$(get_latest_snapshot "$user")
printf " %-20s %-10s %-25s\n" "$user" "$count" "${latest:-(none)}"
done <<< "$accounts"
fi
}
cmd_list() {
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file"
validate_config || die "Invalid configuration"
init_logging
local single_account=""
single_account=$(get_opt account "$@" 2>/dev/null) || true
local remote_flag=""
remote_flag=$(get_opt remote "$@" 2>/dev/null) || true
local remotes=""
remotes=$(get_target_remotes "$remote_flag") || die "Invalid remote specification"
local hostname; hostname=$(hostname -f)
_save_remote_globals
while IFS= read -r rname; do
[[ -z "$rname" ]] && continue
load_remote "$rname" || { log_error "Failed to load remote: $rname"; continue; }
echo ""
if [[ -n "$single_account" ]]; then
echo "Snapshots for ${C_BOLD}$single_account${C_RESET} on ${C_BOLD}[$rname]${C_RESET} (${REMOTE_HOST}):"
else
echo "Backups on ${C_BOLD}[$rname]${C_RESET} (${REMOTE_HOST}):"
fi
echo ""
local conn_ok=false
if _is_rclone_mode; then
test_rclone_connection 2>/dev/null && conn_ok=true
else
test_ssh_connection 2>/dev/null && conn_ok=true
fi
if [[ "$conn_ok" == "true" ]]; then
_list_current_remote "$single_account"
else
echo " ${C_RED}Connection failed${C_RESET}"
fi
done <<< "$remotes"
_restore_remote_globals
}
cmd_verify() {
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file"
validate_config || die "Invalid configuration"
init_logging
local single_account=""
single_account=$(get_opt account "$@" 2>/dev/null) || true
local remote_flag=""
remote_flag=$(get_opt remote "$@" 2>/dev/null) || true
local remotes=""
remotes=$(get_target_remotes "$remote_flag") || die "Invalid remote specification"
_save_remote_globals
local any_failed=false
while IFS= read -r rname; do
[[ -z "$rname" ]] && continue
load_remote "$rname" || { log_error "Failed to load remote: $rname"; any_failed=true; continue; }
echo ""
echo "${C_BOLD}[$rname]${C_RESET} (${REMOTE_TYPE:-ssh}):"
local conn_ok=false
if _is_rclone_mode; then
test_rclone_connection 2>/dev/null && conn_ok=true
else
test_ssh_connection 2>/dev/null && conn_ok=true
fi
if [[ "$conn_ok" == "true" ]]; then
if [[ -n "$single_account" ]]; then
verify_account_backup "$single_account" || any_failed=true
else
verify_all_accounts || any_failed=true
fi
else
echo " ${C_RED}Connection failed${C_RESET}"
any_failed=true
fi
done <<< "$remotes"
_restore_remote_globals
[[ "$any_failed" == "true" ]] && return 1
}
_status_ssh_and_disk() {
if _is_rclone_mode; then
echo -n " Connection: "
if test_rclone_connection 2>/dev/null; then
echo "${C_GREEN}OK${C_RESET} (${REMOTE_TYPE})"
echo -n " Remote storage: "
local size_json; size_json=$(_rclone_cmd about --json "$(_rclone_remote_path "")" 2>/dev/null) || true
if [[ -n "$size_json" ]]; then
local used; used=$(echo "$size_json" | grep -oP '"used":\s*\K[0-9]+' || echo "")
local total; total=$(echo "$size_json" | grep -oP '"total":\s*\K[0-9]+' || echo "")
if [[ -n "$used" && -n "$total" && "$total" -gt 0 ]]; then
echo "$(human_size "$used") used of $(human_size "$total")"
elif [[ -n "$used" ]]; then
echo "$(human_size "$used") used"
else
echo "available"
fi
else
echo "unknown"
fi
else
echo "${C_RED}FAILED${C_RESET}"
fi
else
echo -n " SSH connection: "
if test_ssh_connection 2>/dev/null; then
echo "${C_GREEN}OK${C_RESET}"
echo -n " Remote disk: "
local disk_info; disk_info=$(remote_exec "df -h '${REMOTE_BASE}' 2>/dev/null | tail -1" 2>/dev/null)
if [[ -n "$disk_info" ]]; then
echo "$disk_info" | awk '{printf "%s used of %s (%s)\n", $3, $2, $5}'
else
echo "unknown"
fi
else
echo "${C_RED}FAILED${C_RESET}"
fi
fi
}
cmd_status() {
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file"
validate_config || die "Invalid configuration"
local hostname; hostname=$(hostname -f)
echo "${C_BOLD}gniza v${GNIZA_VERSION}${C_RESET}"
echo ""
echo "Hostname: $hostname"
echo "Log level: ${LOG_LEVEL}"
echo "Notifications: ${NOTIFY_ON}${NOTIFY_EMAIL:+ ($NOTIFY_EMAIL)}"
echo "Mode: multi-remote"
echo ""
if has_remotes; then
_save_remote_globals
local remotes; remotes=$(list_remotes)
while IFS= read -r rname; do
[[ -z "$rname" ]] && continue
load_remote "$rname" 2>/dev/null || { echo " ${C_RED}Failed to load${C_RESET}"; continue; }
echo "${C_BOLD}[$rname]${C_RESET} (${REMOTE_TYPE:-ssh})"
if [[ "${REMOTE_TYPE:-ssh}" == "ssh" ]]; then
echo " Remote: ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PORT}"
elif [[ "$REMOTE_TYPE" == "s3" ]]; then
echo " Bucket: ${S3_BUCKET}"
echo " Region: ${S3_REGION}"
[[ -n "${S3_ENDPOINT:-}" ]] && echo " Endpoint: ${S3_ENDPOINT}"
elif [[ "$REMOTE_TYPE" == "gdrive" ]]; then
echo " Service acct: ${GDRIVE_SERVICE_ACCOUNT_FILE}"
[[ -n "${GDRIVE_ROOT_FOLDER_ID:-}" ]] && echo " Root folder: ${GDRIVE_ROOT_FOLDER_ID}"
fi
echo " Remote base: ${REMOTE_BASE}"
echo " Retention: ${RETENTION_COUNT} snapshots"
if (( BWLIMIT == 0 )); then echo " Bandwidth: unlimited"; else echo " Bandwidth: ${BWLIMIT} KB/s"; fi
_status_ssh_and_disk
echo ""
done <<< "$remotes"
_restore_remote_globals
else
echo "No remotes configured. Run 'gniza init remote <name>' to add one."
fi
echo ""
# Check lock
echo -n "Lock status: "
if [[ -f "${LOCK_FILE:-$DEFAULT_LOCK_FILE}" ]]; then
local pid; pid=$(cat "${LOCK_FILE:-$DEFAULT_LOCK_FILE}" 2>/dev/null)
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
echo "${C_YELLOW}LOCKED (PID $pid)${C_RESET}"
else
echo "stale lock file (no running process)"
fi
else
echo "unlocked"
fi
# Last log
local log_dir="${LOG_DIR:-$DEFAULT_LOG_DIR}"
echo -n "Last log: "
local last_log; last_log=$(ls -1t "$log_dir"/gniza-*.log 2>/dev/null | head -1)
if [[ -n "$last_log" ]]; then
echo "$(basename "$last_log")"
else
echo "(none)"
fi
}
cmd_remote() {
local subcommand="${1:-}"
shift 2>/dev/null || true
case "$subcommand" in
list|ls|"")
if ! has_remotes; then
echo "No remotes configured."
echo "Run 'gniza init remote <name>' to add one."
return 0
fi
local remotes; remotes=$(list_remotes)
local count; count=$(echo "$remotes" | wc -l)
echo "${C_BOLD}Configured remotes ($count):${C_RESET}"
echo ""
printf " %-15s %-8s %-30s %s\n" "NAME" "TYPE" "DESTINATION" "BASE"
printf " %-15s %-8s %-30s %s\n" "----" "----" "-----------" "----"
_save_remote_globals
while IFS= read -r rname; do
[[ -z "$rname" ]] && continue
load_remote "$rname" 2>/dev/null || { printf " %-15s %s\n" "$rname" "${C_RED}(failed to load)${C_RESET}"; continue; }
local dest_str
case "${REMOTE_TYPE:-ssh}" in
ssh) dest_str="${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PORT}" ;;
s3) dest_str="s3://${S3_BUCKET}" ;;
gdrive) dest_str="gdrive:${GDRIVE_SERVICE_ACCOUNT_FILE##*/}" ;;
esac
printf " %-15s %-8s %-30s %s\n" \
"$rname" "${REMOTE_TYPE:-ssh}" "$dest_str" "$REMOTE_BASE"
done <<< "$remotes"
_restore_remote_globals
echo ""
;;
delete|rm|remove)
require_root
local name="${1:-}"
[[ -z "$name" ]] && die "Usage: gniza remote delete <name>"
local conf="$REMOTES_DIR/${name}.conf"
if [[ ! -f "$conf" ]]; then
die "Remote not found: $name (expected $conf)"
fi
# Show what will be deleted
local config_file; config_file=$(get_opt config "${@:2}" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file"
load_remote "$name" 2>/dev/null
echo "Remote: $name"
echo "Host: ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PORT}"
echo "Base: ${REMOTE_BASE}"
echo ""
read -rp "Delete remote '$name'? [y/N] " answer
[[ "$answer" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
rm -f "$conf"
echo "Remote '$name' deleted."
;;
*)
die "Unknown remote subcommand: $subcommand"$'\n'"Usage: gniza remote {list|delete <name>}"
;;
esac
}
cmd_schedule() {
require_root
local subcommand="${1:-show}"
shift 2>/dev/null || true
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file"
init_logging
case "$subcommand" in
add)
local name="${1:-}"
[[ -z "$name" ]] && die "Usage: gniza schedule add <name>"
_schedule_add "$name"
;;
delete|rm|remove-schedule)
local name="${1:-}"
[[ -z "$name" ]] && die "Usage: gniza schedule delete <name>"
_schedule_delete "$name"
;;
run)
local name="${1:-}"
[[ -z "$name" ]] && die "Usage: gniza schedule run <name>"
_schedule_run "$name"
;;
list|ls)
_schedule_list
;;
install) install_schedules ;;
show) show_schedules ;;
remove) remove_schedules ;;
*) die "Unknown schedule subcommand: $subcommand"$'\n'"Usage: gniza schedule {add|delete|run|list|install|show|remove}" ;;
esac
}
_schedule_add() {
local name="$1"
if ! [[ "$name" =~ ^[a-zA-Z0-9_-]+$ ]]; then
die "Schedule name must be alphanumeric (hyphens and underscores allowed): $name"
fi
local config_file="$SCHEDULES_DIR/${name}.conf"
if [[ -f "$config_file" ]]; then
echo "Schedule config already exists: $config_file"
read -rp "Overwrite? [y/N] " answer
[[ "$answer" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
fi
echo "${C_BOLD}gniza schedule add${C_RESET} — New schedule: ${C_BOLD}$name${C_RESET}"
echo ""
echo "Schedule options: hourly, daily, weekly, monthly, custom"
read -rp "Schedule type [daily]: " sched_type
sched_type="${sched_type:-daily}"
local sched_time="02:00"
local sched_day=""
local sched_cron=""
read -rp "Schedule time HH:MM [02:00]: " sched_time
sched_time="${sched_time:-02:00}"
case "$sched_type" in
weekly)
read -rp "Day of week (0=Sun..6=Sat): " sched_day
[[ -z "$sched_day" ]] && die "Day of week is required for weekly schedule"
;;
monthly)
read -rp "Day of month (1-28): " sched_day
[[ -z "$sched_day" ]] && die "Day of month is required for monthly schedule"
;;
custom)
read -rp "Full cron expression (5 fields): " sched_cron
[[ -z "$sched_cron" ]] && die "Cron expression is required for custom schedule"
;;
hourly)
read -rp "Hours between backups (1-23) [1]: " sched_day
sched_day="${sched_day:-1}"
;;
daily) ;;
*) die "Invalid schedule type: $sched_type" ;;
esac
# Select remotes
local sched_remotes=""
if has_remotes; then
echo ""
echo "Available remotes:"
local remotes; remotes=$(list_remotes)
local i=1
while IFS= read -r rname; do
[[ -z "$rname" ]] && continue
echo " $i) $rname"
((i++)) || true
done <<< "$remotes"
echo ""
read -rp "Target remotes (comma-separated names, empty=all): " sched_remotes
fi
# Write config
mkdir -p "$SCHEDULES_DIR"
cat > "$config_file" <<CONF
# gniza schedule config: $name
# Generated by 'gniza schedule add $name'
# $(date -u +"%Y-%m-%d %H:%M:%S UTC")
SCHEDULE="$sched_type"
SCHEDULE_TIME="$sched_time"
SCHEDULE_DAY="$sched_day"
SCHEDULE_CRON="$sched_cron"
REMOTES="$sched_remotes"
CONF
echo ""
echo "${C_GREEN}Schedule '$name' created: $config_file${C_RESET}"
echo "Run 'gniza schedule install' to activate cron entries."
}
_schedule_delete() {
local name="$1"
local config_file="$SCHEDULES_DIR/${name}.conf"
if [[ ! -f "$config_file" ]]; then
die "Schedule not found: $name (expected $config_file)"
fi
load_schedule "$name"
echo "Schedule: $name"
echo "Type: ${SCHEDULE:-none}"
echo "Time: ${SCHEDULE_TIME:-02:00}"
echo "Remotes: ${SCHEDULE_REMOTES:-(all)}"
echo ""
read -rp "Delete schedule '$name'? [y/N] " answer
[[ "$answer" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
rm -f "$config_file"
echo "Schedule '$name' deleted."
echo "Run 'gniza schedule install' to update cron entries."
}
_schedule_run() {
local name="$1"
local config_file="$SCHEDULES_DIR/${name}.conf"
if [[ ! -f "$config_file" ]]; then
die "Schedule not found: $name (expected $config_file)"
fi
load_schedule "$name"
local args=()
if [[ -n "${SCHEDULE_REMOTES:-}" ]]; then
args+=(--remote="$SCHEDULE_REMOTES")
fi
if [[ "${SCHEDULE_SYSBACKUP:-}" == "yes" ]]; then
args+=(--sysbackup)
fi
if [[ "${SCHEDULE_SKIP_SUSPENDED:-}" == "yes" ]]; then
args+=(--skip-suspended)
fi
echo "Running schedule '$name'..."
echo " Remotes: ${SCHEDULE_REMOTES:-(all)}"
echo " Sysbackup: ${SCHEDULE_SYSBACKUP:-no}"
echo " Skip suspended: ${SCHEDULE_SKIP_SUSPENDED:-no}"
echo ""
# Exec replaces this process with the backup command
exec /usr/local/bin/gniza backup "${args[@]}"
}
_schedule_list() {
if ! has_schedules; then
echo "No schedules configured."
echo "Run 'gniza schedule add <name>' to create one."
return 0
fi
local schedules; schedules=$(list_schedules)
local count; count=$(echo "$schedules" | wc -l)
echo "${C_BOLD}Configured schedules ($count):${C_RESET}"
echo ""
printf " %-15s %-10s %-8s %-6s %s\n" "NAME" "TYPE" "TIME" "DAY" "REMOTES"
printf " %-15s %-10s %-8s %-6s %s\n" "----" "----" "----" "---" "-------"
while IFS= read -r sname; do
[[ -z "$sname" ]] && continue
load_schedule "$sname" 2>/dev/null || { printf " %-15s %s\n" "$sname" "${C_RED}(failed to load)${C_RESET}"; continue; }
printf " %-15s %-10s %-8s %-6s %s\n" \
"$sname" "${SCHEDULE:-none}" "${SCHEDULE_TIME:-02:00}" "${SCHEDULE_DAY:--}" "${SCHEDULE_REMOTES:-(all)}"
done <<< "$schedules"
echo ""
}
cmd_init() {
local subcommand="${1:-}"
if [[ "$subcommand" == "remote" ]]; then
shift
_init_remote "$@"
return
fi
local config_dir="/etc/gniza"
local config_file="$config_dir/gniza.conf"
echo "${C_BOLD}gniza init${C_RESET} — Setup wizard"
echo ""
# Step 1: Create main config with local settings
if [[ -f "$config_file" ]]; then
echo "Config file already exists: $config_file"
read -rp "Overwrite? [y/N] " answer
[[ "$answer" =~ ^[Yy]$ ]] || {
echo "Skipping main config."
echo ""
# Still offer to add a remote
echo "Add a remote destination?"
read -rp "Remote name (e.g. nas, offsite): " init_remote_name
if [[ -n "$init_remote_name" ]]; then
_init_remote "$init_remote_name"
fi
return
}
fi
read -rp "Notification email (empty to disable): " init_email
# Create config
mkdir -p "$config_dir"
mkdir -p "$config_dir/remotes.d"
cat > "$config_file" <<CONF
# gniza configuration — generated by 'gniza init'
# $(date -u +"%Y-%m-%d %H:%M:%S UTC")
#
# Remote destinations are configured in /etc/gniza/remotes.d/<name>.conf
# Run 'gniza init remote <name>' to add one.
TEMP_DIR="/usr/local/gniza/workdir"
INCLUDE_ACCOUNTS=""
EXCLUDE_ACCOUNTS="nobody"
LOG_DIR="/var/log/gniza"
LOG_LEVEL="info"
LOG_RETAIN=90
NOTIFY_EMAIL="$init_email"
NOTIFY_ON="failure"
LOCK_FILE="/var/run/gniza.lock"
SSH_TIMEOUT=30
SSH_RETRIES=3
RSYNC_EXTRA_OPTS=""
CONF
echo ""
echo "Config written to $config_file"
# Create log directory
mkdir -p "${LOG_DIR:-$DEFAULT_LOG_DIR}"
# Step 2: Create first remote
echo ""
echo "Now let's set up your first remote destination."
read -rp "Remote name (e.g. nas, offsite): " init_remote_name
[[ -z "$init_remote_name" ]] && die "Remote name is required"
_init_remote "$init_remote_name"
}
_init_remote() {
local name="${1:-}"
[[ -z "$name" ]] && die "Usage: gniza init remote <name>"
# Validate name (alphanumeric, hyphens, underscores)
if ! [[ "$name" =~ ^[a-zA-Z0-9_-]+$ ]]; then
die "Remote name must be alphanumeric (hyphens and underscores allowed): $name"
fi
local config_dir="/etc/gniza/remotes.d"
local config_file="$config_dir/${name}.conf"
echo "${C_BOLD}gniza init remote${C_RESET} — Remote setup: ${C_BOLD}$name${C_RESET}"
echo ""
if [[ -f "$config_file" ]]; then
echo "Remote config already exists: $config_file"
read -rp "Overwrite? [y/N] " answer
[[ "$answer" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
fi
read -rp "Remote host: " init_host
[[ -z "$init_host" ]] && die "Remote host is required"
read -rp "Remote port [22]: " init_port
init_port="${init_port:-22}"
read -rp "Remote user [root]: " init_user
init_user="${init_user:-root}"
read -rp "SSH key path [/root/.ssh/id_rsa]: " init_key
init_key="${init_key:-/root/.ssh/id_rsa}"
read -rp "Remote base directory [/backups]: " init_base
init_base="${init_base:-/backups}"
read -rp "Retention count [30]: " init_retention
init_retention="${init_retention:-30}"
read -rp "Bandwidth limit in KB/s [0 = unlimited]: " init_bwlimit
init_bwlimit="${init_bwlimit:-0}"
# Create config
mkdir -p "$config_dir"
cat > "$config_file" <<CONF
# gniza remote config: $name
# Generated by 'gniza init remote $name'
# $(date -u +"%Y-%m-%d %H:%M:%S UTC")
REMOTE_HOST="$init_host"
REMOTE_PORT=$init_port
REMOTE_USER="$init_user"
REMOTE_KEY="$init_key"
REMOTE_BASE="$init_base"
BWLIMIT=$init_bwlimit
RETENTION_COUNT=$init_retention
RSYNC_EXTRA_OPTS=""
CONF
echo ""
echo "Remote config written to $config_file"
echo ""
# Test SSH
# Load main config first for defaults, then load remote
local main_config="/etc/gniza/gniza.conf"
if [[ -f "$main_config" ]]; then
load_config "$main_config"
fi
load_remote "$name"
echo "Testing SSH connection to $name..."
if test_ssh_connection 2>/dev/null; then
echo "${C_GREEN}SSH connection successful!${C_RESET}"
echo "Creating remote base directory..."
ensure_remote_dir "${REMOTE_BASE}/$(hostname -f)/accounts"
echo "${C_GREEN}Remote directory created.${C_RESET}"
else
echo "${C_YELLOW}SSH connection failed. Check your settings in $config_file${C_RESET}"
fi
echo ""
echo "${C_GREEN}Remote '$name' configured!${C_RESET}"
echo "Run 'gniza schedule add <name>' to set up a backup schedule."
echo "Run 'gniza backup --remote=$name --dry-run' to test."
}
# ── System Backup / Restore ───────────────────────────────────
# Transfer + finalize + retention for system backup on the current remote.
# Globals REMOTE_* must already be set via load_remote().
_sysbackup_to_current_remote() {
local stage_dir="$1"
local ts="$2"
local remote_label="$CURRENT_REMOTE_NAME"
clean_partial_system_snapshots
local prev; prev=$(get_latest_system_snapshot) || prev=""
if ! transfer_system_backup "$stage_dir" "$ts" "$prev"; then
log_error "[$remote_label] System backup transfer failed"
return 1
fi
if ! finalize_system_snapshot "$ts"; then
log_error "[$remote_label] System snapshot finalize failed"
return 1
fi
enforce_system_retention
return 0
}
cmd_sysbackup() {
require_root
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file"
validate_config || die "Invalid configuration"
init_logging
local dry_run=false
has_flag dry-run "$@" && dry_run=true
local remote_flag=""
remote_flag=$(get_opt remote "$@" 2>/dev/null) || true
acquire_lock
trap 'cleanup_system_stage; release_lock' EXIT
local remotes=""
remotes=$(get_target_remotes "$remote_flag") || die "Invalid remote specification"
_save_remote_globals
# Test connectivity upfront
while IFS= read -r rname; do
[[ -z "$rname" ]] && continue
load_remote "$rname" || die "Failed to load remote: $rname"
if _is_rclone_mode; then
test_rclone_connection || die "Cannot connect to remote '$rname' (${REMOTE_TYPE})"
else
test_ssh_connection || die "Cannot connect to remote '$rname' (${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PORT})"
fi
done <<< "$remotes"
_restore_remote_globals
local start_time; start_time=$(date +%s)
local ts; ts=$(timestamp)
local temp_dir="${TEMP_DIR:-$DEFAULT_TEMP_DIR}"
local stage_dir="$temp_dir/system/$ts"
if [[ "$dry_run" == "true" ]]; then
log_info "[DRY RUN] System backup preview"
log_info "[DRY RUN] Would export: packages, tweaksettings, DNS zones, IPs, PHP config"
log_info "[DRY RUN] Would stage: ${#_SYSBACKUP_PATHS[@]} system paths + ea-php configs"
while IFS= read -r rname; do
[[ -z "$rname" ]] && continue
load_remote "$rname"
log_info "[DRY RUN] [$rname] Would transfer system backup to ${REMOTE_HOST:-$REMOTE_TYPE}"
log_info "[DRY RUN] [$rname] Would finalize system snapshot: $ts"
log_info "[DRY RUN] [$rname] Would enforce retention: keep $RETENTION_COUNT"
done <<< "$remotes"
_restore_remote_globals
echo ""
echo "System backup dry run complete. No changes made."
exit "$EXIT_OK"
fi
# Stage ONCE
log_info "=== System Backup ==="
if ! run_system_backup "$stage_dir"; then
log_error "System backup staging failed"
cleanup_system_stage
exit "$EXIT_FATAL"
fi
# Transfer to each remote
local failed=0
local succeeded=0
while IFS= read -r rname; do
[[ -z "$rname" ]] && continue
load_remote "$rname"
log_info "--- Transferring system backup to remote '$rname' ---"
if _sysbackup_to_current_remote "$stage_dir" "$ts"; then
((succeeded++)) || true
log_info "System backup to '$rname' completed"
else
((failed++)) || true
log_error "System backup to '$rname' failed"
fi
done <<< "$remotes"
_restore_remote_globals
cleanup_system_stage
local end_time; end_time=$(date +%s)
local duration=$(( end_time - start_time ))
echo ""
echo "============================================"
echo "System Backup Summary"
echo "============================================"
echo "Timestamp: $ts"
echo "Duration: $(human_duration $duration)"
echo "Remotes: $(echo "$remotes" | tr '\n' ' ')"
echo "Succeeded: ${C_GREEN}${succeeded}${C_RESET}"
if (( failed > 0 )); then
echo "Failed: ${C_RED}${failed}${C_RESET}"
else
echo "Failed: 0"
fi
echo "============================================"
if (( failed > 0 && succeeded > 0 )); then
exit "$EXIT_PARTIAL"
elif (( failed > 0 )); then
exit "$EXIT_FATAL"
fi
exit "$EXIT_OK"
}
cmd_sysrestore() {
require_root
local config_file; config_file=$(get_opt config "$@" 2>/dev/null) || config_file="$DEFAULT_CONFIG_FILE"
load_config "$config_file"
validate_config || die "Invalid configuration"
init_logging
local remote_flag=""
remote_flag=$(get_opt remote "$@" 2>/dev/null) || true
[[ -z "$remote_flag" ]] && die "Specify --remote=NAME for sysrestore."$'\n'"Available remotes: $(list_remotes | tr '\n' ' ')"
_save_remote_globals
load_remote "$remote_flag" || die "Failed to load remote: $remote_flag"
local timestamp=""
timestamp=$(get_opt timestamp "$@" 2>/dev/null) || true
local dry_run=false
has_flag dry-run "$@" && dry_run=true
local phases="1,2,3,4"
phases=$(get_opt phase "$@" 2>/dev/null) || phases="1,2,3,4"
# Test connectivity
if _is_rclone_mode; then
test_rclone_connection || die "Cannot connect to remote '$remote_flag' (${REMOTE_TYPE})"
else
test_ssh_connection || die "Cannot connect to remote '$remote_flag' (${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PORT})"
fi
# Resolve timestamp
local ts; ts=$(resolve_system_snapshot_timestamp "$timestamp") || die "No system snapshot found on remote '$remote_flag'"
[[ -z "$ts" ]] && die "No system snapshot found on remote '$remote_flag'"
log_info "System restore from remote '$remote_flag', snapshot: $ts"
log_info "Phases: $phases, Dry-run: $dry_run"
local temp_dir="${TEMP_DIR:-$DEFAULT_TEMP_DIR}"
local stage_dir="$temp_dir/system-restore/$ts"
# Download snapshot (needed for both dry-run and real restore)
if ! download_system_snapshot "$ts" "$stage_dir"; then
die "Failed to download system snapshot: $ts"
fi
# Run restore
local rc=0
run_system_restore "$stage_dir" "$phases" "$dry_run" || rc=$?
# Cleanup staging
if [[ -d "$stage_dir" ]]; then
rm -rf "$stage_dir"
log_debug "Cleaned up restore staging: $stage_dir"
fi
_restore_remote_globals
if (( rc != 0 )); then
echo ""
echo "${C_YELLOW}System restore completed with errors. Check log for details.${C_RESET}"
exit "$EXIT_PARTIAL"
fi
echo ""
echo "${C_GREEN}System restore completed successfully.${C_RESET}"
exit "$EXIT_OK"
}
cmd_usage() {
cat <<EOF
${C_BOLD}gniza v${GNIZA_VERSION}${C_RESET} — cPanel Backup, Restore & Disaster Recovery
${C_BOLD}Usage:${C_RESET}
gniza <command> [options]
${C_BOLD}Commands:${C_RESET}
backup [--account=NAME] [--remote=NAME[,NAME2]] [--dry-run] [--sysbackup] [--skip-suspended]
restore account <name> [--remote=NAME] [--timestamp=TS] [--force]
restore files <name> [--remote=NAME] [--path=subpath] [--timestamp=TS]
restore database <name> [<dbname>] [--remote=NAME] [--timestamp=TS]
restore mailbox <name> [<email@domain>] [--remote=NAME] [--timestamp=TS]
restore cron <name> [--remote=NAME] [--timestamp=TS]
restore dbusers <name> [--remote=NAME] [--timestamp=TS]
restore cpconfig <name> [--remote=NAME] [--timestamp=TS]
restore domains <name> [--remote=NAME] [--timestamp=TS]
restore ssl <name> [--remote=NAME] [--timestamp=TS]
restore list-databases <name> [--remote=NAME] [--timestamp=TS]
restore list-mailboxes <name> [--remote=NAME] [--timestamp=TS]
restore list-files <name> [--remote=NAME] [--timestamp=TS] [--path=subdir]
restore server [--remote=NAME] [--timestamp=TS]
sysbackup [--remote=NAME[,NAME2]] [--dry-run] Backup system/WHM config
sysrestore --remote=NAME [--timestamp=TS] [--phase=N] [--dry-run]
list [--account=NAME] [--remote=NAME] List remote snapshots
verify [--account=NAME] [--remote=NAME] Verify backup integrity
status Show configuration and status
remote list List configured remotes
remote delete <name> Remove a remote destination
schedule add <name> Create a backup schedule
schedule delete <name> Remove a schedule
schedule run <name> Run a schedule now
schedule list Show configured schedules
schedule {install|show|remove} Manage cron entries
init Setup config + first remote
init remote <name> Add a remote destination
version Show version
${C_BOLD}Global Options:${C_RESET}
--config=PATH Use alternate config file (default: /etc/gniza/gniza.conf)
--remote=NAME Target specific remote(s), comma-separated
--debug Enable debug logging
${C_BOLD}Examples:${C_RESET}
gniza init
gniza backup --dry-run
gniza backup --account=johndoe
gniza backup --remote=nas
gniza backup --remote=nas,offsite
gniza list --remote=offsite
gniza restore files johndoe --remote=nas --path=public_html
gniza restore database johndoe johndoe_wp --remote=nas
gniza restore mailbox johndoe info@example.com --remote=nas
gniza schedule add nightly
gniza schedule list
gniza schedule install
gniza remote list
gniza init remote nas
gniza sysbackup --dry-run
gniza sysbackup --remote=nas
gniza sysrestore --remote=nas
gniza sysrestore --remote=nas --phase=1 --dry-run
EOF
}
# ── Main routing ───────────────────────────────────────────────
main() {
# Global --debug flag
has_flag debug "$@" && GNIZA_DEBUG=true || GNIZA_DEBUG=false
local command="${1:-}"
shift 2>/dev/null || true
case "$command" in
backup) cmd_backup "$@" ;;
sysbackup) cmd_sysbackup "$@" ;;
sysrestore) cmd_sysrestore "$@" ;;
restore) cmd_restore "$@" ;;
list) cmd_list "$@" ;;
verify) cmd_verify "$@" ;;
status) cmd_status "$@" ;;
remote) cmd_remote "$@" ;;
schedule) cmd_schedule "$@" ;;
init) cmd_init "$@" ;;
version) echo "gniza v${GNIZA_VERSION}" ;;
help|-h|--help) cmd_usage ;;
"") cmd_usage ;;
*) die "Unknown command: $command"$'\n'"Run 'gniza help' for usage" ;;
esac
}
main "$@"