#!/usr/bin/env bash # gniza — cPanel Backup, Restore & Disaster Recovery # CLI entrypoint and command routing set -euo pipefail umask 077 # 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 require_cmd rsync require_cmd ssh require_cmd hostname require_cmd gzip 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 require_cmd rsync require_cmd ssh require_cmd hostname 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 [--remote=NAME] [--timestamp=TS] [--terminate]" validate_account_name "$name" || die "Invalid account name" 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="" [[ -n "$timestamp" ]] && { validate_timestamp "$timestamp" || die "Invalid timestamp"; } local exclude; exclude=$(get_opt exclude "$@" 2>/dev/null) || exclude="" local terminate_val; terminate_val=$(get_opt terminate "$@" 2>/dev/null) || terminate_val="" local terminate=false [[ "$terminate_val" == "1" ]] && terminate=true _test_connection restore_full_account "$name" "$timestamp" "$terminate" "$exclude" ;; files) local name="${1:-}" shift 2>/dev/null || true [[ -z "$name" ]] && die "Usage: gniza restore files [--remote=NAME] [--path=subpath] [--timestamp=TS]" validate_account_name "$name" || die "Invalid account name" 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="" [[ -n "$timestamp" ]] && { validate_timestamp "$timestamp" || die "Invalid 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 [] [--remote=NAME] [--timestamp=TS]" validate_account_name "$name" || die "Invalid account name" 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="" [[ -n "$timestamp" ]] && { validate_timestamp "$timestamp" || die "Invalid 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 [] [--remote=NAME] [--timestamp=TS]" validate_account_name "$name" || die "Invalid account name" 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="" [[ -n "$timestamp" ]] && { validate_timestamp "$timestamp" || die "Invalid 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 [--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 [--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 [--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 [--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 [--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 [--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 [--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 [--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 [] [--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 [--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 [] [--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 [] [--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 # list accounts subcommand if [[ "${1:-}" == "accounts" ]]; then shift local remote_flag="" remote_flag=$(get_opt remote "$@" 2>/dev/null) || true [[ -z "$remote_flag" ]] && die "Usage: gniza list accounts --remote=NAME" local remotes; remotes=$(get_target_remotes "$remote_flag") || die "Invalid remote" local rname; rname=$(head -1 <<< "$remotes") _save_remote_globals load_remote "$rname" || die "Failed to load remote: $rname" _test_connection || die "Connection failed to remote: $rname" list_remote_accounts _restore_remote_globals return 0 fi 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." 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." 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 " 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 }" ;; 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 " _schedule_add "$name" ;; delete|rm|remove-schedule) local name="${1:-}" [[ -z "$name" ]] && die "Usage: gniza schedule delete " _schedule_delete "$name" ;; run) local name="${1:-}" [[ -z "$name" ]] && die "Usage: gniza schedule run " _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" <' 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 "" } # ── 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 log_info "[TYPE:SYSBACKUP] System backup started" 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_stats() { 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 log_dir="${LOG_DIR:-$DEFAULT_LOG_DIR}" local stats_file="$log_dir/stats.json" local remotes; remotes=$(list_remotes) || true if [[ -z "$remotes" ]]; then log_error "No remotes configured" return 1 fi local total_accounts=0 local total_snapshots=0 local remote_json_parts=() local -A seen_accounts _save_remote_globals while IFS= read -r rname; do [[ -z "$rname" ]] && continue load_remote "$rname" || { log_warn "Failed to load remote: $rname"; continue; } # Test connectivity 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 local r_accounts=0 local r_snapshots=0 if $conn_ok; then local accounts_list; accounts_list=$(list_remote_accounts 2>/dev/null) || true if [[ -n "$accounts_list" ]]; then while IFS= read -r acc; do [[ -z "$acc" ]] && continue ((r_accounts++)) || true seen_accounts["$acc"]=1 local snaps; snaps=$(list_remote_snapshots "$acc" 2>/dev/null) || true if [[ -n "$snaps" ]]; then local snap_count; snap_count=$(echo "$snaps" | wc -l) ((r_snapshots += snap_count)) || true fi done <<< "$accounts_list" fi else log_warn "Cannot connect to remote: $rname (skipping)" fi ((total_snapshots += r_snapshots)) || true remote_json_parts+=("\"$rname\":{\"accounts\":$r_accounts,\"snapshots\":$r_snapshots}") done <<< "$remotes" _restore_remote_globals total_accounts=${#seen_accounts[@]} # Parse last backup status from most recent log file local last_status="UNKNOWN" local last_log="" local latest_log="" if [[ -d "$log_dir" ]]; then latest_log=$(ls -1t "$log_dir"/gniza-[0-9]*-[0-9]*.log 2>/dev/null | head -1) || true fi if [[ -n "$latest_log" && -f "$latest_log" ]]; then last_log=$(basename "$latest_log") if tail -20 "$latest_log" | grep -qi "Backup completed successfully"; then last_status="SUCCESS" elif tail -20 "$latest_log" | grep -qi "Backup failed\|partial failure\|PARTIAL"; then last_status="FAILURE" fi fi # Build JSON local updated; updated=$(date -u +"%d/%m/%Y %H:%M:%S") local remotes_json; remotes_json=$(IFS=','; echo "${remote_json_parts[*]}") local json="{\"updated\":\"$updated\",\"backed_up_accounts\":$total_accounts,\"snapshots\":$total_snapshots,\"remotes\":{$remotes_json},\"last_backup\":{\"status\":\"$last_status\",\"log\":\"$last_log\"}}" echo "$json" > "$stats_file" log_info "Stats written to $stats_file" echo "$json" } cmd_usage() { cat < [options] ${C_BOLD}Commands:${C_RESET} backup [--account=NAME] [--remote=NAME[,NAME2]] [--dry-run] [--sysbackup] [--skip-suspended] restore account [--remote=NAME] [--timestamp=TS] [--force] restore files [--remote=NAME] [--path=subpath] [--timestamp=TS] restore database [] [--remote=NAME] [--timestamp=TS] restore mailbox [] [--remote=NAME] [--timestamp=TS] restore cron [--remote=NAME] [--timestamp=TS] restore dbusers [--remote=NAME] [--timestamp=TS] restore cpconfig [--remote=NAME] [--timestamp=TS] restore domains [--remote=NAME] [--timestamp=TS] restore ssl [--remote=NAME] [--timestamp=TS] restore list-databases [--remote=NAME] [--timestamp=TS] restore list-mailboxes [--remote=NAME] [--timestamp=TS] restore list-files [--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 Remove a remote destination schedule add Create a backup schedule schedule delete Remove a schedule schedule run Run a schedule now schedule list Show configured schedules schedule {install|show|remove} Manage cron entries stats Collect backup statistics 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 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 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 (used by config.sh load_config) # shellcheck disable=SC2034 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 "$@" ;; stats) cmd_stats "$@" ;; 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 "$@"