#!/usr/bin/env bash # Paso 1 del upgrade PHP 8.3 en feadulta.com (issue #46): # subir 5 ficheros parcheados + limpieza malware, manteniendo PHP 7.4. # # Modos: # --dry-run imprime lo que haría sin tocar nada (default) # --apply ejecuta el despliegue real # --rollback BACKUP_DIR restaura desde un backup pre-step1 concreto # # Pre-flight estricto: aborta si los 5 ficheros remotos no coinciden con el # hash esperado (el que verificamos hoy 2026-05-25). Si en prod cambió algo # entre planning y ejecución, hay que rehacer la planificación. set -euo pipefail # ─── Config ──────────────────────────────────────────────────────────────── SSH_USER="feadulta" SSH_HOST="134.0.10.170" SSH_PASS='6Rm2qOF@eundwpda' REMOTE_WEB_ROOT="/web" LOCAL_SRC_ROOT="/home/rafa/joomla-migration/joomla-php83" BACKUP_ROOT="/home/rafa/joomla-migration/backup/prod-20260525-php83-compat" # Mapa: ruta_relativa | hash_local_esperado | hash_remoto_esperado_actual # (verificado 2026-05-25 con md5sum local + ssh + tar -xzO del backup) declare -a FILES=( "modules/mod_featcats/helper.php|01ae5ad40e13abdcd5852897786d3733|744922888ae533b090eb34effe3bb469" "templates/fe_adulta_1/functions.php|06b2c26a618dbceefa5d8d5f0293c2cf|6318edec84cdf7a6b84a1d07f063c6c0" "templates/fe_adulta_1/index.php|dc318909b9ae5976e5c4f01c06c35f66|9793dfa3c880eba37ad5ad35e6988705" "modules/mod_k2_filter/helper.php|0d9767a0d8d67aa420baefe38382a87c|8530a4e70043973fd2d625c6f8de6ce9" "modules/mod_k2_filter/tmpl/Default/template.php|a62a39dacafa0748528268b9f04aa844|008465147acdeb442eca6f64311fb23d" ) # URLs de smoke test (deben devolver HTTP 200 antes y después del cambio) declare -a SMOKE_URLS=( "https://www.feadulta.com/" "https://www.feadulta.com/es/" "https://www.feadulta.com/es/ayuda.html" "https://www.feadulta.com/es/quienessomos/colaboradores.html" "https://www.feadulta.com/es/buscadoravanzado/itemlist/" "https://www.feadulta.com/es/buscadoravanzado/itemlist/user/43-fraymarcos.html" "https://www.feadulta.com/es/comentcol2.html" ) # Patrones de spam que deben dar 0 hits en las respuestas tras el paso 1 SPAM_REGEX="(apuestadeportiva|vavada\.mobi|inkabet|betsafe|betcris|botbotbot)" # IP del origen para bypass de Cloudflare (la web está detrás de CF managed challenge, # las peticiones curl normales reciben 403). --resolve nos lleva directo al origen. ORIGIN_IP="134.0.10.170" SMOKE_UA="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36" # ─── Utilidades ──────────────────────────────────────────────────────────── # Logs van a stderr; stdout queda libre para "datos" (p.ej. ruta del backup # para capturar con $(...) sin contaminación). Bug detectado en revisión #46. log() { printf '%s [%s] %s\n' "$(date +'%H:%M:%S')" "$1" "$2" >&2; } info() { log "INFO" "$1"; } warn() { log "WARN" "$1"; } err() { log "ERR " "$1"; } ok() { log "OK " "$1"; } ssh_run() { SSHPASS="$SSH_PASS" sshpass -e ssh -o StrictHostKeyChecking=no -o ConnectTimeout=15 \ "$SSH_USER@$SSH_HOST" "$@" } # El cPanel jail rechaza scp y sftp ("Connection closed"). Usamos cat-over-ssh, # que sí funciona (cat está en /usr/bin/cat). Verificación 2026-05-25: download # de un fichero conocido reproduce el mismo MD5; upload de bytes y verificación # por re-lectura sin pérdidas. scp_get() { local remote="$1" local_target="$2" SSHPASS="$SSH_PASS" sshpass -e ssh -o StrictHostKeyChecking=no -o ConnectTimeout=15 \ "$SSH_USER@$SSH_HOST" "cat '$remote'" > "$local_target" } scp_put() { local local_src="$1" remote_target="$2" SSHPASS="$SSH_PASS" sshpass -e ssh -o StrictHostKeyChecking=no -o ConnectTimeout=15 \ "$SSH_USER@$SSH_HOST" "cat > '$remote_target'" < "$local_src" } remote_md5() { ssh_run "md5sum '$1' 2>/dev/null | cut -d' ' -f1" } # ─── Pre-flight checks ───────────────────────────────────────────────────── preflight() { info "Pre-flight: verificar hashes locales y remotos" local fail=0 for entry in "${FILES[@]}"; do IFS='|' read -r rel hl hr_expected <<<"$entry" local local_path="$LOCAL_SRC_ROOT/$rel" local remote_path="$REMOTE_WEB_ROOT/$rel" if [[ ! -f "$local_path" ]]; then err "Local no existe: $local_path"; fail=1; continue fi local hl_actual hl_actual=$(md5sum "$local_path" | cut -d' ' -f1) if [[ "$hl_actual" != "$hl" ]]; then err "Local $rel: hash $hl_actual ≠ esperado $hl"; fail=1 else ok "Local $rel coincide ($hl)" fi local hr_actual hr_actual=$(remote_md5 "$remote_path") if [[ -z "$hr_actual" ]]; then err "Remoto no existe o vacío: $remote_path"; fail=1 elif [[ "$hr_actual" != "$hr_expected" ]]; then err "Remoto $rel: hash $hr_actual ≠ esperado $hr_expected (algo cambió en prod, replanificar)"; fail=1 else ok "Remoto $rel coincide ($hr_actual)" fi done if [[ $fail -eq 1 ]]; then err "Pre-flight FAIL → abortando" exit 2 fi ok "Pre-flight OK" } # ─── Backup pre-cambio (los 5 ficheros remotos) ──────────────────────────── backup_pre_step1() { local ts; ts=$(date +'%Y%m%d-%H%M%S') local dir="$BACKUP_ROOT/pre-step1-$ts" if [[ "$MODE" == "dry-run" ]]; then info "[dry-run] Crearía directorio: $dir" info "[dry-run] Descargaría ${#FILES[@]} ficheros remotos a ese directorio + md5sums.txt + tar.gz" echo "$dir" # imprime para usar después return fi info "Creando backup pre-step1 en $dir" mkdir -p "$dir" for entry in "${FILES[@]}"; do IFS='|' read -r rel _ hr_expected <<<"$entry" local remote_path="$REMOTE_WEB_ROOT/$rel" local local_target="$dir/$rel" mkdir -p "$(dirname "$local_target")" info " Descargando $remote_path" scp_get "$remote_path" "$local_target" if [[ ! -s "$local_target" ]]; then err "Descarga vacía: $local_target — ABORT (revisar acceso SSH)" exit 6 fi local h_after h_after=$(md5sum "$local_target" | cut -d' ' -f1) if [[ "$h_after" != "$hr_expected" ]]; then err "Backup $rel: md5 $h_after ≠ esperado $hr_expected — ABORT" exit 6 fi ok " Backup $rel verificado ($h_after)" done (cd "$dir" && find . -type f -name '*.php' -exec md5sum {} \; > md5sums.txt) tar -czf "$dir.tar.gz" -C "$BACKUP_ROOT" "pre-step1-$ts" ok "Backup pre-step1 creado: $dir" ok "Backup tar.gz: $dir.tar.gz" echo "$dir" } # ─── Subida de los 5 ficheros ────────────────────────────────────────────── upload_files() { for entry in "${FILES[@]}"; do IFS='|' read -r rel hl _ <<<"$entry" local local_path="$LOCAL_SRC_ROOT/$rel" local remote_path="$REMOTE_WEB_ROOT/$rel" if [[ "$MODE" == "dry-run" ]]; then info "[dry-run] scp $local_path → $remote_path" continue fi info "Subiendo $rel" scp_put "$local_path" "$remote_path" local hr_after; hr_after=$(remote_md5 "$remote_path") if [[ "$hr_after" != "$hl" ]]; then err "Subida de $rel: hash remoto post-subida $hr_after ≠ local $hl" err "ABORT — el fichero remoto no coincide con el local. Considerar rollback." exit 3 fi ok "Subido + verificado $rel ($hr_after)" done } # ─── Smoke test HTTP ─────────────────────────────────────────────────────── # allow_spam=1 → solo validar HTTP code (uso post-rollback, donde los ficheros # restaurados llevan las inyecciones spam originales y un spam>0 es esperado). smoke_test() { local allow_spam="${1:-0}" if [[ "$allow_spam" -eq 1 ]]; then info "Smoke test HTTP (post-rollback: solo validar HTTP, spam>0 esperado)" else info "Smoke test HTTP (HTTP 200/3xx + spam=0)" fi local fail=0 for url in "${SMOKE_URLS[@]}"; do if [[ "$MODE" == "dry-run" ]]; then info "[dry-run] curl $url + grep spam" continue fi local code body_spam tmp tmp=$(mktemp) code=$(curl -ksL -A "$SMOKE_UA" --resolve "www.feadulta.com:443:$ORIGIN_IP" \ -o "$tmp" -w "%{http_code}" "$url" || echo "ERR") body_spam=$(grep -cE "$SPAM_REGEX" "$tmp" || true) rm -f "$tmp" if [[ "$code" != "200" && "$code" != "301" && "$code" != "302" ]]; then err " $url → HTTP $code"; fail=1 elif [[ "$allow_spam" -eq 0 && "$body_spam" -gt 0 ]]; then err " $url → HTTP $code, $body_spam strings spam"; fail=1 else ok " $url → HTTP $code, $body_spam spam" fi done if [[ $fail -eq 1 ]]; then err "Smoke test FAIL → considerar rollback manual con --rollback " exit 4 fi ok "Smoke test OK" } # ─── Rollback desde backup ───────────────────────────────────────────────── rollback() { local dir="$1" if [[ ! -d "$dir" ]]; then err "Backup dir no existe: $dir"; exit 5 fi info "Rollback desde $dir" for entry in "${FILES[@]}"; do IFS='|' read -r rel _ _ <<<"$entry" local local_src="$dir/$rel" local remote_path="$REMOTE_WEB_ROOT/$rel" if [[ ! -f "$local_src" ]]; then err " No hay backup para $rel — saltando"; continue fi info " Restaurando $rel" scp_put "$local_src" "$remote_path" ok " Restaurado $rel" done ok "Rollback completado" } # ─── Main ────────────────────────────────────────────────────────────────── MODE="dry-run" ROLLBACK_DIR="" while [[ $# -gt 0 ]]; do case "$1" in --dry-run) MODE="dry-run"; shift ;; --apply) MODE="apply"; shift ;; --rollback) MODE="rollback"; ROLLBACK_DIR="${2:-}"; shift 2 ;; -h|--help) sed -n '1,12p' "$0"; exit 0 ;; *) err "Flag desconocido: $1"; exit 1 ;; esac done info "Modo: $MODE" if [[ "$MODE" == "rollback" ]]; then if [[ -z "$ROLLBACK_DIR" ]]; then err "--rollback requiere ruta del backup"; exit 1; fi rollback "$ROLLBACK_DIR" smoke_test 1 # allow_spam=1: ficheros restaurados aún tienen las inyecciones exit 0 fi preflight backup_dir=$(backup_pre_step1) upload_files smoke_test ok "Paso 1 finalizado en modo: $MODE" if [[ "$MODE" == "apply" ]]; then ok "Backup pre-step1: $backup_dir" ok "Para rollback: $0 --rollback $backup_dir" fi