#!/usr/bin/env bash # gniza4cp/lib/sysbackup.sh — System-level WHM backup: API exports, file staging, snapshot lifecycle [[ -n "${_GNIZA4CP_SYSBACKUP_LOADED:-}" ]] && return 0 _GNIZA4CP_SYSBACKUP_LOADED=1 # ── Path Helpers ───────────────────────────────────────────── _system_snap_base() { local hostname; hostname=$(hostname -f) echo "${REMOTE_BASE}/${hostname}/system/snapshots" } # Rclone subpath (no REMOTE_BASE prefix — _rclone_remote_path adds it) _system_snap_subpath() { echo "system/snapshots" } # ── Snapshot Lifecycle ─────────────────────────────────────── list_system_snapshots() { if _is_rclone_mode; then local snap_sub; snap_sub=$(_system_snap_subpath) local all_dirs; all_dirs=$(rclone_list_dirs "$snap_sub") || true [[ -z "$all_dirs" ]] && return 0 local completed="" while IFS= read -r dir; do [[ -z "$dir" ]] && continue if rclone_exists "${snap_sub}/${dir}/.complete"; then completed+="${dir}"$'\n' fi done <<< "$all_dirs" [[ -n "$completed" ]] && echo "$completed" | sort -r return 0 fi local snap_dir; snap_dir=$(_system_snap_base) local raw; raw=$(remote_exec "ls -1d '$snap_dir'/[0-9]* 2>/dev/null | grep -v '\\.partial$' | sort -r" 2>/dev/null) || true if [[ -n "$raw" ]]; then echo "$raw" | xargs -I{} basename {} | sort -r fi } get_latest_system_snapshot() { if _is_rclone_mode; then local snap_sub; snap_sub=$(_system_snap_subpath) local latest; latest=$(rclone_cat "${snap_sub}/latest.txt" 2>/dev/null) || true if [[ -n "$latest" ]]; then if rclone_exists "${snap_sub}/${latest}/.complete"; then echo "$latest" return 0 fi fi list_system_snapshots | head -1 return 0 fi list_system_snapshots | head -1 } resolve_system_snapshot_timestamp() { local requested="$1" if [[ -z "$requested" || "$requested" == "LATEST" || "$requested" == "latest" ]]; then get_latest_system_snapshot elif _is_rclone_mode; then local snap_sub; snap_sub=$(_system_snap_subpath) if rclone_exists "${snap_sub}/${requested}/.complete"; then echo "$requested" else log_error "System snapshot not found or incomplete: $requested" return 1 fi else local snap_dir; snap_dir=$(_system_snap_base) if remote_exec "test -d '$snap_dir/$requested'" 2>/dev/null; then echo "$requested" else log_error "System snapshot not found: $requested" return 1 fi fi } clean_partial_system_snapshots() { if _is_rclone_mode; then local snap_sub; snap_sub=$(_system_snap_subpath) local all_dirs; all_dirs=$(rclone_list_dirs "$snap_sub") || true [[ -z "$all_dirs" ]] && return 0 while IFS= read -r dir; do [[ -z "$dir" ]] && continue if ! rclone_exists "${snap_sub}/${dir}/.complete"; then log_info "Purging incomplete system snapshot: $dir" rclone_purge "${snap_sub}/${dir}" || { log_warn "Failed to purge incomplete system snapshot: $dir" } fi done <<< "$all_dirs" return 0 fi local snap_dir; snap_dir=$(_system_snap_base) local partials; partials=$(remote_exec "ls -1d '$snap_dir'/*.partial 2>/dev/null" 2>/dev/null) || true if [[ -n "$partials" ]]; then log_info "Cleaning partial system snapshots..." remote_exec "rm -rf '$snap_dir'/*.partial" || { log_warn "Failed to clean partial system snapshots" } fi } finalize_system_snapshot() { local ts="$1" if _is_rclone_mode; then local snap_sub; snap_sub=$(_system_snap_subpath) log_info "Finalizing system snapshot: $ts (rclone)" rclone_rcat "${snap_sub}/${ts}/.complete" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" || { log_error "Failed to create .complete marker for system/$ts" return 1 } rclone_rcat "${snap_sub}/latest.txt" "$ts" || { log_warn "Failed to update system latest.txt" return 1 } log_debug "Updated system latest.txt -> $ts" return 0 fi local snap_dir; snap_dir=$(_system_snap_base) log_info "Finalizing system snapshot: $ts" remote_exec "mv '$snap_dir/${ts}.partial' '$snap_dir/$ts'" || { log_error "Failed to finalize system snapshot: $ts" return 1 } # Update latest symlink local hostname; hostname=$(hostname -f) local base="${REMOTE_BASE}/${hostname}/system" remote_exec "ln -sfn '$snap_dir/$ts' '$base/latest'" || { log_warn "Failed to update system latest symlink" return 1 } log_debug "Updated system latest symlink -> $ts" } enforce_system_retention() { local keep="${RETENTION_COUNT:-$DEFAULT_RETENTION_COUNT}" log_debug "Enforcing system retention: keeping $keep snapshots" local snapshots; snapshots=$(list_system_snapshots) if [[ -z "$snapshots" ]]; then log_debug "No system snapshots found, nothing to prune" return 0 fi local count=0 local pruned=0 while IFS= read -r snap; do ((count++)) || true if (( count > keep )); then log_info "Pruning old system snapshot: $snap" if _is_rclone_mode; then local snap_sub; snap_sub=$(_system_snap_subpath) rclone_purge "${snap_sub}/${snap}" || { log_warn "Failed to purge system snapshot: $snap" } else local snap_dir; snap_dir=$(_system_snap_base) remote_exec "rm -rf '$snap_dir/$snap'" || { log_warn "Failed to prune system snapshot: $snap_dir/$snap" } fi ((pruned++)) || true fi done <<< "$snapshots" if (( pruned > 0 )); then log_info "Pruned $pruned old system snapshot(s)" fi } # ── Transfer to Remote ─────────────────────────────────────── transfer_system_backup() { local stage_dir="$1" local ts="$2" local prev_snapshot="${3:-}" if _is_rclone_mode; then local snap_sub; snap_sub=$(_system_snap_subpath) log_info "Transferring system backup (rclone)..." rclone_to_remote "$stage_dir" "${snap_sub}/${ts}" return fi local snap_dir; snap_dir=$(_system_snap_base) local dest="$snap_dir/${ts}.partial/" local link_dest="" if [[ -n "$prev_snapshot" ]]; then link_dest="$snap_dir/$prev_snapshot" fi ensure_remote_dir "$dest" || return 1 log_info "Transferring system backup..." rsync_to_remote "$stage_dir" "$dest" "$link_dest" } # ── API Export Functions ───────────────────────────────────── _export_packages() { local api_dir="$1" log_info "Exporting hosting packages..." if ! whmapi1 listpkgs --output=json > "$api_dir/packages.json" 2>/dev/null; then log_error "Failed to export packages" return 1 fi } _export_tweaksettings() { local api_dir="$1" log_info "Exporting tweak settings..." if ! whmapi1 get_tweaksettings --output=json > "$api_dir/tweaksettings.json" 2>/dev/null; then log_error "Failed to export tweak settings" return 1 fi } _export_dns_zones() { local api_dir="$1" log_info "Exporting DNS zones..." if ! whmapi1 listzones --output=json > "$api_dir/zones.json" 2>/dev/null; then log_error "Failed to export zone list" return 1 fi mkdir -p "$api_dir/zones" local zones; zones=$(python3 -c " import sys, json data = json.load(sys.stdin) for z in data.get('data', {}).get('zone', []): print(z['domain']) " < "$api_dir/zones.json" 2>/dev/null) || true if [[ -z "$zones" ]]; then log_warn "No DNS zones found to export" return 0 fi local count=0 while IFS= read -r domain; do [[ -z "$domain" ]] && continue if whmapi1 dumpzone domain="$domain" --output=json > "$api_dir/zones/${domain}.zone" 2>/dev/null; then ((count++)) || true else log_warn "Failed to dump zone for: $domain" fi done <<< "$zones" log_info "Exported $count DNS zone(s)" } _export_ips() { local api_dir="$1" log_info "Exporting IP configuration..." if ! whmapi1 listips --output=json > "$api_dir/ips.json" 2>/dev/null; then log_error "Failed to export IP list" return 1 fi } _export_php() { local api_dir="$1" log_info "Exporting PHP configuration..." # Combine multiple PHP API calls into one JSON file local tmpfile; tmpfile=$(mktemp) { echo '{' echo '"system_default":' whmapi1 php_get_system_default_version --output=json 2>/dev/null || echo '{}' echo ',' echo '"installed_versions":' whmapi1 php_get_installed_versions --output=json 2>/dev/null || echo '{}' echo ',' echo '"vhost_versions":' whmapi1 php_get_vhost_versions --output=json 2>/dev/null || echo '{}' echo '}' } > "$tmpfile" if [[ -s "$tmpfile" ]]; then mv "$tmpfile" "$api_dir/php.json" else rm -f "$tmpfile" log_error "Failed to export PHP configuration" return 1 fi } # ── File Staging ───────────────────────────────────────────── # Known system paths to back up readonly _SYSBACKUP_PATHS=( # cPanel core config /var/cpanel/packages /var/cpanel/features /var/cpanel/cpanel.config /etc/wwwacct.conf /etc/cpanel /var/cpanel/ssl /var/cpanel/MultiPHP # DNS /var/named /etc/named.conf # Exim /etc/exim.conf /etc/exim.conf.local /etc/exim.conf.localopts # Mail routing /etc/localdomains /etc/remotedomains /etc/secondarymx /etc/valiases /etc/vdomainaliases /etc/vfilters # Apache / EasyApache /etc/httpd/conf /usr/local/apache/conf /etc/httpd/conf.d /etc/cpanel/ea4 # MySQL /etc/my.cnf /root/.my.cnf # CSF firewall /etc/csf # Root cron & SSH /var/spool/cron/root /root/.ssh # Network /etc/ips /etc/reservedips /etc/reservedipreasons /etc/sysconfig/network /etc/resolv.conf # gniza4cp's own config /etc/gniza4cp ) _stage_files() { local stage_dir="$1" local files_dir="$stage_dir/files" local count=0 local failed=0 for src_path in "${_SYSBACKUP_PATHS[@]}"; do if [[ ! -e "$src_path" ]]; then log_debug "Skipping (not found): $src_path" continue fi # Mirror the source path under files/ (strip leading /) local rel_path="${src_path#/}" local dest="$files_dir/$rel_path" # Create parent directory mkdir -p "$(dirname "$dest")" if cp -a "$src_path" "$dest" 2>/dev/null; then ((count++)) || true log_debug "Staged: $src_path" else ((failed++)) || true log_warn "Failed to stage: $src_path" fi done # Also stage ea-php configs if they exist local ea_php_dirs; ea_php_dirs=$(ls -d /opt/cpanel/ea-php*/root/etc/ 2>/dev/null) || true if [[ -n "$ea_php_dirs" ]]; then while IFS= read -r ea_dir; do [[ -z "$ea_dir" ]] && continue local rel="${ea_dir#/}" local dest="$files_dir/$rel" mkdir -p "$(dirname "$dest")" if cp -a "$ea_dir" "$dest" 2>/dev/null; then ((count++)) || true log_debug "Staged: $ea_dir" else ((failed++)) || true log_warn "Failed to stage: $ea_dir" fi done <<< "$ea_php_dirs" fi log_info "Staged $count system path(s) ($failed failed)" return 0 } # ── Backup Orchestrator ────────────────────────────────────── run_system_backup() { local stage_dir="$1" local api_dir="$stage_dir/api" local api_failed=0 local api_succeeded=0 mkdir -p "$api_dir" mkdir -p "$stage_dir/files" log_info "=== System Backup: API exports ===" # Export each API category — continue on failure if _export_packages "$api_dir"; then ((api_succeeded++)) || true else ((api_failed++)) || true fi if _export_tweaksettings "$api_dir"; then ((api_succeeded++)) || true else ((api_failed++)) || true fi if _export_dns_zones "$api_dir"; then ((api_succeeded++)) || true else ((api_failed++)) || true fi if _export_ips "$api_dir"; then ((api_succeeded++)) || true else ((api_failed++)) || true fi if _export_php "$api_dir"; then ((api_succeeded++)) || true else ((api_failed++)) || true fi log_info "API exports: $api_succeeded succeeded, $api_failed failed" log_info "=== System Backup: File staging ===" _stage_files "$stage_dir" if (( api_failed > 0 )); then log_warn "System backup completed with $api_failed API export failure(s)" return 0 # Don't fail the whole backup for partial API failures fi log_info "System backup staging complete" return 0 } cleanup_system_stage() { local temp_dir="${TEMP_DIR:-$DEFAULT_TEMP_DIR}" local sys_dir="$temp_dir/system" if [[ -d "$sys_dir" ]]; then rm -rf "$sys_dir" log_debug "Cleaned up system staging directory" fi }