Files
gniza4cp/lib/restore.sh
shuki 05db1f2340 Fix restore: use whmapi1 removeacct instead of /scripts/removeacct
The /scripts/removeacct script was failing with "You do not have
permission to remove that account" even when running as root. Switch
to whmapi1 removeacct which uses the WHM API with proper root
authentication context. Also check the whmapi1 result field since
whmapi1 returns exit code 0 even on logical failures.

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

1436 lines
55 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
export SSHPASS="$REMOTE_PASSWORD"
sshpass -e 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 terminate="${3:-}"
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
# Terminate existing account if requested
if [[ "$terminate" == "true" ]] && account_exists "$user"; then
log_info "Terminating existing account $user before restore..."
local removeacct_output
if ! removeacct_output=$(whmapi1 removeacct user="$user" 2>&1); then
log_error "Failed to terminate account $user"
log_debug "removeacct output: $removeacct_output"
rm -rf "$restore_dir"
return 1
fi
log_debug "removeacct output: $removeacct_output"
# Check whmapi1 result field for failure
if echo "$removeacct_output" | grep -q 'result: 0'; then
local reason
reason=$(echo "$removeacct_output" | grep 'reason:' | sed 's/.*reason: //')
log_error "Failed to terminate account $user: $reason"
rm -rf "$restore_dir"
return 1
fi
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" "" ""; 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
}