Files
gniza4cp/lib/restore.sh
shuki b8858bcbc8 Remove restore strategy (merge/terminate) from all layers
Restores now always merge into existing accounts (--force). The
terminate-and-recreate option is removed from CLI, restore library,
Runner allowlist, and WHM UI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 19:47:28 +02:00

1414 lines
54 KiB
Bash

#!/usr/bin/env bash
# gniza/lib/restore.sh — Full account, files, database, mailbox, server restores
# Helper: build rsync download command args for SSH mode
_rsync_download() {
local remote_path="$1"
local local_path="$2"
local rsync_ssh; rsync_ssh=$(build_rsync_ssh_cmd)
if _is_password_mode; then
sshpass -p "$REMOTE_PASSWORD" rsync -aHAX --numeric-ids --rsync-path="rsync --fake-super" \
-e "$rsync_ssh" \
"${REMOTE_USER}@${REMOTE_HOST}:${remote_path}" \
"$local_path"
else
rsync -aHAX --numeric-ids --rsync-path="rsync --fake-super" \
-e "$rsync_ssh" \
"${REMOTE_USER}@${REMOTE_HOST}:${remote_path}" \
"$local_path"
fi
}
# Helper: build --exclude args from comma-separated exclude string.
# Outputs one arg per line (--exclude then pattern).
_build_exclude_args() {
local exclude="$1"
local -a args=()
if [[ -n "$exclude" ]]; then
IFS=',' read -ra patterns <<< "$exclude"
for p in "${patterns[@]}"; do
p="${p## }"; p="${p%% }"
[[ -n "$p" ]] && args+=(--exclude "$p")
done
fi
printf '%s\n' "${args[@]}"
}
# Helper: detect pkgacct base (old vs new format) for rclone or SSH
_detect_pkgacct_base() {
local snap_path="$1"
local snap_subpath="${2:-}"
if _is_rclone_mode; then
if rclone_exists "${snap_subpath}/pkgacct/"; then
echo "${snap_subpath}/pkgacct"
else
echo "$snap_subpath"
fi
else
if remote_exec "test -d '$snap_path/pkgacct'" 2>/dev/null; then
echo "$snap_path/pkgacct"
else
echo "$snap_path"
fi
fi
}
restore_full_account() {
local user="$1"
local timestamp="${2:-}"
local _unused="${3:-}" # formerly strategy, kept for call-site compat
local exclude="${4:-}"
local temp_dir="${TEMP_DIR:-$DEFAULT_TEMP_DIR}"
# Resolve timestamp
local ts; ts=$(resolve_snapshot_timestamp "$user" "$timestamp") || return 1
local snap_dir; snap_dir=$(get_snapshot_dir "$user")
local snap_path="$snap_dir/$ts"
log_info "Restoring full account: $user from snapshot $ts"
local restore_dir="$temp_dir/restore-$user"
mkdir -p "$restore_dir" || {
log_error "Failed to create restore temp directory"
return 1
}
# Download pkgacct data from remote
log_info "Downloading pkgacct data for $user..."
if _is_rclone_mode; then
local snap_subpath="accounts/${user}/snapshots/${ts}"
local pkgacct_sub; pkgacct_sub=$(_detect_pkgacct_base "" "$snap_subpath")
if [[ "$pkgacct_sub" == *"/pkgacct" ]]; then
rclone_from_remote "${pkgacct_sub}" "$restore_dir/$user" || {
log_error "Failed to download pkgacct data for $user"
rm -rf "$restore_dir"
return 1
}
else
# Download snapshot root excluding homedir
rclone_from_remote "${snap_subpath}" "$restore_dir/$user" || {
log_error "Failed to download pkgacct data for $user"
rm -rf "$restore_dir"
return 1
}
rm -rf "$restore_dir/$user/homedir"
fi
else
# Detect old format (pkgacct/ subdir) vs new format (content at root)
local rsync_ssh; rsync_ssh=$(build_rsync_ssh_cmd)
if remote_exec "test -d '$snap_path/pkgacct'" 2>/dev/null; then
# Old format: pkgacct data in subdir
if ! rsync -aHAX --numeric-ids --rsync-path="rsync --fake-super" \
-e "$rsync_ssh" \
"${REMOTE_USER}@${REMOTE_HOST}:${snap_path}/pkgacct/" \
"$restore_dir/$user/"; then
log_error "Failed to download pkgacct data for $user"
rm -rf "$restore_dir"
return 1
fi
else
# New format: pkgacct content at snapshot root, exclude homedir/
if ! rsync -aHAX --numeric-ids --rsync-path="rsync --fake-super" --exclude=/homedir/ \
-e "$rsync_ssh" \
"${REMOTE_USER}@${REMOTE_HOST}:${snap_path}/" \
"$restore_dir/$user/"; then
log_error "Failed to download pkgacct data for $user"
rm -rf "$restore_dir"
return 1
fi
fi
fi
# Gunzip SQL files
log_info "Decompressing SQL files..."
local mysql_dir="$restore_dir/$user/mysql"
if [[ -d "$mysql_dir" ]]; then
find "$mysql_dir" -name "*.sql.gz" -exec gunzip -f {} \;
fi
# Run restorepkg (--force to merge into existing account if present)
log_info "Running restorepkg for $user..."
local -a restorepkg_args=()
account_exists "$user" && restorepkg_args+=(--force)
if ! /scripts/restorepkg "${restorepkg_args[@]}" "$restore_dir/$user"; then
log_error "restorepkg failed for $user"
rm -rf "$restore_dir"
return 1
fi
# Build exclude args for homedir phase
local -a exclude_args=()
if [[ -n "$exclude" ]]; then
while IFS= read -r arg; do
exclude_args+=("$arg")
done < <(_build_exclude_args "$exclude")
fi
# Restore homedir
local homedir; homedir=$(get_account_homedir "$user")
if _is_rclone_mode; then
local snap_subpath="accounts/${user}/snapshots/${ts}"
if rclone_exists "${snap_subpath}/homedir/"; then
log_info "Restoring homedir for $user..."
if [[ ${#exclude_args[@]} -gt 0 ]]; then
if ! rclone_from_remote_filtered "${snap_subpath}/homedir" "$homedir" "${exclude_args[@]}"; then
log_error "Failed to restore homedir for $user"
rm -rf "$restore_dir"
return 1
fi
else
if ! rclone_from_remote "${snap_subpath}/homedir" "$homedir"; then
log_error "Failed to restore homedir for $user"
rm -rf "$restore_dir"
return 1
fi
fi
log_info "Fixing ownership for $homedir..."
chown -R "$user":"$user" "$homedir"
fi
elif remote_exec "test -d '$snap_path/homedir'" 2>/dev/null; then
log_info "Restoring homedir for $user..."
local rsync_ssh; rsync_ssh=$(build_rsync_ssh_cmd)
if ! rsync -aHAX --numeric-ids --rsync-path="rsync --fake-super" \
"${exclude_args[@]}" \
-e "$rsync_ssh" \
"${REMOTE_USER}@${REMOTE_HOST}:${snap_path}/homedir/" \
"$homedir/"; then
log_error "Failed to restore homedir for $user"
rm -rf "$restore_dir"
return 1
fi
# Fix ownership
log_info "Fixing ownership for $homedir..."
chown -R "$user":"$user" "$homedir"
fi
rm -rf "$restore_dir"
log_info "Full account restore completed for $user"
return 0
}
restore_files() {
local user="$1"
local subpath="${2:-}"
local timestamp="${3:-}"
local exclude="${4:-}"
local ts; ts=$(resolve_snapshot_timestamp "$user" "$timestamp") || return 1
local homedir; homedir=$(get_account_homedir "$user")
local local_dest="$homedir/"
if [[ -n "$subpath" ]]; then
local_dest="${homedir}/${subpath}"
mkdir -p "$(dirname "$local_dest")"
fi
log_info "Restoring files for $user: ${subpath:-entire homedir} from snapshot $ts"
# Build exclude args
local -a exclude_args=()
if [[ -n "$exclude" ]]; then
while IFS= read -r arg; do
exclude_args+=("$arg")
done < <(_build_exclude_args "$exclude")
fi
if _is_rclone_mode; then
local snap_subpath="accounts/${user}/snapshots/${ts}"
local remote_sub="${snap_subpath}/homedir"
[[ -n "$subpath" ]] && remote_sub="${snap_subpath}/homedir/${subpath}"
if [[ ${#exclude_args[@]} -gt 0 ]]; then
if ! rclone_from_remote_filtered "$remote_sub" "$local_dest" "${exclude_args[@]}"; then
log_error "Failed to restore files for $user"
return 1
fi
else
if ! rclone_from_remote "$remote_sub" "$local_dest"; then
log_error "Failed to restore files for $user"
return 1
fi
fi
else
local snap_dir; snap_dir=$(get_snapshot_dir "$user")
local snap_path="$snap_dir/$ts"
local rsync_ssh; rsync_ssh=$(build_rsync_ssh_cmd)
local remote_source="${snap_path}/homedir/"
[[ -n "$subpath" ]] && remote_source="${snap_path}/homedir/${subpath}"
if ! rsync -aHAX --numeric-ids --rsync-path="rsync --fake-super" \
"${exclude_args[@]}" \
-e "$rsync_ssh" \
"${REMOTE_USER}@${REMOTE_HOST}:${remote_source}" \
"$local_dest"; then
log_error "Failed to restore files for $user"
return 1
fi
fi
# Fix ownership
chown -R "$user":"$user" "$local_dest"
log_info "File restore completed for $user"
return 0
}
restore_database() {
local user="$1"
local dbname="$2"
local timestamp="${3:-}"
local temp_dir="${TEMP_DIR:-$DEFAULT_TEMP_DIR}"
local ts; ts=$(resolve_snapshot_timestamp "$user" "$timestamp") || return 1
local snap_dir; snap_dir=$(get_snapshot_dir "$user")
local snap_path="$snap_dir/$ts"
local rsync_ssh; rsync_ssh=$(build_rsync_ssh_cmd)
local restore_dir="$temp_dir/restore-db-$user"
mkdir -p "$restore_dir" || {
log_error "Failed to create restore temp directory"
return 1
}
log_info "Restoring database $dbname for $user from snapshot $ts"
local local_sql="$restore_dir/${dbname}.sql.gz"
if _is_rclone_mode; then
local snap_subpath="accounts/${user}/snapshots/${ts}"
local pkgacct_sub; pkgacct_sub=$(_detect_pkgacct_base "" "$snap_subpath")
if ! rclone_from_remote "${pkgacct_sub}/mysql/${dbname}.sql.gz" "$restore_dir"; then
log_error "SQL dump not found for database: $dbname"
rm -rf "$restore_dir"
return 1
fi
else
# Detect old format (pkgacct/ subdir) vs new format (content at root)
local pkgacct_base="$snap_path"
if remote_exec "test -d '$snap_path/pkgacct'" 2>/dev/null; then
pkgacct_base="$snap_path/pkgacct"
fi
local remote_sql="${pkgacct_base}/mysql/${dbname}.sql.gz"
if ! rsync -aHAX --rsync-path="rsync --fake-super" \
-e "$rsync_ssh" \
"${REMOTE_USER}@${REMOTE_HOST}:${remote_sql}" \
"$local_sql" 2>/dev/null; then
log_error "SQL dump not found for database: $dbname"
rm -rf "$restore_dir"
return 1
fi
fi
# Decompress
gunzip -f "$local_sql" || {
log_error "Failed to decompress SQL file"
rm -rf "$restore_dir"
return 1
}
# Download mysql.sql (grants) if it exists
local local_grants="$restore_dir/mysql.sql"
if _is_rclone_mode; then
local snap_subpath="accounts/${user}/snapshots/${ts}"
local pkgacct_sub; pkgacct_sub=$(_detect_pkgacct_base "" "$snap_subpath")
rclone_from_remote "${pkgacct_sub}/mysql.sql" "$restore_dir" 2>/dev/null || true
else
local remote_grants="${pkgacct_base}/mysql.sql"
rsync -aHAX --rsync-path="rsync --fake-super" -e "$rsync_ssh" \
"${REMOTE_USER}@${REMOTE_HOST}:${remote_grants}" \
"$local_grants" 2>/dev/null
fi
# Get cPanel DB prefix for this account
local db_prefix
db_prefix=$(uapi --user="$user" Mysql get_restrictions 2>/dev/null | grep -oP '^\s+prefix:\s+\K\S+') || db_prefix=""
# Create database via cPanel UAPI
log_info "Creating database $dbname via UAPI..."
if [[ -n "$db_prefix" && "$dbname" != "${db_prefix}"* ]]; then
log_error "Database $dbname does not match cPanel prefix '${db_prefix}' — cannot create via UAPI. Rename the database to start with '${db_prefix}'"
rm -rf "$restore_dir"
return 1
fi
local uapi_out; uapi_out=$(uapi --user="$user" Mysql create_database name="$dbname" 2>&1) || true
if echo "$uapi_out" | grep -q 'status: 1'; then
log_info "Database $dbname created via UAPI"
elif echo "$uapi_out" | grep -qi 'already exists'; then
log_debug "Database $dbname already exists"
else
log_error "UAPI create_database failed for $dbname: $uapi_out"
rm -rf "$restore_dir"
return 1
fi
# Import SQL dump — no cPanel API exists for SQL import; mysql binary is required
log_info "Importing SQL dump for $dbname..."
if ! mysql --defaults-file=/root/.my.cnf "$dbname" < "$restore_dir/${dbname}.sql"; then
log_error "Failed to import SQL dump for $dbname"
rm -rf "$restore_dir"
return 1
fi
# Restore database users and privileges via UAPI
if [[ -f "$local_grants" ]]; then
log_info "Restoring database users and privileges via UAPI..."
# Build grep pattern that matches both escaped (\_) and unescaped (_) underscores
local dbname_grep; dbname_grep="${dbname//_/\\\\?_}"
local grant_users; grant_users=$(grep -iE "\`${dbname_grep}\`" "$local_grants" | grep -oP "TO\s+'?\K[^'@]+(?='@)" | sort -u) || true
if [[ -n "$grant_users" ]]; then
while IFS= read -r dbuser; do
[[ -z "$dbuser" ]] && continue
if [[ -n "$db_prefix" && "$dbuser" != "${db_prefix}"* ]]; then
log_warn "Skipping DB user $dbuser — does not match cPanel prefix '${db_prefix}'"
continue
fi
# Create user via UAPI
local cu_out; cu_out=$(uapi --user="$user" Mysql create_user name="$dbuser" password="$(openssl rand -base64 24)" 2>&1) || true
if echo "$cu_out" | grep -q 'status: 1'; then
log_debug "Created DB user $dbuser via UAPI"
elif echo "$cu_out" | grep -qi 'exists'; then
log_debug "DB user $dbuser already exists"
else
log_error "UAPI create_user failed for $dbuser: $cu_out"
rm -rf "$restore_dir"
return 1
fi
# Grant privileges via UAPI
local sp_out; sp_out=$(uapi --user="$user" Mysql set_privileges_on_database user="$dbuser" database="$dbname" privileges=ALL 2>&1) || true
if ! echo "$sp_out" | grep -q 'status: 1'; then
log_error "UAPI set_privileges_on_database failed for $dbuser on $dbname: $sp_out"
rm -rf "$restore_dir"
return 1
fi
log_debug "Registered DB user $dbuser for $dbname via UAPI"
done <<< "$grant_users"
fi
fi
rm -rf "$restore_dir"
log_info "Database restore completed for $dbname"
return 0
}
restore_all_databases() {
local user="$1"
local timestamp="${2:-}"
log_info "Restoring all databases for $user"
local db_list; db_list=$(list_snapshot_databases "$user" "$timestamp")
if [[ -z "$db_list" ]]; then
log_warn "No databases found in snapshot for $user"
return 0
fi
local total=0 succeeded=0 failed=0
while IFS= read -r dbname; do
[[ -z "$dbname" ]] && continue
((total++)) || true
log_info "--- Restoring database $dbname ($total) ---"
if restore_database "$user" "$dbname" "$timestamp"; then
((succeeded++)) || true
else
((failed++)) || true
log_error "Failed to restore database: $dbname"
fi
done <<< "$db_list"
log_info "Restored $succeeded/$total databases for $user"
(( failed > 0 )) && return 1
return 0
}
restore_mailbox() {
local user="$1"
local email="$2"
local timestamp="${3:-}"
# Parse email into domain and mailbox user
local mailbox_user="${email%%@*}"
local domain="${email##*@}"
if [[ -z "$mailbox_user" || -z "$domain" || "$mailbox_user" == "$email" ]]; then
log_error "Invalid email format: $email (expected user@domain)"
return 1
fi
log_info "Restoring mailbox $email for account $user"
# Ensure mailbox account exists in cPanel via UAPI
local pop_out; pop_out=$(uapi --user="$user" Email add_pop \
email="$mailbox_user" domain="$domain" \
password="$(openssl rand -base64 24)" quota=0 2>&1) || true
if echo "$pop_out" | grep -q 'status: 1'; then
log_info "Created mailbox $email via UAPI"
elif echo "$pop_out" | grep -qi 'exists'; then
log_debug "Mailbox $email already exists"
else
log_error "UAPI Email add_pop failed for $email: $pop_out"
return 1
fi
# Restore mailbox data files
local mail_subpath="mail/${domain}/${mailbox_user}/"
restore_files "$user" "$mail_subpath" "$timestamp"
}
restore_all_mailboxes() {
local user="$1"
local timestamp="${2:-}"
log_info "Restoring all mailboxes for $user"
local mailbox_list; mailbox_list=$(list_snapshot_mailboxes "$user" "$timestamp")
if [[ -z "$mailbox_list" ]]; then
log_warn "No mailboxes found in snapshot for $user"
return 0
fi
local total=0 succeeded=0 failed=0
while IFS= read -r email; do
[[ -z "$email" ]] && continue
((total++)) || true
log_info "--- Restoring mailbox $email ($total) ---"
if restore_mailbox "$user" "$email" "$timestamp"; then
((succeeded++)) || true
else
((failed++)) || true
log_error "Failed to restore mailbox: $email"
fi
done <<< "$mailbox_list"
log_info "Restored $succeeded/$total mailboxes for $user"
(( failed > 0 )) && return 1
return 0
}
# List database names available in a snapshot's mysql/ directory.
# Outputs one database name per line (without .sql.gz extension).
list_snapshot_databases() {
local user="$1"
local timestamp="${2:-}"
local ts; ts=$(resolve_snapshot_timestamp "$user" "$timestamp") || return 1
if _is_rclone_mode; then
local snap_subpath="accounts/${user}/snapshots/${ts}"
local pkgacct_sub; pkgacct_sub=$(_detect_pkgacct_base "" "$snap_subpath")
rclone_list_files "${pkgacct_sub}/mysql" 2>/dev/null | while IFS= read -r f; do
[[ "$f" == *.sql.gz ]] || continue
echo "${f%.sql.gz}"
done
return
fi
local snap_dir; snap_dir=$(get_snapshot_dir "$user")
local snap_path="$snap_dir/$ts"
# Detect old format vs new format
local pkgacct_base="$snap_path"
if remote_exec "test -d '$snap_path/pkgacct'" 2>/dev/null; then
pkgacct_base="$snap_path/pkgacct"
fi
local mysql_dir="${pkgacct_base}/mysql"
remote_exec "ls -1 '$mysql_dir'/*.sql.gz 2>/dev/null" 2>/dev/null | while IFS= read -r f; do
local base; base=$(basename "$f" .sql.gz)
echo "$base"
done
}
# List mailbox addresses available in a snapshot's homedir/mail/ directory.
# Outputs one email address per line (user@domain format).
list_snapshot_mailboxes() {
local user="$1"
local timestamp="${2:-}"
local ts; ts=$(resolve_snapshot_timestamp "$user" "$timestamp") || return 1
if _is_rclone_mode; then
local snap_subpath="accounts/${user}/snapshots/${ts}"
local domains; domains=$(rclone_list_dirs "${snap_subpath}/homedir/mail" 2>/dev/null) || true
[[ -z "$domains" ]] && return 0
while IFS= read -r domain; do
[[ -z "$domain" || "$domain" == .* ]] && continue
local users; users=$(rclone_list_dirs "${snap_subpath}/homedir/mail/${domain}" 2>/dev/null) || true
[[ -z "$users" ]] && continue
while IFS= read -r mailbox_user; do
[[ -z "$mailbox_user" || "$mailbox_user" == .* ]] && continue
echo "${mailbox_user}@${domain}"
done <<< "$users"
done <<< "$domains"
return
fi
local snap_dir; snap_dir=$(get_snapshot_dir "$user")
local snap_path="$snap_dir/$ts"
local mail_dir="${snap_path}/homedir/mail"
remote_exec "find '$mail_dir' -mindepth 2 -maxdepth 2 -type d 2>/dev/null" 2>/dev/null | while IFS= read -r d; do
local mailbox_user; mailbox_user=$(basename "$d")
local domain; domain=$(basename "$(dirname "$d")")
# Skip Maildir system directories (not real domains/users)
[[ "$domain" == .* || "$mailbox_user" == .* ]] && continue
echo "${mailbox_user}@${domain}"
done
}
# List files/directories in a snapshot's homedir/ directory.
# Outputs one entry per line. Directories end with '/'.
# Optional subpath argument for navigating subdirectories.
list_snapshot_files() {
local user="$1"
local timestamp="${2:-}"
local subpath="${3:-}"
# Guard against path traversal
if [[ "$subpath" == *".."* ]]; then
log_error "Path traversal not allowed: $subpath"
return 1
fi
local ts; ts=$(resolve_snapshot_timestamp "$user" "$timestamp") || return 1
if _is_rclone_mode; then
local snap_subpath="accounts/${user}/snapshots/${ts}/homedir"
[[ -n "$subpath" ]] && snap_subpath="${snap_subpath}/${subpath}"
rclone_list_files "$snap_subpath" 2>/dev/null || true
return
fi
local snap_dir; snap_dir=$(get_snapshot_dir "$user")
local snap_path="$snap_dir/$ts"
local target_dir="${snap_path}/homedir"
if [[ -n "$subpath" ]]; then
target_dir="${target_dir}/${subpath}"
fi
remote_exec "ls -1ap '$target_dir' 2>/dev/null" 2>/dev/null | while IFS= read -r entry; do
# Filter out . and ..
[[ "$entry" == "." || "$entry" == ".." || "$entry" == "./" || "$entry" == "../" ]] && continue
echo "$entry"
done
}
# List database users found in a snapshot's mysql.sql grant file.
# Outputs one username per line.
list_snapshot_dbusers() {
local user="$1"
local timestamp="${2:-}"
local ts; ts=$(resolve_snapshot_timestamp "$user" "$timestamp") || return 1
if _is_rclone_mode; then
local snap_subpath="accounts/${user}/snapshots/${ts}"
local pkgacct_sub; pkgacct_sub=$(_detect_pkgacct_base "" "$snap_subpath")
local grants_content; grants_content=$(rclone_cat "${pkgacct_sub}/mysql.sql" 2>/dev/null) || true
[[ -z "$grants_content" ]] && return 0
echo "$grants_content" | grep -oP "TO\s+'?\K[^'@]+" | sort -u | while IFS= read -r dbuser; do
[[ "$dbuser" == "$user" ]] && continue
[[ -z "$dbuser" ]] && continue
echo "$dbuser"
done
return
fi
local snap_dir; snap_dir=$(get_snapshot_dir "$user")
local snap_path="$snap_dir/$ts"
local pkgacct_base="$snap_path"
if remote_exec "test -d '$snap_path/pkgacct'" 2>/dev/null; then
pkgacct_base="$snap_path/pkgacct"
fi
local grants_file="${pkgacct_base}/mysql.sql"
remote_exec "grep -oP \"TO\\\\s+'?\\\\K[^'@]+\" '$grants_file' 2>/dev/null | sort -u" 2>/dev/null | while IFS= read -r dbuser; do
# Skip the account's default DB user (same as cPanel username)
[[ "$dbuser" == "$user" ]] && continue
[[ -z "$dbuser" ]] && continue
echo "$dbuser"
done
}
# List cron job entries found in a snapshot's cron/ directory.
# Outputs one cron line per line (skips comments, empty lines, env vars).
list_snapshot_cron() {
local user="$1"
local timestamp="${2:-}"
local ts; ts=$(resolve_snapshot_timestamp "$user" "$timestamp") || return 1
if _is_rclone_mode; then
local snap_subpath="accounts/${user}/snapshots/${ts}"
local pkgacct_sub; pkgacct_sub=$(_detect_pkgacct_base "" "$snap_subpath")
local files; files=$(rclone_list_files "${pkgacct_sub}/cron" 2>/dev/null) || true
[[ -z "$files" ]] && return 0
local first_file; first_file=$(echo "$files" | head -1)
[[ -z "$first_file" ]] && return 0
rclone_cat "${pkgacct_sub}/cron/${first_file}" 2>/dev/null | grep -vE '^\s*#|^\s*$|^\s*[A-Za-z_]+=' || true
return
fi
local snap_dir; snap_dir=$(get_snapshot_dir "$user")
local snap_path="$snap_dir/$ts"
local pkgacct_base="$snap_path"
if remote_exec "test -d '$snap_path/pkgacct'" 2>/dev/null; then
pkgacct_base="$snap_path/pkgacct"
fi
local cron_dir="${pkgacct_base}/cron"
remote_exec "f=\$(find '$cron_dir' -type f 2>/dev/null | head -1); [ -f \"\$f\" ] && grep -vE '^\s*#|^\s*$|^\s*[A-Za-z_]+=' \"\$f\" || true" 2>/dev/null
}
# List DNS zone names found in a snapshot's dnszones/ directory.
# Outputs one domain name per line.
list_snapshot_dns() {
local user="$1"
local timestamp="${2:-}"
local ts; ts=$(resolve_snapshot_timestamp "$user" "$timestamp") || return 1
if _is_rclone_mode; then
local snap_subpath="accounts/${user}/snapshots/${ts}"
local pkgacct_sub; pkgacct_sub=$(_detect_pkgacct_base "" "$snap_subpath")
rclone_list_files "${pkgacct_sub}/dnszones" 2>/dev/null | while IFS= read -r f; do
[[ "$f" == *.db ]] || continue
echo "${f%.db}"
done
return
fi
local snap_dir; snap_dir=$(get_snapshot_dir "$user")
local snap_path="$snap_dir/$ts"
local pkgacct_base="$snap_path"
if remote_exec "test -d '$snap_path/pkgacct'" 2>/dev/null; then
pkgacct_base="$snap_path/pkgacct"
fi
local zones_dir="${pkgacct_base}/dnszones"
remote_exec "ls -1 '$zones_dir'/*.db 2>/dev/null" 2>/dev/null | while IFS= read -r f; do
local base; base=$(basename "$f" .db)
echo "$base"
done
}
# List SSL certificate domains found in a snapshot's ssl/ directory.
# Outputs one cert filename (without extension) per line.
list_snapshot_ssl() {
local user="$1"
local timestamp="${2:-}"
local ts; ts=$(resolve_snapshot_timestamp "$user" "$timestamp") || return 1
if _is_rclone_mode; then
local snap_subpath="accounts/${user}/snapshots/${ts}"
local pkgacct_sub; pkgacct_sub=$(_detect_pkgacct_base "" "$snap_subpath")
{
rclone_list_files "${pkgacct_sub}/ssl/certs" 2>/dev/null || true
rclone_list_files "${pkgacct_sub}/ssl/installed/certs" 2>/dev/null || true
rclone_list_files "${pkgacct_sub}/sslcerts" 2>/dev/null || true
rclone_list_files "${snap_subpath}/homedir/ssl/certs" 2>/dev/null || true
} | while IFS= read -r f; do
[[ "$f" == *.crt ]] || continue
echo "${f%.crt}"
done | sort -u
return
fi
local snap_dir; snap_dir=$(get_snapshot_dir "$user")
local snap_path="$snap_dir/$ts"
local pkgacct_base="$snap_path"
if remote_exec "test -d '$snap_path/pkgacct'" 2>/dev/null; then
pkgacct_base="$snap_path/pkgacct"
fi
local homedir_base="${snap_path}/homedir"
remote_exec "ls -1 '$pkgacct_base/ssl/certs/'*.crt '$pkgacct_base/ssl/installed/certs/'*.crt '$pkgacct_base/sslcerts/'*.crt '$homedir_base/ssl/certs/'*.crt 2>/dev/null || true" 2>/dev/null | while IFS= read -r f; do
[[ -z "$f" ]] && continue
basename "$f" .crt
done | sort -u
}
restore_cron() {
local user="$1"
local timestamp="${2:-}"
local temp_dir="${TEMP_DIR:-$DEFAULT_TEMP_DIR}"
local ts; ts=$(resolve_snapshot_timestamp "$user" "$timestamp") || return 1
local snap_dir; snap_dir=$(get_snapshot_dir "$user")
local snap_path="$snap_dir/$ts"
local rsync_ssh; rsync_ssh=$(build_rsync_ssh_cmd)
# Detect old format vs new format
local pkgacct_base="$snap_path"
if remote_exec "test -d '$snap_path/pkgacct'" 2>/dev/null; then
pkgacct_base="$snap_path/pkgacct"
fi
local restore_dir="$temp_dir/restore-cron-$user"
mkdir -p "$restore_dir" || {
log_error "Failed to create restore temp directory"
return 1
}
log_info "Restoring cron jobs for $user from snapshot $ts"
# Download cron/ directory from snapshot
if _is_rclone_mode; then
local snap_subpath="accounts/${user}/snapshots/${ts}"
local pkgacct_sub; pkgacct_sub=$(_detect_pkgacct_base "" "$snap_subpath")
if ! rclone_from_remote "${pkgacct_sub}/cron" "$restore_dir/cron"; then
log_error "No cron data found in snapshot for $user"
rm -rf "$restore_dir"
return 1
fi
else
if ! rsync -aHAX --rsync-path="rsync --fake-super" \
-e "$rsync_ssh" \
"${REMOTE_USER}@${REMOTE_HOST}:${pkgacct_base}/cron/" \
"$restore_dir/cron/" 2>/dev/null; then
log_error "No cron data found in snapshot for $user"
rm -rf "$restore_dir"
return 1
fi
fi
# Find the crontab file
local crontab_file
crontab_file=$(find "$restore_dir/cron" -type f | head -1)
if [[ -z "$crontab_file" ]]; then
log_warn "Cron directory exists but no crontab file found for $user"
rm -rf "$restore_dir"
return 0
fi
# Restore cron jobs via cpapi2 (registers with cPanel's cron manager)
log_info "Restoring cron jobs via cPanel API..."
# Remove existing cron entries via cpapi2
local existing_lines; existing_lines=$(cpapi2 --user="$user" Cron listcron 2>&1)
if [[ $? -ne 0 ]]; then
log_error "cpapi2 Cron listcron failed for $user: $existing_lines"
rm -rf "$restore_dir"
return 1
fi
local linekeys; linekeys=$(echo "$existing_lines" | grep -oP 'linekey:\s*\K\S+') || true
if [[ -n "$linekeys" ]]; then
while IFS= read -r linekey; do
[[ -z "$linekey" ]] && continue
cpapi2 --user="$user" Cron remove_line linekey="$linekey" 2>/dev/null || true
done <<< "$linekeys"
fi
# Parse each cron line and add via cpapi2
while IFS= read -r line; do
# Skip empty lines, comments, and environment variables (KEY=value)
[[ -z "$line" || "$line" =~ ^[[:space:]]*# || "$line" =~ ^[[:space:]]*[A-Za-z_]+= ]] && continue
# Split into 5 cron fields + command
local minute hour day month weekday command
read -r minute hour day month weekday command <<< "$line"
[[ -z "$command" ]] && continue
local add_out; add_out=$(cpapi2 --user="$user" Cron add_line \
minute="$minute" hour="$hour" day="$day" \
month="$month" weekday="$weekday" command="$command" 2>&1) || true
if ! echo "$add_out" | grep -q 'status: 1'; then
log_error "cpapi2 Cron add_line failed for '$line': $add_out"
rm -rf "$restore_dir"
return 1
fi
done < "$crontab_file"
rm -rf "$restore_dir"
log_info "Cron restore completed for $user"
return 0
}
restore_dbusers() {
local user="$1"
local specific_dbuser="${2:-}"
local timestamp="${3:-}"
local temp_dir="${TEMP_DIR:-$DEFAULT_TEMP_DIR}"
local ts; ts=$(resolve_snapshot_timestamp "$user" "$timestamp") || return 1
local snap_dir; snap_dir=$(get_snapshot_dir "$user")
local snap_path="$snap_dir/$ts"
local rsync_ssh; rsync_ssh=$(build_rsync_ssh_cmd)
# Detect old format vs new format
local pkgacct_base="$snap_path"
if remote_exec "test -d '$snap_path/pkgacct'" 2>/dev/null; then
pkgacct_base="$snap_path/pkgacct"
fi
local restore_dir="$temp_dir/restore-dbusers-$user"
mkdir -p "$restore_dir" || {
log_error "Failed to create restore temp directory"
return 1
}
log_info "Restoring DB users & grants for $user from snapshot $ts"
# Download mysql.sql (grant statements)
local local_grants="$restore_dir/mysql.sql"
if _is_rclone_mode; then
local snap_subpath="accounts/${user}/snapshots/${ts}"
local pkgacct_sub; pkgacct_sub=$(_detect_pkgacct_base "" "$snap_subpath")
if ! rclone_from_remote "${pkgacct_sub}/mysql.sql" "$restore_dir"; then
log_error "No mysql.sql (grants) found in snapshot for $user"
rm -rf "$restore_dir"
return 1
fi
else
local remote_grants="${pkgacct_base}/mysql.sql"
if ! rsync -aHAX --rsync-path="rsync --fake-super" \
-e "$rsync_ssh" \
"${REMOTE_USER}@${REMOTE_HOST}:${remote_grants}" \
"$local_grants" 2>/dev/null; then
log_error "No mysql.sql (grants) found in snapshot for $user"
rm -rf "$restore_dir"
return 1
fi
fi
# Get cPanel DB prefix for this account
local db_prefix
db_prefix=$(uapi --user="$user" Mysql get_restrictions 2>/dev/null | grep -oP '^\s+prefix:\s+\K\S+') || db_prefix=""
log_info "Registering DB users & privileges (cPanel prefix: ${db_prefix:-none})..."
# Extract unique db users from GRANT statements
local grant_users; grant_users=$(grep -oP "TO\s+'?\K[^'@]+(?='@)" "$local_grants" | sort -u) || true
if [[ -n "$grant_users" ]]; then
while IFS= read -r dbuser; do
[[ -z "$dbuser" ]] && continue
# Skip account default DB user — it always exists with the account
[[ "$dbuser" == "$user" ]] && continue
# If a specific DB user was requested, skip all others
if [[ -n "$specific_dbuser" && "$dbuser" != "$specific_dbuser" ]]; then
continue
fi
if [[ -n "$db_prefix" && "$dbuser" != "${db_prefix}"* ]]; then
log_warn "Skipping DB user $dbuser — does not match cPanel prefix '${db_prefix}'"
continue
fi
# Create user via UAPI
local uapi_out; uapi_out=$(uapi --user="$user" Mysql create_user name="$dbuser" password="$(openssl rand -base64 24)" 2>&1) || true
if echo "$uapi_out" | grep -q 'status: 1'; then
log_debug "Created DB user $dbuser via UAPI"
elif echo "$uapi_out" | grep -qi 'exists'; then
log_debug "DB user $dbuser already exists"
else
log_error "UAPI create_user failed for $dbuser: $uapi_out"
rm -rf "$restore_dir"
return 1
fi
# Extract databases this user has grants on and set privileges via UAPI
# Strip backslash escapes (GRANT syntax uses \_ for literal underscores)
local dbs; dbs=$(grep -i "TO '$dbuser'@" "$local_grants" | grep -oP "ON\s+\`\K[^\`]+" | sed 's/\\//g' | sort -u) || true
if [[ -n "$dbs" ]]; then
while IFS= read -r dbname; do
[[ -z "$dbname" || "$dbname" == "*" ]] && continue
if [[ -n "$db_prefix" && "$dbname" != "${db_prefix}"* ]]; then
log_warn "Skipping privileges for $dbuser on $dbname — database does not match cPanel prefix"
continue
fi
local sp_out; sp_out=$(uapi --user="$user" Mysql set_privileges_on_database user="$dbuser" database="$dbname" privileges=ALL 2>&1) || true
if ! echo "$sp_out" | grep -q 'status: 1'; then
log_error "UAPI set_privileges_on_database failed for $dbuser on $dbname: $sp_out"
rm -rf "$restore_dir"
return 1
fi
log_debug "Set privileges for $dbuser on $dbname via UAPI"
done <<< "$dbs"
fi
done <<< "$grant_users"
fi
rm -rf "$restore_dir"
log_info "DB users & grants restore completed for $user"
return 0
}
restore_cpconfig() {
local user="$1"
local timestamp="${2:-}"
local temp_dir="${TEMP_DIR:-$DEFAULT_TEMP_DIR}"
local ts; ts=$(resolve_snapshot_timestamp "$user" "$timestamp") || return 1
local snap_dir; snap_dir=$(get_snapshot_dir "$user")
local snap_path="$snap_dir/$ts"
local rsync_ssh; rsync_ssh=$(build_rsync_ssh_cmd)
# Detect old format vs new format
local pkgacct_base="$snap_path"
if remote_exec "test -d '$snap_path/pkgacct'" 2>/dev/null; then
pkgacct_base="$snap_path/pkgacct"
fi
local restore_dir="$temp_dir/restore-cpconfig-$user"
mkdir -p "$restore_dir" || {
log_error "Failed to create restore temp directory"
return 1
}
log_info "Restoring panel config for $user from snapshot $ts"
# Download cp/ directory
if _is_rclone_mode; then
local snap_subpath="accounts/${user}/snapshots/${ts}"
local pkgacct_sub; pkgacct_sub=$(_detect_pkgacct_base "" "$snap_subpath")
if ! rclone_from_remote "${pkgacct_sub}/cp" "$restore_dir/cp"; then
log_error "No cPanel config data found in snapshot for $user"
rm -rf "$restore_dir"
return 1
fi
else
if ! rsync -aHAX --rsync-path="rsync --fake-super" \
-e "$rsync_ssh" \
"${REMOTE_USER}@${REMOTE_HOST}:${pkgacct_base}/cp/" \
"$restore_dir/cp/" 2>/dev/null; then
log_error "No cPanel config data found in snapshot for $user"
rm -rf "$restore_dir"
return 1
fi
fi
# Copy to cPanel user config location
local cp_user_file="$restore_dir/cp/$user"
if [[ -f "$cp_user_file" ]]; then
cp -f "$cp_user_file" "/var/cpanel/users/$user" || {
log_error "Failed to copy cPanel config for $user"
rm -rf "$restore_dir"
return 1
}
else
log_warn "No user config file found in cp/ directory for $user"
fi
# Rebuild cPanel caches
log_info "Rebuilding cPanel user domain caches..."
/usr/local/cpanel/scripts/updateuserdomains 2>/dev/null || log_warn "updateuserdomains returned non-zero"
rm -rf "$restore_dir"
log_info "Panel config restore completed for $user"
return 0
}
restore_domains() {
local user="$1"
local specific_domain="${2:-}"
local timestamp="${3:-}"
local temp_dir="${TEMP_DIR:-$DEFAULT_TEMP_DIR}"
local ts; ts=$(resolve_snapshot_timestamp "$user" "$timestamp") || return 1
local snap_dir; snap_dir=$(get_snapshot_dir "$user")
local snap_path="$snap_dir/$ts"
local rsync_ssh; rsync_ssh=$(build_rsync_ssh_cmd)
# Detect old format vs new format
local pkgacct_base="$snap_path"
if remote_exec "test -d '$snap_path/pkgacct'" 2>/dev/null; then
pkgacct_base="$snap_path/pkgacct"
fi
local restore_dir="$temp_dir/restore-domains-$user"
mkdir -p "$restore_dir" || {
log_error "Failed to create restore temp directory"
return 1
}
log_info "Restoring domains & DNS for $user from snapshot $ts"
# Download userdata/, addons, dnszones/
if _is_rclone_mode; then
local snap_subpath="accounts/${user}/snapshots/${ts}"
local pkgacct_sub; pkgacct_sub=$(_detect_pkgacct_base "" "$snap_subpath")
rclone_from_remote "${pkgacct_sub}/userdata" "$restore_dir/userdata" 2>/dev/null || log_warn "No userdata found in snapshot"
rclone_from_remote "${pkgacct_sub}/addons" "$restore_dir" 2>/dev/null || log_debug "No addons file in snapshot"
rclone_from_remote "${pkgacct_sub}/dnszones" "$restore_dir/dnszones" 2>/dev/null || log_warn "No DNS zones found in snapshot"
else
rsync -aHAX --rsync-path="rsync --fake-super" \
-e "$rsync_ssh" \
"${REMOTE_USER}@${REMOTE_HOST}:${pkgacct_base}/userdata/" \
"$restore_dir/userdata/" 2>/dev/null || log_warn "No userdata found in snapshot"
rsync -aHAX --rsync-path="rsync --fake-super" \
-e "$rsync_ssh" \
"${REMOTE_USER}@${REMOTE_HOST}:${pkgacct_base}/addons" \
"$restore_dir/addons" 2>/dev/null || log_debug "No addons file in snapshot"
rsync -aHAX --rsync-path="rsync --fake-super" \
-e "$rsync_ssh" \
"${REMOTE_USER}@${REMOTE_HOST}:${pkgacct_base}/dnszones/" \
"$restore_dir/dnszones/" 2>/dev/null || log_warn "No DNS zones found in snapshot"
fi
# Restore userdata
if [[ -d "$restore_dir/userdata" ]]; then
log_info "Restoring userdata for $user..."
mkdir -p "/var/cpanel/userdata/$user"
cp -af "$restore_dir/userdata/"* "/var/cpanel/userdata/$user/" || {
log_error "Failed to restore userdata for $user"
rm -rf "$restore_dir"
return 1
}
fi
# Rebuild cPanel caches and Apache config
log_info "Rebuilding cPanel domain caches..."
/usr/local/cpanel/scripts/updateuserdomains 2>/dev/null || log_warn "updateuserdomains returned non-zero"
/usr/local/cpanel/scripts/rebuildhttpdconf 2>/dev/null || log_warn "rebuildhttpdconf returned non-zero"
# Restore DNS zones via whmapi1
if [[ -d "$restore_dir/dnszones" ]]; then
log_info "Restoring DNS zones via whmapi1..."
for zone_file in "$restore_dir/dnszones"/*.db; do
[[ -f "$zone_file" ]] || continue
local zone_name; zone_name=$(basename "$zone_file" .db)
[[ -z "$zone_name" ]] && continue
# If a specific domain was requested, skip all others
if [[ -n "$specific_domain" && "$zone_name" != "$specific_domain" ]]; then
continue
fi
# Extract main IP from the zone's A record for adddns
local zone_ip; zone_ip=$(grep -m1 -P "^${zone_name}\.\s+\d+\s+(IN\s+)?A\s+" "$zone_file" \
| grep -oP 'A\s+\K[0-9.]+') || zone_ip=""
# Ensure zone exists in cPanel
local adddns_out; adddns_out=$(whmapi1 adddns domain="$zone_name" ip="$zone_ip" trueowner="$user" 2>&1) || true
if echo "$adddns_out" | grep -q 'result: 1'; then
log_debug "Created DNS zone for $zone_name via whmapi1"
elif echo "$adddns_out" | grep -qi 'already exists\|exists on\|already has\|has dns'; then
log_debug "DNS zone $zone_name already exists"
else
log_warn "whmapi1 adddns failed for $zone_name: $(echo "$adddns_out" | grep -oP 'reason:\s*\K.*' | head -1)"
fi
# Parse zone file and add records via whmapi1 addzonerecord
# Skip SOA, NS, and comment lines — cPanel manages those
local add_ok=0 add_fail=0
while IFS= read -r line; do
[[ -z "$line" || "$line" =~ ^[[:space:]]*\; || "$line" =~ ^\$ ]] && continue
# Match: name [ttl] [IN] type data
local rec_name rec_ttl rec_type rec_data
if [[ "$line" =~ ^([^[:space:]]+)[[:space:]]+([0-9]+)[[:space:]]+(IN[[:space:]]+)?([A-Z]+)[[:space:]]+(.+)$ ]]; then
rec_name="${BASH_REMATCH[1]}"
rec_ttl="${BASH_REMATCH[2]}"
rec_type="${BASH_REMATCH[4]}"
rec_data="${BASH_REMATCH[5]}"
elif [[ "$line" =~ ^([^[:space:]]+)[[:space:]]+(IN[[:space:]]+)?([A-Z]+)[[:space:]]+(.+)$ ]]; then
rec_name="${BASH_REMATCH[1]}"
rec_ttl="14400"
rec_type="${BASH_REMATCH[3]}"
rec_data="${BASH_REMATCH[4]}"
else
continue
fi
# Skip SOA, NS — cPanel manages these
[[ "$rec_type" == "SOA" || "$rec_type" == "NS" ]] && continue
# Strip trailing dot from name
rec_name="${rec_name%.}"
# Strip trailing comments and whitespace
rec_data="${rec_data%%;*}"
rec_data="${rec_data%"${rec_data##*[![:space:]]}"}"
# Strip trailing dots from data (FQDN format)
rec_data="${rec_data%.}"
local rec_out=""
case "$rec_type" in
A|AAAA)
rec_out=$(whmapi1 addzonerecord domain="$zone_name" name="$rec_name" type="$rec_type" address="$rec_data" ttl="$rec_ttl" 2>&1) || true
;;
CNAME)
rec_out=$(whmapi1 addzonerecord domain="$zone_name" name="$rec_name" type=CNAME cname="$rec_data" ttl="$rec_ttl" 2>&1) || true
;;
MX)
local mx_prio="${rec_data%%[[:space:]]*}"
local mx_host="${rec_data#*[[:space:]]}"
mx_host="${mx_host%.}"
rec_out=$(whmapi1 addzonerecord domain="$zone_name" name="$rec_name" type=MX exchange="$mx_host" preference="$mx_prio" ttl="$rec_ttl" 2>&1) || true
;;
TXT)
# Strip surrounding quotes
rec_data="${rec_data#\"}"
rec_data="${rec_data%\"}"
rec_out=$(whmapi1 addzonerecord domain="$zone_name" name="$rec_name" type=TXT txtdata="$rec_data" ttl="$rec_ttl" 2>&1) || true
;;
SRV)
# SRV: priority weight port target
local srv_prio srv_weight srv_port srv_target
read -r srv_prio srv_weight srv_port srv_target <<< "$rec_data"
srv_target="${srv_target%.}"
rec_out=$(whmapi1 addzonerecord domain="$zone_name" name="$rec_name" type=SRV priority="$srv_prio" weight="$srv_weight" port="$srv_port" target="$srv_target" ttl="$rec_ttl" 2>&1) || true
;;
CAA)
rec_out=$(whmapi1 addzonerecord domain="$zone_name" name="$rec_name" type=CAA record="$rec_data" ttl="$rec_ttl" 2>&1) || true
;;
*)
log_debug "Skipping unsupported record type $rec_type for $rec_name"
continue
;;
esac
if echo "$rec_out" | grep -q 'result: 1'; then
((add_ok++)) || true
else
local reason; reason=$(echo "$rec_out" | grep -oP 'reason:\s*\K.*' | head -1)
if echo "$reason" | grep -qi 'already has\|duplicate'; then
log_debug "Record $rec_name $rec_type already exists in $zone_name"
else
log_debug "addzonerecord failed for $rec_name $rec_type in $zone_name: $reason"
((add_fail++)) || true
fi
fi
done < "$zone_file"
log_info "Restored DNS zone $zone_name via whmapi1 ($add_ok records added, $add_fail failed)"
done
fi
rm -rf "$restore_dir"
log_info "Domains & DNS restore completed for $user"
return 0
}
restore_ssl() {
local user="$1"
local specific_cert="${2:-}"
local timestamp="${3:-}"
local temp_dir="${TEMP_DIR:-$DEFAULT_TEMP_DIR}"
local ts; ts=$(resolve_snapshot_timestamp "$user" "$timestamp") || return 1
local snap_dir; snap_dir=$(get_snapshot_dir "$user")
local snap_path="$snap_dir/$ts"
local rsync_ssh; rsync_ssh=$(build_rsync_ssh_cmd)
# Detect old format vs new format
local pkgacct_base="$snap_path"
if remote_exec "test -d '$snap_path/pkgacct'" 2>/dev/null; then
pkgacct_base="$snap_path/pkgacct"
fi
local restore_dir="$temp_dir/restore-ssl-$user"
mkdir -p "$restore_dir" || {
log_error "Failed to create restore temp directory"
return 1
}
log_info "Restoring SSL certificates for $user from snapshot $ts"
local found_any=false
# Download ssl/, sslcerts/, autossl.json
if _is_rclone_mode; then
local snap_subpath="accounts/${user}/snapshots/${ts}"
local pkgacct_sub; pkgacct_sub=$(_detect_pkgacct_base "" "$snap_subpath")
rclone_from_remote "${pkgacct_sub}/ssl" "$restore_dir/ssl" 2>/dev/null && found_any=true
rclone_from_remote "${pkgacct_sub}/sslcerts" "$restore_dir/sslcerts" 2>/dev/null && found_any=true
rclone_from_remote "${pkgacct_sub}/autossl.json" "$restore_dir" 2>/dev/null && found_any=true
rclone_from_remote "${snap_subpath}/homedir/ssl" "$restore_dir/homedir-ssl" 2>/dev/null && found_any=true
else
if rsync -aHAX --rsync-path="rsync --fake-super" \
-e "$rsync_ssh" \
"${REMOTE_USER}@${REMOTE_HOST}:${pkgacct_base}/ssl/" \
"$restore_dir/ssl/" 2>/dev/null; then
found_any=true
fi
if rsync -aHAX --rsync-path="rsync --fake-super" \
-e "$rsync_ssh" \
"${REMOTE_USER}@${REMOTE_HOST}:${pkgacct_base}/sslcerts/" \
"$restore_dir/sslcerts/" 2>/dev/null; then
found_any=true
fi
if rsync -aHAX --rsync-path="rsync --fake-super" \
-e "$rsync_ssh" \
"${REMOTE_USER}@${REMOTE_HOST}:${pkgacct_base}/autossl.json" \
"$restore_dir/autossl.json" 2>/dev/null; then
found_any=true
fi
# Also download homedir/ssl/ (user-managed certs)
local homedir_base="${snap_path}/homedir"
if rsync -aHAX --rsync-path="rsync --fake-super" \
-e "$rsync_ssh" \
"${REMOTE_USER}@${REMOTE_HOST}:${homedir_base}/ssl/" \
"$restore_dir/homedir-ssl/" 2>/dev/null; then
found_any=true
fi
fi
if [[ "$found_any" == "false" ]]; then
log_error "No SSL data found in snapshot for $user"
rm -rf "$restore_dir"
return 1
fi
# Install certs on domains via whmapi1 installssl (no file copies)
log_info "Installing SSL certificates via whmapi1..."
local installed_count=0
# Find cert files in ssl/ and sslcerts/ directories
local cert_dirs=()
[[ -d "$restore_dir/ssl/certs" ]] && cert_dirs+=("$restore_dir/ssl/certs")
[[ -d "$restore_dir/sslcerts" ]] && cert_dirs+=("$restore_dir/sslcerts")
[[ -d "$restore_dir/ssl/installed/certs" ]] && cert_dirs+=("$restore_dir/ssl/installed/certs")
[[ -d "$restore_dir/homedir-ssl/certs" ]] && cert_dirs+=("$restore_dir/homedir-ssl/certs")
for cert_search_dir in "${cert_dirs[@]}"; do
for cert_file in "$cert_search_dir"/*.crt "$cert_search_dir"/*.pem; do
[[ -f "$cert_file" ]] || continue
# If a specific cert was requested, skip all others
if [[ -n "$specific_cert" ]]; then
local cert_base; cert_base=$(basename "$cert_file" .crt)
[[ "$cert_base" == "$cert_file" ]] && cert_base=$(basename "$cert_file" .pem)
if [[ "$cert_base" != "$specific_cert" ]]; then
continue
fi
fi
# Extract domain from certificate
local cert_domain; cert_domain=$(openssl x509 -in "$cert_file" -noout -subject 2>/dev/null \
| grep -oP 'CN\s*=\s*\K[^\s/]+') || continue
[[ -z "$cert_domain" ]] && continue
# Read cert content
local cert_content; cert_content=$(cat "$cert_file")
# Find matching key file
local key_content=""
local cert_basename; cert_basename=$(basename "$cert_file")
local key_basename="${cert_basename%.*}.key"
# Search for key in ssl/keys/ directory
for key_search_dir in "$restore_dir/ssl/keys" "$restore_dir/ssl/installed/keys" "$restore_dir/homedir-ssl/keys"; do
if [[ -f "$key_search_dir/$key_basename" ]]; then
key_content=$(cat "$key_search_dir/$key_basename")
break
fi
done
# If no exact match, try to find any key that matches the cert
if [[ -z "$key_content" ]]; then
local cert_modulus; cert_modulus=$(openssl x509 -in "$cert_file" -noout -modulus 2>/dev/null) || continue
for key_search_dir in "$restore_dir/ssl/keys" "$restore_dir/ssl/installed/keys" "$restore_dir/homedir-ssl/keys"; do
[[ -d "$key_search_dir" ]] || continue
for key_file in "$key_search_dir"/*.key; do
[[ -f "$key_file" ]] || continue
local key_modulus; key_modulus=$(openssl rsa -in "$key_file" -noout -modulus 2>/dev/null) || continue
if [[ "$cert_modulus" == "$key_modulus" ]]; then
key_content=$(cat "$key_file")
break 2
fi
done
done
fi
[[ -z "$key_content" ]] && { log_debug "No matching key found for cert $cert_domain, skipping"; continue; }
# Find CA bundle if available
local cab_content=""
for cab_search_dir in "$restore_dir/ssl/cabundles" "$restore_dir/ssl/installed/cabundles" "$restore_dir/homedir-ssl/cabundles"; do
local cab_file="$cab_search_dir/${cert_basename%.*}.cabundle"
if [[ -f "$cab_file" ]]; then
cab_content=$(cat "$cab_file")
break
fi
done
# Install via whmapi1
local install_out
if [[ -n "$cab_content" ]]; then
install_out=$(whmapi1 installssl domain="$cert_domain" crt="$cert_content" key="$key_content" cab="$cab_content" 2>&1) || true
else
install_out=$(whmapi1 installssl domain="$cert_domain" crt="$cert_content" key="$key_content" 2>&1) || true
fi
if echo "$install_out" | grep -q 'result: 1'; then
log_info "Installed SSL cert for $cert_domain via whmapi1"
((installed_count++)) || true
else
log_warn "whmapi1 installssl failed for $cert_domain: $(echo "$install_out" | grep -oP 'reason:\s*\K.*' | head -1)"
fi
done
done
if (( installed_count == 0 )); then
log_warn "No SSL certificates were installed via whmapi1"
else
log_info "Installed $installed_count SSL certificate(s) via whmapi1"
fi
rm -rf "$restore_dir"
log_info "SSL restore completed for $user"
return 0
}
restore_server() {
local timestamp="${1:-}"
log_info "Starting full server rebuild..."
local accounts; accounts=$(list_remote_accounts)
if [[ -z "$accounts" ]]; then
log_error "No accounts found on remote for restore"
return 1
fi
local total=0 succeeded=0 failed=0
local failed_accounts=""
while IFS= read -r user; do
[[ -z "$user" ]] && continue
((total++)) || true
log_info "--- Restoring account $user ($total) ---"
if restore_full_account "$user" "$timestamp" "merge"; then
((succeeded++)) || true
else
((failed++)) || true
failed_accounts+=" - $user"$'\n'
fi
done <<< "$accounts"
echo ""
echo "============================================"
echo "Server Restore Summary"
echo "============================================"
echo "Total accounts: $total"
echo "Succeeded: $succeeded"
echo "Failed: $failed"
if [[ -n "$failed_accounts" ]]; then
echo ""
echo "Failed accounts:"
echo "$failed_accounts"
fi
echo "============================================"
(( failed > 0 )) && return 1
return 0
}