# Runbook del cutover — WordPress feadulta.com: CDMON → Hetzner **Fecha prevista: lunes 3 de agosto de 2026.** Plan maestro: gitea `rafa/feadulta`#180. Todos los tiempos de abajo están **medidos en el ensayo del 31-jul**, no estimados. --- ## Antes de empezar (verde obligatorio) - [ ] **Freeze editorial** avisado a Inma. El cutover no puede caer el día de publicación de la carta. *(La carta 738 se compone el martes: el lunes está libre.)* - [ ] **CDMON sigue vivo** (§1). Es el rollback: si no está, no se ejecuta. - [x] ~~**Modo SSL de la zona**~~ — **CONFIRMADO POR INMA (2-ago): está en `Full`, no `Flexible`.** El riesgo de bucle de redirección queda descartado. (*Full (strict)* además valida el certificado del origen; es mejor y se puede subir después, no bloquea el cutover.) - [ ] 🔴 **`wp-nuevo.feadulta.com` declarado en Coolify — ver paso 0. SIN ESTO SE ROMPEN LOS BOTS.** - [ ] **Inventario del wildcard.** La zona tiene `*.feadulta.com` → apex, proxied. Al mover el A del apex, **todo subdominio no declarado se va a Hetzner** y Traefik devolverá su 404. `info.feadulta.com`: Rafa lo da por residual, se acepta que dé 404. --- ## 0. 🔴 `wp-nuevo.feadulta.com` — hacer ANTES del cutover **Los bots de la carta (`teologasbot`, `escritoresbot`, `recordatoriobot`) publican por `wp-nuevo.feadulta.com`, no por `www`**, porque el WAF de Cloudflare los bloquea por `www` (regla "bloquear bots 1"). Aviso de Mixbot, 2-ago. Ese hostname **no tiene registro propio**: lo cubre el wildcard. Al mover el A del apex viaja a Hetzner, y **Traefik solo tiene router para `nuevo.feadulta.com`** → devolvería su 404 y los bots dejarían de publicar. La carta 738 se compone el **martes**. ⚠️ **Hay que hacerlo en el PANEL de Coolify.** Por API no vale: `PATCH /services/{uuid}` con `fqdn` da `422 "This field is not allowed"`, y actualizar `SERVICE_FQDN_WORDPRESS` por API cambia la variable pero **no regenera los routers de Traefik** (comprobado el 2-ago). Servicio `feadulta-wp` → campo de dominios: ``` antes del cutover: https://nuevo.feadulta.com,https://wp-nuevo.feadulta.com después del cutover: https://www.feadulta.com,https://feadulta.com,https://wp-nuevo.feadulta.com ``` **Plan B si el martes wp-nuevo falla:** los bots cambian una línea de su `.env` y publican por **`nuevo.feadulta.com`** — es grey cloud, así que **no pasa por Cloudflare y ninguna regla del WAF le aplica**, ya tiene certificado y ya está enrutado. Mixbot puede hacerlo al momento. ✅ **Estado a 2-ago: HECHO.** Verificado en el servidor: Traefik tiene los dos routers (`Host(nuevo.feadulta.com)` y `Host(wp-nuevo.feadulta.com)`), responde 302 a ambos y **404 a cualquier hostname no declarado**. ℹ️ **No hace falta esperar al certificado de Let's Encrypt para que los bots funcionen.** `wp-nuevo` va **proxied** (naranja, por el wildcard): Cloudflare presenta *su* certificado a los bots, y hacia el origen —con el modo SSL en **`Full`, no `Full (strict)`**— acepta cualquier certificado, incluido el autofirmado por defecto de Traefik. Así que el hostname sirve desde el primer segundo y LE emitirá el suyo cuando le toque. ⚠️ El reverso: **subir la zona a `Full (strict)` antes de que todos los certificados estén emitidos rompería justamente esto.** Hacerlo después, y comprobando. Verificación tras el cutover (no vale hacerla desde la máquina de Rafa: **Cloudflare devuelve 403 a esa IP para cualquier hostname de feadulta**, así que un 403 desde ahí no significa nada): ```bash curl -sI https://wp-nuevo.feadulta.com/wp-json/ | head -3 # desde el Hetzner o desde los bots ``` ## Tiempos medidos (ensayo del 31-jul) | Fase | Medido | ¿Se repite el lunes? | |---|---|---| | Dump CDMON → WSL (105 MB) | 25 s | **Sí** | | WSL → Hetzner | 74 s | **Sí** | | Saneo de collations + exclusión `*_bak` | 2 s | **Sí** | | Import en MySQL 8.4 | 107 s | **Sí** | | Instalar plugins + tema + 28 mu-plugins | 4 min 19 s | **No** (ya hecho) | | Pre-sync de `uploads` 5,9 GB | ver #180 | **No** (ya hecho; el lunes solo el delta) | | **Camino crítico de la ventana** | **≈ 3 min 30 s + delta de uploads** | | > El corte real percibido por el visitante es **solo el cambio del A record**, que en Cloudflare es > instantáneo. Todo lo de arriba se hace **antes**, con el sitio viejo aún sirviendo. --- ## 1. Backup de última hora en el origen No fiarse del cron de UpdraftPlus de las 05:09: forzar uno nuevo o confirmar que el del día existe. ```bash sshpass -p "$FEA_PROD_SSH_PASS" ssh feadulta@134.0.10.170 \ 'ls -lt /web/wp-content/updraft/*-db.gz | head -3' ``` ## 2. Traer el dump del día ```bash bash scripts/cutover/01-traer-dump.sh # CDMON → WSL → Hetzner, con sha256 en los 3 puntos ``` Dos saltos a propósito: `scp`/`sftp` no funcionan contra la jaula de CDMON, y no queremos dejar la contraseña de CDMON escrita en el Hetzner. ## 3. Sanear el dump ⚠️ el paso que no se puede saltar El origen es **MariaDB 11.8.6** y el destino **MySQL 8.4.10**. El dump trae **20 tablas con collations `uca1400_*` que solo existen en MariaDB 11+**; MySQL las rechaza en el `CREATE TABLE` y el import aborta. ``` utf8mb4_uca1400_ai_ci → utf8mb4_0900_ai_ci utf8mb3_uca1400_ai_ci → utf8mb4_0900_ai_ci (+ CHARSET=utf8mb3 → utf8mb4) utf8mb4_unicode_520_ci → sin tocar (válida en MySQL 8.4) ``` También excluye las 5 tablas de respaldo (`wp_options_bak`, `wp_posts_bak`, `wp_postmeta_bak`, `wp_usermeta_bak`, `wp_fea_date_slug_posts_backup`). **Verificación antes de importar** (las tres deben dar 0): ```bash grep -c 'uca1400' feadulta-saneado.sql grep -c 'utf8mb3' feadulta-saneado.sql grep -oE '^CREATE TABLE `[^`]+`' feadulta-saneado.sql | grep -cE '_bak|backup' ``` ## 4. Importar **`--default-character-set=utf8mb4` en el CLIENTE es obligatorio.** Omitirlo es exactamente lo que produjo el mojibake `’` en summaraise. ```bash docker exec -i mysql-r2ssjifwj0r0ghyoqd528uaa \ mysql --default-character-set=utf8mb4 -uroot -p"$RP" wordpress < feadulta-saneado.sql ``` Verificación: 29 tablas, y las filas deben cuadrar con `(líneas que empiezan por " (") + (nº de sentencias INSERT)` de cada tabla — la primera fila de cada `INSERT` va pegada al `VALUES`. **Ignora el `# Approximate rows expected` del dump**: es la estimación de InnoDB y para `wp_posts` dice 64.160 cuando las filas reales son 43.941. ## 5. Delta de uploads ```bash rsync -a --info=progress2 --stats --exclude='*.php' \ -e "sshpass -p ... ssh" feadulta@134.0.10.170:/web/wp-content/uploads/ / ``` Solo mueve lo publicado desde el pre-sync. **`--delete` únicamente tras revisar un `--dry-run`.** ## 6. Higiene de seguridad — SIEMPRE después de cada import ```bash bash scripts/cutover/post-import-seguridad.sh ``` El dump trae las cuentas de administrador con sus hashes, **incluidas las tres deshabilitadas en el incidente #183**: reimportar las revive. El script rota la contraseña de los 6 administradores (leyéndolas de `~/.hermes/profiles/feadulta/.env`), retira el rol a `pabloarias`/`josek`/`andrey`, y limpia los cron huérfanos de Yoast y WP Profile Builder. Doble cinturón: además del rol retirado, el mu-plugin `fea-security-blocked-users.php` bloquea `wp_authenticate_user` y `allow_password_reset` para esos tres IDs. **Va en el manifiesto de `docs/cutover/mu-plugins-manifest.txt`, no en un glob `fea-*`** — un glob se lo dejaría fuera. El script retira además la opción **`wordfence_core_options`**, que **no es de Wordfence**: es el payload de SEO spam que dejó el atacante (div oculto con enlaces a un casino, cifrado con un césar de una letra). Está inerte —quien lo leía era `wp-highlits`, en cuarentena— pero **viaja dentro del dump y cada import lo reintroduce**. ## 6b. Plugins de seguridad — Wordfence entra, LLAR sale **Siguen siendo 7 plugins activos**, pero no los mismos: entra **Wordfence** (8.2.2, limpio desde wordpress.org) y sale **`limit-login-attempts-reloaded`** (decisión de Rafa, 2-ago). Motivo de que entre Wordfence: CDMON tenía **ModSecurity** delante a nivel de hosting —fue lo que detectó las subidas de `wp-cl.php` y `health-check.php` en el #183— y **en Hetzner con Traefik no hay WAF ninguno**. Sin él, el servidor nuevo estrenaría con una capa defensiva menos que la que tenía el sitio comprometido. Motivo de que salga LLAR: Wordfence hace lo mismo (límite de intentos, bloqueos) **y además** firewall de aplicación, escáner de malware y 2FA. Los dos a la vez son dos contadores compitiendo y bloqueos difíciles de diagnosticar. **El dump trae LLAR en `active_plugins`, así que cada import lo revive** → lo retira `post-import-seguridad.sh` (paso 3c), junto con sus 12 opciones huérfanas. El mismo script comprueba que Wordfence sigue activo y lo reinstala si faltara. ⚠️ Wordfence queda con **protección básica** (a nivel de plugin). El *extended protection* exige configurar `auto_prepend_file` desde su asistente; se hace **después** del cutover y con calma, no en la ventana. ⚠️ `fea-cloudflare-realip.php` es obligatorio con Wordfence por el mismo motivo que lo era con LLAR: sin él ve todas las visitas como si vinieran de las IPs de Cloudflare. Está en el manifiesto. ## 7. Quitar el WP_HOME/WP_SITEURL del staging ``` Coolify → servicio feadulta-wp → borrar la variable WORDPRESS_CONFIG_EXTRA → redeploy ``` **No hace falta ningún `search-replace`**: la base de datos ya trae `siteurl`/`home` = `https://www.feadulta.com`, que es la URL final. La constante era solo para el staging. Esto ahorra la pasada más lenta de todo el proceso. ## 8. Cambiar el A record en Cloudflare ```bash # feadulta.com y www.feadulta.com → 188.40.120.157, MANTENIENDO proxied=true (naranja) ``` El token **sí tiene permiso de escritura**, verificado el 31-jul creando `nuevo.feadulta.com`. Después: purgar la caché de Cloudflare. ## 9. Verificación en caliente - [ ] Portada en los 5 idiomas (`/`, `/en/`, `/fr/`, `/it/`, `/pt/`) — `/es/` responde 301, es lo normal. - [ ] Carta de la semana, evangelios, EFFA, autores con avatar. - [ ] Buscador (índice FULLTEXT) devuelve resultados. - [ ] **Audio TTS** suena. - [ ] Imágenes cargan (depende del delta de uploads). - [ ] `wp-login.php` entra con la contraseña rotada, y **las 3 cuentas del incidente NO entran**. - [ ] Certificado Let's Encrypt para apex y `www`. - [ ] Cero errores PHP en la portada y en `docker logs`. - [ ] GA4 en tiempo real. ## 10. Application passwords — con Inma delante, no antes **Solo existe una en todo el sitio**: `publicabot`, del usuario `icalvotorre` (ID 1048), creada el 27-jun-2026, último uso el 29-jul-2026 desde `213.94.23.1`. Es la que usan los bots de la carta. Rotarla sin coordinar **para la publicación de la carta semanal** y nadie sabrá por qué. Se rota con Inma delante, generando la nueva y actualizándola en el bot en el mismo momento. *(Rotar la contraseña de un usuario **no** revoca sus application passwords: el paso 6 no la afecta.)* --- ## Rollback **Revertir el A record en Cloudflare + purgar caché.** Segundos. El Joomla/WordPress de CDMON sigue intacto y sirviendo — comprobado con `curl --resolve` contra `134.0.10.170`. **Esto solo funciona mientras CDMON siga vivo.** Es la razón por la que el §1 no es opcional. ## 🔴 El correo — no es del cutover, pero es lo que más puede doler El cutover **no toca el correo**: el MX apunta a `mail.feadulta.com` → `134.0.13.123`, una IP distinta de la del web. Mover el A del apex no lo afecta. **Pero la caducidad del 07/08 sí.** Los 17 buzones viven en CDMON, y `ediciones@` y `contenido@` son de donde los bots sacan el material de la carta: si cae el correo, se para la carta. Hay que resolverlo con CDMON **esta semana**, vaya como vaya el cutover. ## Después (no el mismo día) - **Backups en Hetzner: hoy NO HAY NINGUNO.** La tabla `scheduled_database_backups` de Coolify está vacía y no hay cron. Summaraise lleva semanas así. Hay que darle dueño. - Actualizar los scripts y guías que apuntan a `134.0.10.170` y a `/web/` (cierra #158). - Retirar `nuevo.feadulta.com` de Cloudflare cuando ya no haga falta.