#!/usr/bin/env bash # ============================================================================ # WOO v12 — WordPress Operations Orchestrator (Interactive, One-Command) # Target: Ubuntu 22.04 / 24.04 LTS # # - Run once to set up the server. After that, just type `woo` for the menu. # - Always installs the latest stable PHP from Ondřej’s PPA (tries 8.3, falls back to 8.2). # - Per-site isolation: separate PHP-FPM pool, DB+user, Nginx vhost, filesystem. # - No Multisite, No Staging, No Cloudflare, No remote backups (local only). # - XML-RPC kept ON but protected by a global whitelist (for Odoo). # - Smart, safe, zero-fail mindset with rollback on errors and clear reports. # ============================================================================ set -euo pipefail IFS=$'\n\t' # --------------------------- Auto-sudo (OVH-friendly) ----------------------- if [ "${EUID:-$(id -u)}" -ne 0 ]; then exec sudo -E bash "$0" "$@" fi # --------------------------- Colors & Log ----------------------------------- c_blue='\033[0;34m'; c_green='\033[0;32m'; c_yellow='\033[1;33m'; c_red='\033[0;31m'; c_nc='\033[0m' readonly LOG_DIR="/var/log/woo-toolkit" readonly LOG_FILE="${LOG_DIR}/woo-$(date +%Y%m%d_%H%M%S).log" mkdir -p "$LOG_DIR"; touch "$LOG_FILE" log() { echo -e "${c_blue}[$(date '+%F %T')]${c_nc} $*" | tee -a "$LOG_FILE"; } ok() { echo -e "${c_green}✓${c_nc} $*" | tee -a "$LOG_FILE"; } warn() { echo -e "${c_yellow}‼${c_nc} $*" | tee -a "$LOG_FILE"; } fail(){ echo -e "${c_red}✗${c_nc} $*" | tee -a "$LOG_FILE"; exit 1; } trap 'error_handler $LINENO "$BASH_COMMAND"' ERR # --------------------------- Global Defaults -------------------------------- readonly WEBROOT="/var/www" readonly CONFIG_DIR="/etc/woo" readonly PHP_CANDIDATES=("8.3" "8.2") PHP_VERSION="" readonly MIN_RAM_MB=2048 readonly MIN_DISK_MB=10240 readonly XMLRPC_WHITELIST_FILE="/etc/nginx/conf.d/xmlrpc_whitelist.conf" readonly LIMITS_FILE="/etc/nginx/conf.d/limits.conf" readonly CACHE_FILE="/etc/nginx/conf.d/fastcgi_cache.conf" declare -A CTX=() # rollback context keys: DOMAIN, SITE_DIR, DB_NAME, DB_USER # --------------------------- Helpers ---------------------------------------- have(){ command -v "$1" &>/dev/null; } gen_pass(){ openssl rand -base64 "${1:-24}"; } rand_hex(){ openssl rand -hex "${1:-6}"; } press_any(){ read -n 1 -s -r -p "Press any key to continue..."; echo; } error_handler(){ local l="$1" c="$2" log "Error on line $l: $c" rollback_safe fail "Aborted. See log: $LOG_FILE" } rollback_safe(){ if [[ -n "${CTX[DOMAIN]:-}" ]]; then rm -f "/etc/nginx/sites-available/${CTX[DOMAIN]}" "/etc/nginx/sites-enabled/${CTX[DOMAIN]}" || true rm -f "/etc/php/${PHP_VERSION}/fpm/pool.d/${CTX[DOMAIN]}.conf" || true systemctl reload "php${PHP_VERSION}-fpm" nginx || true fi [[ -n "${CTX[DB_NAME]:-}" ]] && mysql --defaults-file=/root/.my.cnf -e "DROP DATABASE IF EXISTS \`${CTX[DB_NAME]}\`;" || true [[ -n "${CTX[DB_USER]:-}" ]] && mysql --defaults-file=/root/.my.cnf -e "DROP USER IF EXISTS '${CTX[DB_USER]}'@'localhost';" || true [[ -n "${CTX[SITE_DIR]:-}" ]] && rm -rf "${CTX[SITE_DIR]}" || true } # --------------------------- Preflight & Detection -------------------------- preflight(){ log "Preflight checks…" [[ -f /etc/os-release ]] || fail "Unsupported OS." . /etc/os-release [[ "${NAME}" =~ Ubuntu ]] || fail "Ubuntu required." if [[ "${VERSION_ID}" != "22.04" && "${VERSION_ID}" != "24.04" ]]; then warn "Tested on 22.04/24.04. You are ${VERSION_ID}." fi local free_mb; free_mb=$(df -Pm / | awk 'NR==2 {print $4}') (( free_mb >= MIN_DISK_MB )) || fail "Low disk: ${free_mb}MB (< ${MIN_DISK_MB})." ok "Preflight OK." } ensure_swap(){ local ram_mb; ram_mb=$(awk '/MemTotal/ {print int($2/1024)}' /proc/meminfo) if (( ram_mb < MIN_RAM_MB )); then warn "RAM ${ram_mb}MB < ${MIN_RAM_MB}MB. Creating 2G swap for stability…" if ! swapon --show | grep -q /swapfile; then fallocate -l 2G /swapfile || dd if=/dev/zero of=/swapfile bs=1M count=2048 chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile grep -q '/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab ok "Swap enabled." fi fi } select_php_version(){ for v in "${PHP_CANDIDATES[@]}"; do if apt-cache policy "php${v}-fpm" | grep -q Candidate; then PHP_VERSION="$v"; break; fi done [[ -n "$PHP_VERSION" ]] || PHP_VERSION="8.2" ok "Using PHP ${PHP_VERSION}." } # --------------------------- Stack Install ---------------------------------- install_stack(){ log "Installing & updating packages…" apt-get update -y apt-get install -y software-properties-common curl wget unzip git rsync psmisc jq dnsutils ca-certificates gnupg lsb-release add-apt-repository -y ppa:ondrej/php add-apt-repository -y ppa:ondrej/nginx apt-get update -y select_php_version DEBIAN_FRONTEND=noninteractive apt-get install -y \ nginx mariadb-server redis-server fail2ban ufw \ certbot python3-certbot-nginx unattended-upgrades haveged \ php${PHP_VERSION}-fpm php${PHP_VERSION}-mysql php${PHP_VERSION}-curl php${PHP_VERSION}-mbstring php${PHP_VERSION}-xml \ php${PHP_VERSION}-zip php${PHP_VERSION}-gd php${PHP_VERSION}-opcache php${PHP_VERSION}-redis php${PHP_VERSION}-imagick php${PHP_VERSION}-bcmath if ! have wp; then curl -fsSL -o /usr/local/bin/wp https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar chmod +x /usr/local/bin/wp fi ok "Stack installed." } secure_mariadb(){ log "Securing MariaDB…" if [[ -f /root/.my.cnf ]]; then warn "MariaDB already secured. Skipping."; return; fi local pass; pass="$(gen_pass 32)" mysql -u root </root/.my.cnf < 4194304 )); then ib="4096M"; else ib="${ib_k}K"; fi cat >/etc/mysql/mariadb.conf.d/99-woo-tuned.cnf <"/etc/php/${PHP_VERSION}/fpm/conf.d/99-woo-opcache.ini" <>"$pol" <<'EOF' EOF fi fi } harden_firewall_fail2ban(){ log "UFW + Fail2Ban…" ufw default deny incoming || true ufw default allow outgoing || true ufw allow OpenSSH || true ufw allow 'Nginx Full' || true echo "y" | ufw enable || true cat >/etc/fail2ban/jail.d/wordpress.conf </etc/fail2ban/filter.d/nginx-wp-login.conf <<'EOF' [Definition] failregex = - .* "(POST|GET) /wp-login.php ignoreregex = EOF cat >/etc/fail2ban/filter.d/nginx-xmlrpc.conf <<'EOF' [Definition] failregex = - .* "POST /xmlrpc.php ignoreregex = EOF systemctl restart fail2ban || true } nginx_global(){ log "Writing global Nginx configs (limits, cache, xmlrpc whitelist, default)…" cat >"$LIMITS_FILE" <<'EOF' limit_req_zone $binary_remote_addr zone=logins:10m rate=5r/m; limit_req_zone $binary_remote_addr zone=xmlrpc:10m rate=10r/m; limit_conn_zone $binary_remote_addr zone=perip:10m; EOF cat >"$CACHE_FILE" <<'EOF' fastcgi_cache_path /var/run/nginx-cache levels=1:2 keys_zone=WORDPRESS:100m inactive=60m; fastcgi_cache_key "$scheme$request_method$host$request_uri"; fastcgi_cache_use_stale error timeout invalid_header http_500; fastcgi_ignore_headers Cache-Control Expires Set-Cookie; EOF cat >"$XMLRPC_WHITELIST_FILE" <<'EOF' # Global XML-RPC whitelist for all sites (0 = blocked by default) geo $xmlrpc_allowed { default 0; # Add IPs via the WOO menu -> XML-RPC whitelist # Example: 203.0.113.10 1; } EOF cat >/etc/nginx/sites-available/default <<'EOF' server { listen 80 default_server; listen [::]:80 default_server; server_name _; return 444; } EOF ln -sf /etc/nginx/sites-available/default /etc/nginx/sites-enabled/default nginx -t && systemctl reload nginx } systemd_logrotate(){ log "Systemd & logrotate…" mkdir -p /etc/systemd/system/nginx.service.d cat >/etc/systemd/system/nginx.service.d/override.conf <<'EOF' [Service] Restart=always RestartSec=2 LimitNOFILE=100000 EOF systemctl daemon-reload systemctl restart nginx cat >/etc/logrotate.d/woo-nginx <<'EOF' /var/log/nginx/*.log { daily rotate 14 compress missingok notifempty create 0640 www-data adm sharedscripts postrotate [ -s /run/nginx.pid ] && kill -USR1 `cat /run/nginx.pid` endscript } EOF cat >/etc/logrotate.d/woo-phpfpm <<'EOF' /var/log/php-fpm/*.log { daily rotate 14 compress missingok notifempty create 0640 www-data adm } EOF } # --------------------------- Per-Site Functions ----------------------------- php_pool_create(){ local domain="$1" cat >"/etc/php/${PHP_VERSION}/fpm/pool.d/${domain}.conf" <"/etc/nginx/sites-available/${domain}" <"${site_dir}/wp-content/mu-plugins/woo-hardening.php" <<'PHP' /dev/null | grep -v "wp cron event run --due-now --path=${site_dir}" ; \ echo "* * * * * sudo -u www-data WP_CLI_CACHE_DIR=/tmp/wp-cli-cache wp cron event run --due-now --path=${site_dir} >/dev/null 2>&1") | crontab - find "${site_dir}" -type d -exec chmod 755 {} \; find "${site_dir}" -type f -exec chmod 644 {} \; chmod 600 "${site_dir}/wp-config.php" mkdir -p /root/woo_credentials local cred="/root/woo_credentials/${domain}.txt" cat >"$cred" </dev/null; then :; else echo "No sites found."; fi echo; press_any } manage_cache(){ clear; echo -e "${c_blue}--- Manage Cache ---${c_nc}\n" read -rp "Domain: " domain local conf="/etc/nginx/sites-available/${domain}" [[ -f "$conf" ]] || { warn "Vhost not found."; press_any; return; } if grep -q "fastcgi_cache WORDPRESS" "$conf"; then echo "Cache is currently: ON" read -rp "Turn OFF cache? (y/N): " a if [[ "$a" =~ ^[Yy]$ ]]; then nginx_site_write "$domain" "${WEBROOT}/${domain}" "off" ok "Cache disabled." fi else echo "Cache is currently: OFF" read -rp "Turn ON cache? (y/N): " a if [[ "$a" =~ ^[Yy]$ ]]; then nginx_site_write "$domain" "${WEBROOT}/${domain}" "on" ok "Cache enabled." fi fi press_any } xmlrpc_whitelist_menu(){ clear; echo -e "${c_blue}--- XML-RPC Whitelist (Global) ---${c_nc}\n" echo "Current entries:" grep -E "^\s*([0-9]{1,3}\.){3}[0-9]{1,3}" "$XMLRPC_WHITELIST_FILE" | awk '{print " - " $1}' || echo " - None" echo echo "1) Add IP to whitelist (e.g., your Odoo server)" echo "2) Remove IP from whitelist" echo "3) Back" read -rp "Choice: " c case "$c" in 1) read -rp "Enter IP to ALLOW: " ip if [[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then if grep -q "$ip" "$XMLRPC_WHITELIST_FILE"; then warn "IP already present." else sed -i "/^geo /a\ \ \ \ ${ip} 1;" "$XMLRPC_WHITELIST_FILE" nginx -t && systemctl reload nginx ok "Added ${ip}." fi else warn "Invalid IP format." fi ;; 2) read -rp "Enter IP to REMOVE: " ip if grep -q "$ip" "$XMLRPC_WHITELIST_FILE"; then sed -i "/${ip}/d" "$XMLRPC_WHITELIST_FILE" nginx -t && systemctl reload nginx ok "Removed ${ip}." else warn "IP not found." fi ;; *);; esac press_any } backups_menu(){ clear; echo -e "${c_blue}--- Backups (Local Only) ---${c_nc}\n" echo "1) Backup a single site now" echo "2) Backup ALL sites now" echo "3) Schedule daily backups at 02:00" echo "4) Disable scheduled backups" echo "5) Back" read -rp "Choice: " c case "$c" in 1) read -rp "Domain: " domain; backup_site "$domain" ;; 2) backup_all_sites ;; 3) (crontab -l 2>/dev/null | grep -v "woo.sh backup-all"; echo "0 2 * * * /usr/bin/env bash /root/woo.sh backup-all >> /var/log/woo-toolkit/backup.log 2>&1") | crontab - ; ok "Daily backups scheduled."; press_any ;; 4) (crontab -l 2>/dev/null | grep -v "woo.sh backup-all") | crontab - ; ok "Scheduled backups disabled."; press_any ;; *) ;; esac } backup_site(){ local domain="$1" local dir="${WEBROOT}/${domain}" [[ -d "$dir" ]] || { warn "No site found at ${dir}."; press_any; return; } log "Backing up ${domain}…" local out="/root/woo_backups/${domain}"; mkdir -p "$out" local ts="$(date +%Y%m%d_%H%M%S)" local archive="${out}/full_${ts}.tar.gz" local db="$(sudo -u www-data wp config get DB_NAME --path="$dir" --quiet || echo)" local tmpdb="/tmp/db_${domain}_${ts}.sql" if [[ -n "$db" ]]; then sudo -u www-data wp db export "$tmpdb" --path="$dir" || true tar -czf "$archive" -C "$dir" . -C /tmp "$(basename "$tmpdb")" rm -f "$tmpdb" else tar -czf "$archive" -C "$dir" . fi ls -tp "${out}"/full_*.tar.gz 2>/dev/null | tail -n +8 | xargs -r -d $'\n' rm -f -- ok "Backup stored at: ${archive}" press_any } backup_all_sites(){ log "--- Backing up all sites ---" for s in $(ls -1 "${WEBROOT}" 2>/dev/null | grep -v '^html$' || true); do backup_site "$s" done ok "All backups complete." } doctor_report(){ clear; echo -e "${c_blue}--- System Doctor Report ---${c_nc}\n" echo "- OS: $(. /etc/os-release; echo "$PRETTY_NAME")" echo "- Nginx: $(nginx -v 2>&1)" echo "- PHP-FPM: $(php-fpm${PHP_VERSION} -v 2>/dev/null | head -n1 || echo php${PHP_VERSION})" echo "- MariaDB: $(mysql --version)" echo "- Redis: $(redis-server --version 2>/dev/null | head -n1 || echo 'installed')" echo "- RAM total: $(awk '/MemTotal/ {print int($2/1024) "MB"}' /proc/meminfo)" echo "- Free disk (/): $(df -h / | awk 'NR==2{print $4 " free"}')" echo "- Nginx config test:"; nginx -t || true echo "- SSL certs expiring in <14 days:" for d in /etc/letsencrypt/live/*; do [[ -d "$d" ]] || continue local crt="$d/fullchain.pem"; [[ -f "$crt" ]] || continue local exp_ts=$(date -d "$(openssl x509 -enddate -noout -in "$crt" | cut -d= -f2)" +%s) local now=$(date +%s); local diff=$(( (exp_ts-now)/86400 )) (( diff < 14 )) && echo " - $(basename "$d"): ${diff}d" done echo; press_any } # --------------------------- First-run Setup -------------------------------- first_run_setup(){ preflight ensure_swap install_stack secure_mariadb tune_mariadb harden_php harden_imagick harden_firewall_fail2ban nginx_global systemd_logrotate # Create the 'woo' convenience command cat >/usr/local/bin/woo <<'EOF' #!/usr/bin/env bash exec sudo -E bash /root/woo.sh "$@" EOF chmod +x /usr/local/bin/woo ok "Base server setup complete. Next time, just type: woo" press_any } # --------------------------- Menu ------------------------------------------- main_menu(){ while true; do clear echo -e "${c_blue}=====================================================${c_nc}" echo -e "${c_blue} WOO — WordPress Operations Orchestrator (v12) ${c_nc}" echo -e "${c_blue}=====================================================${c_nc}\n" echo " 1) Add New Site" echo " 2) Remove Site" echo " 3) List Sites" echo " 4) Manage Cache" echo " 5) XML-RPC Whitelist" echo " 6) Backups" echo " 7) Doctor (Report)" echo " 8) Exit" echo read -rp "Choose an option [1-8]: " ans case "$ans" in 1) create_wp_site ;; 2) remove_wp_site ;; 3) list_sites ;; 4) manage_cache ;; 5) xmlrpc_whitelist_menu ;; 6) backups_menu ;; 7) doctor_report ;; 8) clear; exit 0 ;; *) warn "Invalid option." ; press_any ;; case_esac done } # --------------------------- Entry Point ------------------------------------ main(){ # Normalize line endings if needed (harmless if already fine) command -v dos2unix >/dev/null 2>&1 || { apt-get update -y && apt-get install -y dos2unix; } dos2unix "$0" >/dev/null 2>&1 || true perl -i -pe 's/\r$//' "$0" || true # Detect PHP version after packages (or default for paths) if [[ -z "$PHP_VERSION" ]]; then for v in "${PHP_CANDIDATES[@]}"; do [[ -d "/etc/php/${v}" ]] && PHP_VERSION="$v" && break done [[ -z "$PHP_VERSION" ]] && PHP_VERSION="8.2" fi # One-time base setup if [[ ! -f /root/.woo-first-run-complete ]]; then first_run_setup touch /root/.woo-first-run-complete fi # Always show the menu main_menu } main "$@"