- CLI binary: bin/gniza -> bin/gniza4cp - Install path: /usr/local/gniza4cp/ - Config path: /etc/gniza4cp/ - Log path: /var/log/gniza4cp/ - WHM plugin: gniza4cp-whm/ - cPanel plugin: cpanel/gniza4cp/ - AdminBin: Gniza4cp::Restore - Perl modules: Gniza4cpWHM::*, Gniza4cpCPanel::* - DaisyUI theme: gniza4cp - All internal references, branding, paths updated - Git remote updated to gniza4cp repo
472 lines
14 KiB
Bash
472 lines
14 KiB
Bash
#!/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
|
|
}
|