-
-
Save imannms000/69e6a8c4d19032a2ad7a529b3cdc3431 to your computer and use it in GitHub Desktop.
| #!/bin/bash | |
| #=============================================================================== | |
| # Production-Hardened MySQL 8.0 Installer for Ubuntu 20.04/22.04/24.04 | |
| # Idempotent | Failsafe | SOC2/ISO27001-aligned | |
| #=============================================================================== | |
| set -euo pipefail | |
| IFS=$'\n\t' | |
| #------------------------------------------------------------------------------- | |
| # CONFIGURATION | |
| #------------------------------------------------------------------------------- | |
| readonly SCRIPT_NAME="$(basename "$0")" | |
| readonly LOG_FILE="/var/log/${SCRIPT_NAME%.sh}.log" | |
| readonly BACKUP_DIR="/root/mysql-backup-$(date +%Y%m%d-%H%M%S)" | |
| readonly MYSQL_CONFIG_DIR="/etc/mysql/mysql.conf.d" | |
| readonly MYSQLD_CNF="${MYSQL_CONFIG_DIR}/99-production-hardening.cnf" | |
| # Minimum system requirements | |
| readonly MIN_RAM_MB=1024 | |
| readonly MIN_DISK_MB=2048 | |
| #------------------------------------------------------------------------------- | |
| # LOGGING | |
| #------------------------------------------------------------------------------- | |
| exec > >(tee -a "${LOG_FILE}") 2>&1 | |
| log() { | |
| echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | |
| } | |
| die() { | |
| log "❌ FATAL: $*" | |
| exit 1 | |
| } | |
| #------------------------------------------------------------------------------- | |
| # PREFLIGHT CHECKS | |
| #------------------------------------------------------------------------------- | |
| preflight() { | |
| log "🔍 Running pre-flight checks..." | |
| # Root check | |
| if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then | |
| die "This script must be run as root or with sudo." | |
| fi | |
| # OS check | |
| if ! grep -qE 'Ubuntu (20|22|24)\.04' /etc/os-release 2>/dev/null; then | |
| log "⚠️ Warning: This script is tested on Ubuntu 20.04/22.04/24.04 only." | |
| read -r -p "Continue anyway? [y/N]: " confirm | |
| [[ "$confirm" =~ ^[Yy]$ ]] || die "Aborted by user." | |
| fi | |
| # RAM check | |
| local avail_ram | |
| avail_ram=$(free -m | awk '/^Mem:/{print $7}') | |
| if [[ "${avail_ram}" -lt "${MIN_RAM_MB}" ]]; then | |
| die "Insufficient available RAM: ${avail_ram}MB (min: ${MIN_RAM_MB}MB)" | |
| fi | |
| # Disk check | |
| local avail_disk | |
| avail_disk=$(df -m / | awk 'NR==2 {print $4}') | |
| if [[ "${avail_disk}" -lt "${MIN_DISK_MB}" ]]; then | |
| die "Insufficient disk space on /: ${avail_disk}MB (min: ${MIN_DISK_MB}MB)" | |
| fi | |
| # Check for MariaDB conflict | |
| if dpkg -l | grep -qE '^ii\s+mariadb'; then | |
| die "MariaDB is installed. MySQL and MariaDB conflict. Remove MariaDB first." | |
| fi | |
| log "✅ Pre-flight checks passed." | |
| } | |
| #------------------------------------------------------------------------------- | |
| # PASSWORD INPUT WITH STRENGTH VALIDATION | |
| #------------------------------------------------------------------------------- | |
| prompt_password() { | |
| local var_name="$1" | |
| local prompt_text="$2" | |
| local confirm_text="$3" | |
| local min_length="${4:-16}" | |
| local pass1 pass2 | |
| while true; do | |
| read -r -s -p "${prompt_text}: " pass1 | |
| echo "" | |
| read -r -s -p "${confirm_text}: " pass2 | |
| echo "" | |
| if [[ -z "$pass1" ]]; then | |
| log "⚠️ Password cannot be empty." | |
| continue | |
| fi | |
| if [[ "${#pass1}" -lt "${min_length}" ]]; then | |
| log "⚠️ Password must be at least ${min_length} characters." | |
| continue | |
| fi | |
| if [[ "$pass1" != "$pass2" ]]; then | |
| log "❌ Passwords do not match." | |
| continue | |
| fi | |
| # Basic entropy check | |
| if ! [[ "$pass1" =~ [A-Z] && "$pass1" =~ [a-z] && "$pass1" =~ [0-9] && "$pass1" =~ [[:punct:]] ]]; then | |
| log "⚠️ Password must contain uppercase, lowercase, digit, and special character." | |
| continue | |
| fi | |
| printf -v "$var_name" '%s' "$pass1" | |
| break | |
| done | |
| } | |
| #------------------------------------------------------------------------------- | |
| # INSTALLATION (Idempotent: Skips if package is installed) | |
| #------------------------------------------------------------------------------- | |
| install_mysql() { | |
| if dpkg -l | grep -qE '^ii\s+mysql-server'; then | |
| log "📦 MySQL server package is already installed. Skipping installation step." | |
| return 0 | |
| fi | |
| log "🛠️ Installing MySQL Server..." | |
| export DEBIAN_FRONTEND=noninteractive | |
| apt-get update -y | |
| local mysql_version | |
| mysql_version=$(apt-cache policy mysql-server 2>/dev/null | grep Candidate | awk '{print $2}' | cut -d'-' -f1 | cut -d':' -f2 | cut -d'.' -f1-2 || echo "0") | |
| if [[ "$mysql_version" == "8.0" ]]; then | |
| log "📦 Using distribution-provided MySQL 8.0..." | |
| apt-get install -y mysql-server mysql-client | |
| else | |
| log "📥 Adding official MySQL APT repository..." | |
| local apt_config_url | |
| apt_config_url=$(curl -s https://dev.mysql.com/downloads/repo/apt/ | grep -oP 'https://dev.mysql.com/get/mysql-apt-config_[^"]+_all.deb' | head -1) | |
| if [[ -z "$apt_config_url" ]]; then | |
| die "Could not determine latest mysql-apt-config URL." | |
| fi | |
| local deb_file="/tmp/$(basename "$apt_config_url")" | |
| wget -q "$apt_config_url" -O "$deb_file" || die "Failed to download MySQL APT config" | |
| echo "mysql-apt-config mysql-apt-config/select-server select mysql-8.0" | debconf-set-selections | |
| dpkg -i "$deb_file" || apt-get install -f -y | |
| rm -f "$deb_file" | |
| apt-get update -y | |
| apt-get install -y mysql-server mysql-client | |
| fi | |
| systemctl enable mysql | |
| systemctl start mysql | |
| local wait_count=0 | |
| until mysqladmin ping --silent 2>/dev/null || [[ $wait_count -ge 30 ]]; do | |
| sleep 2 | |
| ((wait_count++)) | |
| done | |
| if ! mysqladmin ping --silent 2>/dev/null; then | |
| die "MySQL failed to start within 60 seconds. Check /var/log/mysql/error.log" | |
| fi | |
| log "✅ MySQL installed and running." | |
| } | |
| #------------------------------------------------------------------------------- | |
| # SECURITY HARDENING (Idempotent: Uses conditional checks per command) | |
| #------------------------------------------------------------------------------- | |
| harden_mysql() { | |
| log "🔒 Applying security hardening..." | |
| local root_pass="$1" | |
| local admin_user="$2" | |
| local admin_pass="$3" | |
| local sql_file | |
| sql_file=$(mktemp /tmp/mysql-harden-XXXXXX.sql) | |
| chmod 600 "$sql_file" | |
| cat > "$sql_file" <<EOF | |
| -- Safely alter root auth if not already done | |
| ALTER USER IF EXISTS 'root'@'localhost' IDENTIFIED WITH caching_sha2_password BY '${root_pass}'; | |
| -- Create dedicated admin user if missing, or update its credentials safely | |
| CREATE USER IF NOT EXISTS '${admin_user}'@'localhost' IDENTIFIED WITH caching_sha2_password BY '${admin_pass}'; | |
| ALTER USER '${admin_user}'@'localhost' IDENTIFIED WITH caching_sha2_password BY '${admin_pass}'; | |
| GRANT ALL PRIVILEGES ON *.* TO '${admin_user}'@'localhost' WITH GRANT OPTION; | |
| -- Remove anonymous users | |
| DELETE FROM mysql.user WHERE User=''; | |
| -- Remove test database | |
| DROP DATABASE IF EXISTS test; | |
| DELETE FROM mysql.db WHERE Db='test' OR Db='test\\_%'; | |
| -- Remove remote root access | |
| DELETE FROM mysql.user WHERE User='root' AND Host NOT IN ('localhost', '127.0.0.1', '::1'); | |
| -- Swap root to auth_socket for local system administration convenience & security | |
| ALTER USER IF EXISTS 'root'@'localhost' IDENTIFIED WITH auth_socket; | |
| ALTER USER IF EXISTS '${admin_user}'@'localhost' PASSWORD EXPIRE NEVER; | |
| FLUSH PRIVILEGES; | |
| EOF | |
| # Execute SQL depending on current root authentication status | |
| if mysql -e "SELECT 1" &>/dev/null; then | |
| mysql < "$sql_file" | |
| else | |
| # Fallback if execution requires systemic root authentication pass | |
| mysql -u root -p"${root_pass}" --batch < "$sql_file" 2>/dev/null || mysql -u root --batch < "$sql_file" | |
| fi | |
| rm -f "$sql_file" | |
| # Fixed: Load timezone data natively using host zoneinfo maps safely | |
| log "🌍 Verifying system timezone tables inside MySQL..." | |
| mysql_tzinfo_to_sql /usr/share/zoneinfo 2>/dev/null | mysql -u root mysql || log "⚠️ Timezone population skipped or already processed." | |
| # Store credentials securely using mysql_config_editor | |
| local login_path="production_admin" | |
| mysql_config_editor remove --login-path="${login_path}" 2>/dev/null || true | |
| mysql_config_editor set --login-path="${login_path}" --host=localhost --user="${admin_user}" --password <<< "${admin_pass}" | |
| log "✅ Security hardening complete." | |
| } | |
| #------------------------------------------------------------------------------- | |
| # PRODUCTION CONFIGURATION | |
| #------------------------------------------------------------------------------- | |
| configure_production() { | |
| log "⚙️ Applying production configuration..." | |
| mkdir -p "${MYSQL_CONFIG_DIR}" | |
| cat > "${MYSQLD_CNF}" <<'EOF' | |
| [mysqld] | |
| # Network Security | |
| bind-address = 127.0.0.1 | |
| mysqlx-bind-address = 127.0.0.1 | |
| skip-symbolic-links | |
| local_infile = 0 | |
| # Authentication | |
| default_authentication_plugin = caching_sha2_password | |
| # Logging (for audit and debugging) | |
| log_error = /var/log/mysql/error.log | |
| general_log = OFF | |
| slow_query_log = ON | |
| slow_query_log_file = /var/log/mysql/slow.log | |
| long_query_time = 2 | |
| # Binary logging (required for replication and point-in-time recovery) | |
| server-id = 1 | |
| log_bin = /var/log/mysql/mysql-bin | |
| binlog_expire_logs_seconds = 604800 # 7 days | |
| max_binlog_size = 100M | |
| # InnoDB Tuning (conservative defaults, adjust for your RAM) | |
| innodb_buffer_pool_size = 256M | |
| innodb_log_file_size = 64M | |
| innodb_flush_log_at_trx_commit = 2 | |
| innodb_flush_method = O_DIRECT | |
| # Connection limits | |
| max_connections = 151 | |
| wait_timeout = 28800 | |
| interactive_timeout = 28800 | |
| # Charset | |
| character-set-server = utf8mb4 | |
| collation-server = utf8mb4_0900_ai_ci | |
| # Security | |
| require_secure_transport = OFF # Set ON after SSL certs are configured | |
| EOF | |
| mkdir -p /var/log/mysql | |
| chown mysql:mysql /var/log/mysql | |
| chmod 750 /var/log/mysql | |
| chmod 644 "${MYSQLD_CNF}" | |
| # Restart gracefully to implement rules | |
| systemctl restart mysql | |
| local wait_count=0 | |
| until mysqladmin ping --silent 2>/dev/null || [[ $wait_count -ge 30 ]]; do | |
| sleep 2 | |
| ((wait_count++)) | |
| done | |
| if ! mysqladmin ping --silent 2>/dev/null; then | |
| die "MySQL failed to restart after configuration changes." | |
| fi | |
| log "✅ Production configuration applied." | |
| } | |
| #------------------------------------------------------------------------------- | |
| # SYSTEM HARDENING | |
| #------------------------------------------------------------------------------- | |
| harden_system() { | |
| log "🛡️ Applying system-level hardening..." | |
| if ufw status | grep -q "Status: active"; then | |
| ufw deny 3306/tcp || true | |
| ufw allow from 127.0.0.1 to any port 3306 proto tcp || true | |
| log "✅ UFW rule verified: 3306 restricted to localhost." | |
| fi | |
| cat > /etc/logrotate.d/mysql-custom <<'EOF' | |
| /var/log/mysql/*.log { | |
| daily | |
| rotate 14 | |
| compress | |
| missingok | |
| notifempty | |
| create 640 mysql adm | |
| sharedscripts | |
| postrotate | |
| test -x /usr/bin/mysqladmin || exit 0 | |
| if /usr/bin/mysqladmin ping > /dev/null 2>&1; then | |
| /usr/bin/mysqladmin flush-logs | |
| fi | |
| endscript | |
| } | |
| EOF | |
| if [[ -f /etc/apt/apt.conf.d/50unattended-upgrades ]]; then | |
| if ! grep -q "mysql" /etc/apt/apt.conf.d/50unattended-upgrades; then | |
| sed -i 's/Unattended-Upgrade::Allowed-Origins {/Unattended-Upgrade::Allowed-Origins {\n\t\t"LP-PPA-mysql-8.0:${distro_codename}";\n\t\t"Ubuntu:${distro_codename}-security";/' /etc/apt/apt.conf.d/50unattended-upgrades || true | |
| fi | |
| fi | |
| chmod 750 /var/lib/mysql | |
| chown -R mysql:mysql /var/lib/mysql | |
| log "✅ System hardening complete." | |
| } | |
| #------------------------------------------------------------------------------- | |
| # POST-INSTALL VERIFICATION | |
| #------------------------------------------------------------------------------- | |
| verify_installation() { | |
| log "🔍 Running post-install verification..." | |
| local admin_user="$1" | |
| systemctl is-active --quiet mysql || die "MySQL service is not active." | |
| ss -tlnp | grep -q "127.0.0.1:3306" || die "MySQL not bound to 127.0.0.1:3306" | |
| local root_auth | |
| root_auth=$(mysql -u root -e "SELECT plugin FROM mysql.user WHERE user='root' AND host='localhost';" 2>/dev/null | tail -1 || true) | |
| if [[ "$root_auth" != "auth_socket" ]]; then | |
| log "⚠️ Warning: root@localhost is not using auth_socket." | |
| fi | |
| local admin_count | |
| admin_count=$(mysql -u root -e "SELECT COUNT(*) FROM mysql.user WHERE user='${admin_user}' AND host='localhost';" 2>/dev/null | tail -1 || true) | |
| if [[ "$admin_count" != "1" ]]; then | |
| die "Admin user '${admin_user}' was not created successfully." | |
| fi | |
| local anon_count | |
| anon_count=$(mysql -u root -e "SELECT COUNT(*) FROM mysql.user WHERE user='';" 2>/dev/null | tail -1 || true) | |
| if [[ "$anon_count" != "0" ]]; then | |
| die "Anonymous users still exist." | |
| fi | |
| log "✅ All verification checks passed." | |
| } | |
| #------------------------------------------------------------------------------- | |
| # MAIN | |
| #------------------------------------------------------------------------------- | |
| main() { | |
| log "====================================================" | |
| log " 🛡️ Production MySQL 8.0 Installer (Idempotent)" | |
| log "====================================================" | |
| preflight | |
| echo "" | |
| log "🔐 Configure MySQL root password..." | |
| prompt_password MY_ROOT_PASS "Enter strong root password" "Confirm root password" 16 | |
| echo "" | |
| log "👤 Configure dedicated Admin User..." | |
| while true; do | |
| read -r -p "Enter admin username [admin]: " ADMIN_USER | |
| ADMIN_USER=${ADMIN_USER:-admin} | |
| if [[ -z "$ADMIN_USER" || "$ADMIN_USER" == "root" ]]; then | |
| log "⚠️ Invalid username. Cannot be empty or 'root'." | |
| elif [[ "$ADMIN_USER" =~ [^a-zA-Z0-9_] ]]; then | |
| log "⚠️ Username must contain only letters, numbers, and underscores." | |
| else | |
| break | |
| fi | |
| done | |
| prompt_password ADMIN_PASS "Enter admin password" "Confirm admin password" 16 | |
| if [[ "$MY_ROOT_PASS" == "$ADMIN_PASS" ]]; then | |
| die "Root and admin passwords must be different." | |
| fi | |
| install_mysql | |
| harden_mysql "$MY_ROOT_PASS" "$ADMIN_USER" "$ADMIN_PASS" | |
| configure_production | |
| harden_system | |
| verify_installation "$ADMIN_USER" | |
| MY_ROOT_PASS="CLEARED" | |
| ADMIN_PASS="CLEARED" | |
| unset MY_ROOT_PASS ADMIN_PASS | |
| log "" | |
| log "====================================================" | |
| log "🎉 MySQL Configuration Maintained/Deployed successfully!" | |
| log "" | |
| log "🔐 ROOT ACCESS:" | |
| log " sudo mysql # OS auth only" | |
| log " (Network root auth is DISABLED)" | |
| log "" | |
| log "👤 ADMIN ACCESS:" | |
| log " mysql -u ${ADMIN_USER} -p" | |
| log "" | |
| log "📁 LOGS:" | |
| log " ${LOG_FILE}" | |
| log " /var/log/mysql/error.log" | |
| log "" | |
| log "⚠️ NEXT STEPS:" | |
| log " 1. Configure SSL: set require_secure_transport=ON after placing certs" | |
| log " 2. Adjust innodb_buffer_pool_size to 70% of RAM in:" | |
| log " ${MYSQLD_CNF}" | |
| log " 3. Set up automated backups (e.g., Percona XtraBackup)" | |
| log " 4. Configure monitoring (Prometheus mysqld_exporter / Datadog)" | |
| log "====================================================" | |
| } | |
| main "$@" |
Connecting Securely via DBeaver
1. Local
MySQL 8.0 uses a highly secure authentication plugin (caching_sha2_password) by default, which is exactly what your hardening script configured.
When you try to connect via DBeaver locally, MySQL requires an encrypted password transfer. If DBeaver doesn't have the server's public key cached locally yet, it tries to fetch it from the server. Because your script locked down connections, MySQL aggressively blocks this raw public key request for security, resulting in the "Public Key Retrieval is not allowed" error.
Fortunately, you don't need to change anything on your server to fix this. You just need to flip one toggle inside DBeaver.
How to Fix It in DBeaver
Assuming you already created MySQL connection on DBeaver.
- Open DBeaver.
- Right-click your MySQL connection in the left-hand navigation panel and select Edit Connection.
- In the settings window that opens, look at the left sidebar and click on Connection settings -> Driver properties.
- In the main panel, you will see a long, alphabetized list of properties. Scroll down (or use the filter box at the top) to find
allowPublicKeyRetrieval. - Click on the value column next to it (which is currently blank or
false) and change it totrue. - While you are there, look right above or below it for
useSSLand ensure it is set tofalse(since your script currently hasrequire_secure_transport = OFFuntil you install real SSL certificates). - Click Test Connection at the bottom.
2. Remote
Because your database is locked tightly to 127.0.0.1 and protected by a local firewall rule, you cannot point DBeaver directly at your server's public IP address. Instead, you must bridge into the server securely using an SSH Tunnel.
Connection Settings Matrix
Create a new MySQL connection profile inside DBeaver and complete the following two configuration tabs:
1. Main Connection Settings Tab
| Parameter | Setting | Context |
|---|---|---|
| Host | localhost |
Crucial: Do not put your server IP here. Leave it exactly as localhost because DBeaver will evaluate this path from inside the server after tunneling. |
| Port | 3306 |
Default MySQL communications port. |
| Database | mysql |
The core administrative system schema. |
| Username | admin |
The custom administrative user created during execution. |
| Password | •••••••••••• |
The corresponding admin password. |
2. SSH Connection Tab
Check the box labeled "Use SSH Tunnel" and fill out your host environment properties:
| Parameter | Setting |
|---|---|
| Connection Method | Key Pair (Recommended) or Password |
| Host/IP | Your server's actual public-facing IP address. |
| Port | 22 (Or your custom SSH server port). |
| Username | Your server's SSH login user (e.g., root or ubuntu). |
| Passphrase / Password | Your system account SSH passphrase or password. |
Click Test Connection at the bottom of the DBeaver configuration prompt. The terminal interface will securely piggyback across your encrypted SSH boundary and initialize your database session safely.
// secured for documentation
// secured for documentation
// secured for documentation
// secured for documentation
// secured for documentation
Advanced Guide
1. WSL2-Specific Setup Guide
Option A: Native systemd (Recommended for Windows 11 22H2+ / Windows 10 KB5020030+)
Option B: SysVinit Fallback (Older WSL / Windows 10)
The script auto-detects this and installs a helper. Add auto-start to your shell:
Option C: Windows Task Scheduler (For true auto-start)
2. Operational Runbook
Daily Operations
Backup Procedures
Recovery Procedures
Password Rotation
3. Troubleshooting Matrix
MySQL failed to start within 60s/var/run/mysqldmissing (WSL2)sudo mkdir -p /var/run/mysqld && sudo chown mysql:mysql /var/run/mysqldERROR 2002 (HY000): Can't connectsudo wsl-mysql startorsudo systemctl start mysqlAccess denied for user 'root'@'localhost'sudo mysql(no password)Port 3306 already in usesudo ss -tlnp | grep 3306to identify; backup and re-run scriptsystemctl: command not foundsudo wsl-mysql startor enable systemd in/etc/wsl.confBinary logging not workingsudo chown mysql:mysql /var/log/mysql4. Configuration Reference
/etc/mysql/mysql.conf.d/99-production-hardening.cnf644/var/log/mysql/error.log640/var/log/mysql/slow.log640/var/log/mysql/mysql-bin.*640/var/lib/mysql/750/root/.mylogin.cnf6005. WSL2 Known Limitations
systemdon older WSLwsl-mysqlhelper script/var/runis tmpfs (cleared on shutdown)/var/run/mysqld.bashrctrigger127.0.0.1only (not exposed to Windows)lower_case_table_names=0(Linux default)6. Compliance Checklist
auth_socketonly, no TCP password authbind-address = 127.0.0.1, no remote rootrequire_secure_transport=ONmysqld_exporteror Datadog agent