5ed2eaf3fe
CDMON tiene ModSecurity a nivel de hosting: fue lo que detecto las subidas de wp-cl.php y health-check.php durante el incidente #183. En Hetzner, con Traefik, NO hay WAF ninguno. Migrar tal cual estrenaria servidor con una capa defensiva menos que la que tenia el sitio comprometido. Wordfence 8.2.2 instalado limpio desde wordpress.org en el staging y verificado: portada 200, wp-login 200, sin fatales, y fea-cloudflare-realip.php presente (sin el veria todas las visitas como si vinieran de Cloudflare). Queda en proteccion basica: el extended protection exige auto_prepend_file desde su asistente y eso se hace despues del cutover, no en la ventana. Anotado tambien que Wordfence y limit-login-attempts-reloaded solapan en el bloqueo de login; hay que decidir cual manda. Y documentada en el paso 6 la retirada de wordfence_core_options, que pese al nombre no es de Wordfence sino el payload de spam del atacante. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
185 lines
8.3 KiB
Markdown
185 lines
8.3 KiB
Markdown
# 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.
|
|
- [ ] **CDMON sigue vivo** (§1). Es el rollback: si no está, no se ejecuta.
|
|
- [ ] **Modo SSL de la zona en Cloudflare = Full (strict)**. Si está en *Flexible*, Cloudflare habla
|
|
HTTP con el origen mientras Traefik fuerza HTTPS → **bucle de redirección y sitio caído**.
|
|
Nuestro token no puede leer este ajuste: lo confirma Inma.
|
|
- [ ] **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. Comprobado
|
|
pendiente: `info.feadulta.com`.
|
|
|
|
## 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 — son 8, no 7
|
|
|
|
A los 7 plugins de producción se añade **Wordfence** (8.2.2, instalado limpio desde wordpress.org el
|
|
31-jul). Motivo: 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 Wordfence, el servidor nuevo estrenaría con una capa defensiva menos que la que tenía
|
|
el sitio comprometido.
|
|
|
|
⚠️ 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.
|
|
|
|
⚠️ Wordfence y `limit-login-attempts-reloaded` solapan en el bloqueo de login. No es un conflicto,
|
|
pero conviene decidir cuál manda para no acabar con dos contadores distintos.
|
|
|
|
## 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.
|
|
|
|
## 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.
|