Files
gniza4cp/lib/sysbackup.sh
shuki 3547b00ead Add sysbackup/sysrestore CLI commands and schedule integration
- Add lib/sysbackup.sh and lib/sysrestore.sh for system-level
  backup and restore of WHM/cPanel config, packages, and cron jobs
- Wire cmd_sysbackup and cmd_sysrestore into bin/gniza
- Add --sysbackup flag to cmd_backup: runs system backup after all
  account backups complete
- Add SYSBACKUP schedule config key so cron jobs can include
  --sysbackup automatically via build_cron_line()
- Add "Include system backup" toggle to WHM schedule form
- Revert sysbackup toggle from remotes.cgi (belongs in schedules)

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

472 lines
14 KiB
Bash

#!/usr/bin/env bash
# gniza/lib/sysbackup.sh — System-level WHM backup: API exports, file staging, snapshot lifecycle
[[ -n "${_GNIZA_SYSBACKUP_LOADED:-}" ]] && return 0
_GNIZA_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
# gniza's own config
/etc/gniza
)
_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
}