#!/bin/bash set -Eeuo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PLUGIN_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" COMMON_FILE="$PLUGIN_DIR/scripts/setup/mysql_common.sh" SETTINGS_FILE="${DA_ALT_MYSQL_SETTINGS_FILE:-$PLUGIN_DIR/plugin-settings.conf}" LOG_FILE="${DA_ALT_MYSQL_RESTORE_LOG:-$PLUGIN_DIR/data/logs/da-restore.log}" DA_USER="" PAYLOAD_DIR="" OVERWRITE_POLICY="" RESTORE_ROLLBACK_FILE="" RESTORE_ROLLBACK_CREATED=0 mkdir -p "$(dirname "$LOG_FILE")" log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG_FILE" } die() { log "ERROR: $*" printf 'alt-mysql restore payload: %s\n' "$*" >&2 exit 1 } usage() { cat >&2 <<'EOF' Usage: alt_mysql_restore_payload.sh --user DA_USER --payload /path/to/backup/alt_mysql [--overwrite allow|deny] EOF exit 2 } while [ "$#" -gt 0 ]; do case "$1" in --user) DA_USER="${2:-}"; shift 2 ;; --payload) PAYLOAD_DIR="${2:-}"; shift 2 ;; --overwrite) OVERWRITE_POLICY="${2:-}"; shift 2 ;; *) usage ;; esac done [ -n "$DA_USER" ] || usage [ -n "$PAYLOAD_DIR" ] || usage [[ "$DA_USER" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]*$ ]] || die "unsafe DirectAdmin username: $DA_USER" [ -d "$PAYLOAD_DIR" ] || die "payload directory does not exist: $PAYLOAD_DIR" [ -r "$COMMON_FILE" ] || die "missing helper: $COMMON_FILE" # shellcheck source=../setup/mysql_common.sh source "$COMMON_FILE" mysql_load_plugin_settings_file "$SETTINGS_FILE" read_plugin_setting() { local key="$1" local default="$2" local line value [ -r "$SETTINGS_FILE" ] || { printf '%s\n' "$default" return 0 } line="$(grep -E "^${key}=" "$SETTINGS_FILE" | tail -n 1 || true)" [ -n "$line" ] || { printf '%s\n' "$default" return 0 } value="${line#*=}" value="${value%%#*}" value="${value%\"}" value="${value#\"}" value="${value%\'}" value="${value#\'}" value="$(printf '%s' "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" printf '%s\n' "${value:-$default}" } sha256_file() { local file="$1" if command -v sha256sum >/dev/null 2>&1; then sha256sum "$file" | awk '{print $1}' return 0 fi if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$file" | awk '{print $1}' return 0 fi die "missing sha256sum/shasum" } require_php_cli() { command -v php >/dev/null 2>&1 || die "php CLI is required to parse manifest.json" } manifest_database_rows() { local manifest="$1" php -r ' $manifest = $argv[1]; $data = json_decode(file_get_contents($manifest), true); if (!is_array($data) || ($data["format"] ?? "") !== "da-alt-mysql-v1") { fwrite(STDERR, "invalid manifest\n"); exit(3); } foreach (($data["databases"] ?? []) as $db) { $name = (string)($db["name"] ?? ""); $file = (string)($db["file"] ?? ""); $sha = (string)($db["sha256"] ?? ""); if ($name === "" || $file === "" || $sha === "") { fwrite(STDERR, "invalid database entry\n"); exit(4); } echo $name, "\t", $file, "\t", $sha, "\n"; } ' "$manifest" } safe_database_name() { local db="$1" [[ "$db" =~ ^[A-Za-z0-9_]+$ ]] || die "unsafe database name in payload: $db" printf '%s\n' "$db" } database_allowed_for_user() { local db="$1" [[ "$db" == "${DA_USER}_"* ]] } import_gzip_sql() { local database="$1" local file="$2" if [[ "$file" == *.gz ]]; then gunzip -c "$file" | mysql_exec "$database" return "${PIPESTATUS[1]}" fi mysql_exec "$database" < "$file" } cleanup_restore_rollback() { if [ -n "${RESTORE_ROLLBACK_FILE:-}" ] && [ -f "$RESTORE_ROLLBACK_FILE" ]; then rm -f "$RESTORE_ROLLBACK_FILE" fi RESTORE_ROLLBACK_FILE="" RESTORE_ROLLBACK_CREATED=0 } backup_alt_database_for_rollback() { local db="$1" cleanup_restore_rollback RESTORE_ROLLBACK_FILE="$(mktemp)" if [ -z "$(mysql_exec mysql -Nse "SELECT 1 FROM information_schema.SCHEMATA WHERE SCHEMA_NAME='$(mysql_escape_literal "$db")' LIMIT 1" 2>>"$LOG_FILE" || true)" ]; then cleanup_restore_rollback return 0 fi log "creating rollback dump: $db" if mysqldump_exec \ --single-transaction \ --quick \ --skip-lock-tables \ --routines \ --triggers \ --events \ --default-character-set=utf8mb4 \ "$db" > "$RESTORE_ROLLBACK_FILE" 2>>"$LOG_FILE"; then RESTORE_ROLLBACK_CREATED=1 return 0 fi cleanup_restore_rollback return 1 } restore_alt_database_from_rollback() { local db="$1" if [ "$RESTORE_ROLLBACK_CREATED" -ne 1 ] || [ -z "${RESTORE_ROLLBACK_FILE:-}" ] || [ ! -r "$RESTORE_ROLLBACK_FILE" ]; then return 0 fi log "restoring rollback dump: $db" mysql_exec mysql -e "DROP DATABASE IF EXISTS $(mysql_quote_identifier "$db");" >> "$LOG_FILE" 2>&1 || true mysql_exec mysql -e "CREATE DATABASE $(mysql_quote_identifier "$db") CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" >> "$LOG_FILE" 2>&1 || true mysql_exec "$db" < "$RESTORE_ROLLBACK_FILE" >> "$LOG_FILE" 2>&1 || { log "ERROR: rollback restore failed for $db" return 1 } } trap cleanup_restore_rollback INT TERM EXIT metadata_cleanup_for_user() { local escaped_user escaped_user="$(mysql_escape_literal "$DA_USER")" mysql_exec mysql -e "DELETE FROM da_plugin_access_hosts WHERE da_user='${escaped_user}' OR role_name LIKE '${escaped_user}\\_%';" mysql_exec mysql -e "DELETE FROM da_plugin_grant_profiles WHERE db_name LIKE '${escaped_user}\\_%' OR role_name LIKE '${escaped_user}\\_%';" mysql_exec mysql -e "DELETE FROM da_plugin_database_owners WHERE db_name LIKE '${escaped_user}\\_%' OR role_name LIKE '${escaped_user}\\_%';" } verify_database_exists() { local db="$1" local escaped_db escaped_db="$(mysql_escape_literal "$db")" [ -n "$(mysql_exec mysql -Nse "SELECT 1 FROM information_schema.SCHEMATA WHERE SCHEMA_NAME='${escaped_db}' LIMIT 1")" ] \ || die "database was not restored: $db" } sync_remote_hosts_if_enabled() { local allow sync_script allow="$(read_plugin_setting allow_remote_hosts 0)" [ "$allow" = "1" ] || return 0 sync_script="$(read_plugin_setting MYSQL_PLUGIN_SUDO_SYNC_HOSTS /usr/local/directadmin/plugins/alt-mysql/scripts/setup/mysql_host_sync.sh)" if [ -x "$sync_script" ]; then "$sync_script" >> "$LOG_FILE" 2>&1 || log "WARNING: host sync failed after restore" fi } main() { local manifest users_file grants_file metadata_file db file rel_file expected_sha actual_sha overwrite manifest="$PAYLOAD_DIR/manifest.json" users_file="$PAYLOAD_DIR/grants/users.sql.gz" grants_file="$PAYLOAD_DIR/grants/grants.sql.gz" [ -r "$manifest" ] || die "missing manifest: $manifest" [ -r "$users_file" ] || die "missing users payload: $users_file" [ -r "$grants_file" ] || die "missing grants payload: $grants_file" require_php_cli mysql_load_alt_credentials mysql_ensure_metadata_schema overwrite="${OVERWRITE_POLICY:-$(read_plugin_setting DA_ALT_MYSQL_RESTORE_OVERWRITE allow)}" case "$overwrite" in allow|deny) ;; prompt) overwrite="deny" ;; *) overwrite="deny" ;; esac log "restore payload start: user=$DA_USER payload=$PAYLOAD_DIR overwrite=$overwrite" manifest_database_rows "$manifest" >/dev/null while IFS=$'\t' read -r db rel_file expected_sha; do db="$(safe_database_name "$db")" database_allowed_for_user "$db" || die "database $db does not match target user prefix $DA_USER" file="$PAYLOAD_DIR/$rel_file" [ -r "$file" ] || die "missing database dump: $file" actual_sha="$(sha256_file "$file")" [ "$actual_sha" = "$expected_sha" ] || die "checksum mismatch for $db" if [ "$overwrite" = "deny" ] && [ -n "$(mysql_exec mysql -Nse "SELECT 1 FROM information_schema.SCHEMATA WHERE SCHEMA_NAME='$(mysql_escape_literal "$db")' LIMIT 1")" ]; then die "target database exists and overwrite is denied: $db" fi done < <(manifest_database_rows "$manifest") log "restoring user definitions" import_gzip_sql mysql "$users_file" >> "$LOG_FILE" 2>&1 || die "cannot restore MySQL users from payload" metadata_cleanup_for_user while IFS=$'\t' read -r db rel_file expected_sha; do db="$(safe_database_name "$db")" file="$PAYLOAD_DIR/$rel_file" log "restoring database: $db" backup_alt_database_for_rollback "$db" || die "cannot create rollback dump before restoring database: $db" if ! mysql_exec mysql -e "DROP DATABASE IF EXISTS $(mysql_quote_identifier "$db");" >> "$LOG_FILE" 2>&1; then restore_alt_database_from_rollback "$db" || true die "cannot drop database before restore: $db" fi if ! mysql_exec mysql -e "CREATE DATABASE $(mysql_quote_identifier "$db") CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" >> "$LOG_FILE" 2>&1; then restore_alt_database_from_rollback "$db" || true die "cannot create database before restore: $db" fi if ! import_gzip_sql "$db" "$file" >> "$LOG_FILE" 2>&1; then restore_alt_database_from_rollback "$db" || true die "cannot import database dump: $db" fi cleanup_restore_rollback verify_database_exists "$db" done < <(manifest_database_rows "$manifest") for metadata_file in \ "$PAYLOAD_DIR/metadata/da_plugin_database_owners.sql.gz" \ "$PAYLOAD_DIR/metadata/da_plugin_grant_profiles.sql.gz" \ "$PAYLOAD_DIR/metadata/da_plugin_access_hosts.sql.gz"; do if [ -r "$metadata_file" ]; then log "restoring metadata: ${metadata_file##*/}" import_gzip_sql mysql "$metadata_file" >> "$LOG_FILE" 2>&1 || die "cannot restore metadata: $metadata_file" fi done log "restoring grants" import_gzip_sql mysql "$grants_file" >> "$LOG_FILE" 2>&1 || die "cannot restore grants from payload" mysql_exec mysql -e "FLUSH PRIVILEGES;" >> "$LOG_FILE" 2>&1 || true sync_remote_hosts_if_enabled log "restore payload completed: user=$DA_USER" } main "$@"