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>
1436 lines
55 KiB
Bash
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
|
|
}
|