Files
feadulta/docs/cutover/RUNBOOK-cutover-wordpress.md
T
rafa d98f99d0ab certificados: emitidos, y las dos trampas del camino
www.feadulta.com, feadulta.com y wp-nuevo.feadulta.com, de Let's Encrypt y
validos hasta el 1-nov. Ya se puede subir la zona a Full (strict).

Lo que funciono es el plan B de Inma, porque la Configuration Rule que propuse
primero no se puede montar en su plan de Cloudflare: custom rule del WAF con
accion Skip para la ruta del reto, mas apagar Always Use HTTPS a nivel de zona
unos minutos.

Dos cosas que costaron tiempo y quedan anotadas:

acme.json escribe "main": "dominio" CON espacio. Un grep sin espacio da vacio
siempre y parece que no hay certificados. Los de hoy llevaban casi una hora
emitidos mientras mi bucle de vigilancia informaba de "sin novedad", y por eso
lanzamos un reintento que no hacia falta. Va el snippet que parsea el JSON.

Y se retira un riesgo que yo mismo habia documentado: el modo Full NO impide el
reto. Se temia que Cloudflare mandara la validacion al 443, donde el handler de
ACME no existe. Es falso y se midio: con Always Use HTTPS apagado, una peticion
HTTP a la ruta del reto a traves de Cloudflare devuelve 404 con 0 bytes y
server: cloudflare, o sea que reenvia al puerto 80 y responde Traefik. Mejor
quitarlo que dejar una advertencia falsa en un documento que va a leer otra
gente.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 09:17:31 -04:00

280 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
---
## ✅ EJECUTADO — 3-ago-2026, 10:50 UTC. Ventana real: 3 min 10 s
Dump 67 s · saneo 2 s · import 114 s · delta de uploads 6 s (0 ficheros, como estaba medido).
Cuadró al dígito con producción: **43.893 posts, 1.228 usuarios, 4.416 terms, 135.959 postmeta**
(los options salen 958 frente a 975: son transients, hay 580 y caducan solos). Con tráfico real:
**490×200, 66×301, 9×302, 0 respuestas 4xx/5xx y 0 errores PHP**, 177 IPs distintas atendidas en
10 min tras la purga de caché. **E2E 11/11 en 200.** Audio TTS 200 (285.741 b). Login: `eqpyk` e
`icalvotorre` entran con la contraseña rotada, las tres cuentas del #183 reciben *"This account is
disabled"*.
### Lo que quedó abierto
| | |
|---|---|
| ✅ **Certificados LE de `www` y el apex** | **Emitidos el 3-ago a las 12:03 UTC**, válidos hasta el 1-nov. Detalle y las dos trampas del camino en `certificados-letsencrypt-cloudflare.md`. **Ya se puede subir la zona a `Full (strict)`.** |
| ✅ **Application passwords** | Wordfence las desactiva por defecto y eso dejaba a los bots sin poder publicar (401 en la REST API). Reactivado y **añadido como paso 3e de `post-import-seguridad.sh`**, porque el 3d reinstala Wordfence y volvería a romperse. |
| ✅ **Backups en Hetzner** | Ya existen (verificado por Rafa en otra sesión). |
| 🟡 **CDMON** | Es el rollback. Inma lo está degradando a un plan barato con el correo. Caduca el **07/08**. |
| 🟡 **`nuevo.feadulta.com`** | Ya no hace falta; retirar el registro cuando se quiera. |
| 🟡 **Scripts que apuntan a `134.0.10.170` y `/web/`** | Cierra #158. |
**Purga de caché de Cloudflare: la hizo Inma.** Nuestro token no puede (`purge_cache` → 10000
Authentication error): solo tiene `Zone → DNS → Edit`. Si se quiere automatizar, añadirle
`Zone → Cache Purge → Purge`.
---
## 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/ <destino>/
```
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.