#!/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" # ── 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 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) 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 "============================================" # 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 [--remote=NAME] [--timestamp=TS] [--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 force=false has_flag force "$@" && force=true _test_connection restore_full_account "$name" "$timestamp" "$force" ;; files) local name="${1:-}" shift 2>/dev/null || true [[ -z "$name" ]] && die "Usage: gniza restore files [--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="" _test_connection restore_files "$name" "$subpath" "$timestamp" ;; database) local name="${1:-}" local dbname="${2:-}" [[ -z "$name" ]] && die "Usage: gniza restore database [] [--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 [] [--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 [--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 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 ' 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 ' 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 " 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" ;; list|ls) _schedule_list ;; install) install_schedules ;; show) show_schedules ;; remove) remove_schedules ;; *) die "Unknown schedule subcommand: $subcommand"$'\n'"Usage: gniza schedule {add|delete|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 "" } 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 # Run 'gniza init remote ' 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 " # 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" </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 ' to set up a backup schedule." echo "Run 'gniza backup --remote=$name --dry-run' to test." } cmd_usage() { cat < [options] ${C_BOLD}Commands:${C_RESET} backup [--account=NAME] [--remote=NAME[,NAME2]] [--dry-run] 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] 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 list Show configured schedules schedule {install|show|remove} Manage cron entries init Setup config + first remote init remote 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 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 "$@" ;; 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 "$@"