MIGRACIÓN feadulta.com: CDMON → Hetzner (deadline hosting 07/08/2026) — plan maestro #180
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Migración feadulta.com: hosting CDMON → servidor Hetzner (Coolify)
Deadline duro: el hosting de CDMON caduca el 07/08/2026 (viernes).
Rafa está en Madrid del 16-jul al 01-ago (trabajo remoto vía Hermes). Ventana de ejecución
prevista: lunes 03-ago, con 04/05-ago de colchón. Eso deja 4 días de margen contra la
caducidad → ver §1, el seguro no es opcional.
Este issue es el plan maestro. No se ejecuta nada de la Fase 4 sin validación explícita de
Rafa. Antecedentes: #153 (checklist del cutover Joomla→WP, la plantilla de la que sale este plan),
#162 / #164 / #168 (legacy Joomla),
rafa/server#3(migración a Coolify estándar de aqtalent — elpatrón a repetir),
rafa/server#5(topología de los 2 servidores).0. Situación de partida
Qué hay hoy en CDMON (134.0.10.170, cPanel, cuenta
feadulta)/web/— ~11 GB, ~24.800 posts, BD ~104 MB gz/web/antiguo//web/anterior/Qué hay en Hetzner (188.40.120.157, Coolify 4.1.2)
Ya en producción: summaraise.com, gitea.feadulta.com, rafacalvo.nyc, CRM Relaticle, Beszel.
Hardware: i7-7700, 64 GB RAM, 460 GB útiles (RAID1). feadulta (~11 GB + BD) cabe de sobra, pero
hay que medir el disco libre real antes (T1.1).
Ventaja del cutover: Cloudflare hace de interruptor
Todos los hostnames van proxied (naranja) por Cloudflare. El cutover no es una espera de TTL de
24-48h: es cambiar el A record en el dashboard de Cloudflare y el edge lo aplica en segundos —
y el rollback es igual de rápido, siempre que CDMON siga vivo. Esto es lo que hace viable una
ventana corta. Pero depende por completo de tener acceso a Cloudflare en la ventana → B1.
1. 🔴 Seguro anti-deadline (hacer en julio, NO en agosto)
El calendario real es: Rafa vuelve el 01-ago, cutover el 03-ago, caducidad el 07-ago.
Si algo se tuerce el día 3, quedan 4 días naturales para arreglarlo, con el rollback muriéndose.
Eso no es un plan, es una apuesta.
(a) los buzones de correo y (b) el
/webactual como rollback, aunque sea 1-2 meses.Preguntar a CDMON qué plan mínimo cubre eso y cuánto cuesta. Decidir antes del 24-jul,
no el 6 de agosto a las 23:00.
feadulta.comestá registrado en CDMON y cuándo caduca. Lacaducidad del hosting y la del dominio son cosas distintas; perder el dominio sería
catastrófico y no lo arregla ningún backup.
gracia, borrado de datos? (CDMON suele dar gracia, pero confirmarlo por escrito, no
asumirlo).
2. Decisiones pendientes (bloquean la planificación fina)
D1 — ¿Qué hacemos con el Joomla legacy (antiguo.feadulta.com)?
Rafa preguntó qué se pierde con la opción estática. Respuesta:
Opción A — mirror HTML estático (
wget --mirror→ static en Coolify). Mi recomendación.com_search), formularios de contacto, login/registrode los 1.182 usuarios, comentarios K2, feeds RSS dinámicos, y la paginación por querystring
(
?start=20) queda capturada solo parcialmente.ocurre 15-47 veces/día), desaparece la BD, el coste operativo es ~0 y es literalmente
inhackeable. Los 301 de
fea-legacy-redirect.phpsiguen funcionando igual.(conclusión ya cerrada en la sesión del cutover). Nada de lo que se pierde tiene uso real.
Opción B — actualizar Joomla 3.10 → 5.x y migrarlo vivo.
en J4/J5 es dudoso. La plantilla vieja se rompe con seguridad. Esto no es una tarde: es un
proyecto en sí, compitiendo por el mismo julio en el que hay que migrar el WordPress de verdad.
arriesgar la migración importante por el sitio muerto.
Opción C — migrar el Joomla 3.10 tal cual (contenedor PHP 7.4 aislado): rechazada. Poner
software EOL sin parches en un servidor que hoy está limpio, para servir un archivo, es el peor
de los tres.
D2 — Correo: ¿Zoho o CDMON degradado?
tenga).
trabajo; (b) Zoho Mail gestionado (es ya el plan para aqtalent en
rafa/server#3): MX/SPF/DKIM en Cloudflare + import IMAP de cada buzón.
muertos).
07/08, no después: cuando el hosting cae, el correo antiguo se va con él.
3. Bloqueantes identificados
token de API (ver #164: ni siquiera se pudo diagnosticar el bug de
antiguopor esto). Elcutover es un cambio en Cloudflare. Si el 03-ago Inma no está disponible, no hay cutover.
en su defecto confirmar su disponibilidad explícita para el 03-ago.
phpCLI ywp-cliya no existen en la jaula SSH de CDMON. El soporte se los llevó alre-sincronizar el jail (2026-07-01). Sin ellos no hay
wp db exportniwp search-replaceenorigen → el dump hay que sacarlo por UpdraftPlus o phpMyAdmin/cPanel. Ver T2.2.
mysqldumpen la jaula. Mismo camino: UpdraftPlus (hace dump diario de BDverificado,
wp-content/updraft/*-db.gz, ~104 MB).tarea de la Fase 4 se ejecuta sin él delante.
4. Plan por fases
Fase 1 — Inventario y validación (17-jul → 22-jul) · remoto, sin tocar nada
df -h,docker system df) y confirmar quecaben ~15 GB con holgura.
/web,/web/antiguo,/web/anterior,tamaño real de la BD, versión de PHP, lista de subdominios, lista de cuentas de
correo, crons activos, certificados.
contra el repo tras #179),
wp_optionscon URLs hardcodeadas.scripts de
scripts/,fea-legacy-redirect.php,sync_audio_to_prod.py,sync_carta_from_prod.py, guía de Mixbot (#158 sigue abierto), skill de Hermes.Fase 2 — Montar el destino y ensayar (23-jul → 27-jul) · remoto, sin tocar producción
paridad con local, lección de summaraise). Features estándar de Coolify, nada ad-hoc.
pull en streaming desde Hetzner (
ssh feadulta@134.0.10.170 'tar czf - -C /web .' > feadulta.tar.gz) — no requiere espacio libre en el origen. Verificar tamaños e integridad.--default-character-set=utf8mb4en el cliente mysql y--init-command="SET SESSION sql_mode=''". ⚠️ Esto es exactamente el bug que produjomojibake (
’) en summaraise: el dump y las tablas ya son utf8mb4, el fallo estaba en elcliente de import. No repetirlo.
fea-staging.rafacalvo.nyc,Cloudflare grey cloud,
noindexpuesto antes de que exista el DNS).wp-content/(no en/usr/local/bin: todo/var/www/htmles el volumen persistente,/usr/local/binse pierde al recrear elcontenedor — lección de summaraise 2026-07-12).
search-replacedewww.feadulta.com→ staging, permalinks, y correr la suiteE2E + verify (#121/#130) contra staging. Coste 0, es exactamente para esto.
autores + avatares, buscador (#178), alta boletín (Brevo), imágenes, audio TTS,
wp-admin/login.
estimación inventada.
Fase 3 — Cerrar flancos (28-jul → 31-jul) · remoto
limit-login-attempts-reloaded+ mu-pluginfea-cloudflare-realip.php. ⚠️ Sin el mu-plugin, LLAR bloquea a todo el mundo (ve la IPcompartida de Cloudflare como origen de todos los intentos). Cierra #18 heredado.
con MX preparados pero sin cortar.
contenido, imágenes) antes de dar por bueno nada.
/web/anterior/(V1 FrontPage) como static.semana. Este es el ensayo general.
Igual que en #163, escrito para que se pueda ejecutar sin improvisar.
publicar cartas ni tocar contenido mientras se ejecuta). Coordinar con el ciclo de la carta
semanal — el cutover no puede caer el día de publicación de la carta.
exista — lección de #153 §1).
Fase 4 — Cutover (lunes 03-ago) · Rafa presente, ejecución validada
search-replacestaging →www.feadulta.com+siteurl/homedefinitivos.www(apex primero, patrón de summaraise).
noindexquitado en producción y puesto en staging.G-6RT9ZRS4LW, portable, no se toca) + Search Console + sitemap.antiguo.feadulta.comsegún D1.Rollback (mientras CDMON viva): revertir el A record en Cloudflare + purgar caché. Segundos.
Por eso §1 es el seguro de todo este plan — sin CDMON vivo, no hay rollback, hay un incidente.
Fase 5 — Estabilización (04-ago → 07-ago y siguientes)
rafa/server#5).(esto no puede quedarse sin dueño).
5. Riesgos
--default-character-set=utf8mb4(T2.3)6. Qué necesito de Rafa / Inma para desbloquear
ventana (T3.8).
Gestiones desde Madrid (16-jul → 01-ago) — no son técnicas, son 3 correos
Rafa está fuera hasta el 01-ago y la preocupación declarada es "que no nos pille el toro".
Siendo honestos: con el plan tal cual, el 03-ago se ejecuta sin red — cuatro días de margen y
un rollback que caduca el viernes 7. Eso no es estar controlado, es que salga bien a la primera.
Lo único que de verdad elimina el riesgo es el §1 (el seguro de CDMON). En cuanto CDMON siga
vivo un mes más, el deadline deja de mandar: el rollback sigue disponible, el correo no se cae, y
si el 03-ago sale regular se para sin drama y se repite el 10. Sin eso, la fecha manda sobre las
decisiones, que es exactamente donde se cometen los errores.
Lo que Rafa puede hacer desde Madrid (por correo, sin consola)
/webcomo rollback ycuánto cuesta, y (b) qué pasa exactamente el 07/08 si no se renueva: ¿corte seco,
periodo de gracia, borrado? Que lo confirmen por escrito. → desbloquea §1.
cutover el día 3, da igual lo bien preparado que esté todo lo demás. → desbloquea B1.
feadulta.comy cuándo caduca. Esdistinto del hosting y no lo salva ningún backup. → T1.5.
Lo que avanzo yo mientras tanto (vía Hermes, sin tocar producción)
Fases 1 → 3 completas: inventario, WordPress+MySQL 8 en Coolify, import a staging, ensayo
completo con la suite E2E, cronometrado y repetido. Nada de la Fase 4 sin Rafa delante.
Objetivo para el 01-ago: que la conversación al volver sea "el ensayo está en verde dos veces
y cronometrado, ¿le damos el lunes?" — y no "¿por dónde empezamos?".
D2 — Inventario de correo (aportado por Rafa, 2026-07-16)
17 buzones, 15 activados + 2 desactivados. Total ≈ 24,0 GB.
Total: 24.614 MB ≈ 24,0 GB. Los tres primeros (
ediciones,amigos,contenido) son el88 % de todo.
Lo que esto cambia
El correo pesa más del doble que el WordPress (~24 GB vs ~11 GB). Deja de ser "un trámite al
final" y pasa a ser la parte más pesada de la migración.
Y rompe la opción Zoho por precio. El correo gestionado se cobra por buzón, y aquí hay 15
vivos. Peor:
ediciones@(12,3 GB) yamigos@(5,6 GB) no caben en los planes de entrada (~5GB/usuario), así que habría que subir de plan a toda la organización por culpa de dos buzones.
Orden de magnitud: 15 usuarios × plan con espacio suficiente ≈ 50-60 €/mes — es decir, casi
lo que cuesta el servidor de Hetzner entero (59 €/mes), para servir correo de una fundación.
No tiene sentido. (Precios a confirmar antes de decidir nada — pueden haber cambiado.)
Recomendación revisada de D2: dejar el correo en CDMON con el plan más barato
Encaja con el §1 (el seguro anti-deadline) y resuelve las dos cosas con un solo trámite:
mantiene los buzones donde están (cero migración IMAP, cero riesgo de perder correo) y mantiene
/webcomo rollback durante la estabilización. D1/§1/D2 dejan de ser tres decisiones y pasan aser una.
Alternativa si CDMON no ofrece un plan solo-correo razonable: un proveedor que cobre por
dominio en vez de por buzón (tipo Migadu, ~90 €/año con espacio de sobra para los 24 GB y
buzones ilimitados) en lugar de uno por-usuario. Sigue habiendo que migrar 24 GB por IMAP
(
imapsync), que es lento y frágil, pero es viable si se hace en julio, no en agosto.Descartado: correo self-hosted en Hetzner (mailcow). Reputación de IP, deliverability y SPF/
DKIM/DMARC son un proyecto permanente, no un contenedor. Y va contra la política de "solo
features estándar de Coolify".
⚠️ Ojo con los buzones a 0 MB — no asumir que están muertos
goretti,inma,rafael,sinli,victordanielmarcan 0 MB. 0 MB no significa "sin uso":puede ser un cliente POP3 que descarga y borra del servidor, que es justo lo que haría alguien
que lleva años con Outlook.
inma@es el caso obvio — Inma está activa a diario. Si se retiranesos buzones por "vacíos" y en realidad son POP3, se pierde la dirección, no el histórico, y
el correo entrante empieza a rebotar en silencio.
Pendiente
los de 0 MB (¿POP3?).
ediciones@son 12,3 GB — ¿archivo histórico de la editorial? Si se archiva a.mboxy se parte del buzón, el requisito baja a ~12 GB y abre más opciones.vicente,victordaniel) — ¿archivar y no migrar?desbloquea D2.
sinli@sugiere SINLI (intercambio editorial). Si hay automatismos colgando de esadirección, un cambio de proveedor los rompe. Verificar antes de mover nada.
D2 (cont.) — segurosabc.com y la opción de mailserver propio: descartada
segurosabc.com está FUERA del alcance
Confirmado por Rafa:
segurosabc.com(empresa a la que damos servicio, 10 buzones, tráfico alto)está en otra cuenta de CDMON, no en la que caduca el 07/08. No se toca en esta migración y
no hay riesgo para un tercero el día 7.
día.
Mailserver propio (mailcow en Hetzner) — evaluado y descartado
Se planteó aprovechar la migración para self-hostear el correo de los dos dominios (27 buzones).
Razones para no hacerlo, en orden de peso:
feadulta. El servidor propio ahorraría ~90 €/año frente a un proveedor por-dominio — a
cambio de mantenimiento permanente, parches, blocklists, backup de 24 GB (que hoy no tiene
dueño) y un punto único de fallo sin redundancia. No sale a cuenta ni contando el tiempo a 0.
casi margen puro (CDMON mantiene, nosotros cobramos la relación). Self-hosteando, ese ingreso
pequeño y pasivo se convierte en una obligación 24/7 y cambia el papel: hoy si el correo se
cae es CDMON; mañana somos nosotros, de madrugada, con una correduría sin partes. Un incidente
de deliverability se come años de ese margen.
desbloqueo justificado) y sus rangos arrastran mala reputación con Outlook/Hotmail — justo
donde reciben los clientes de una correduría.
rafa/server#3se descartócPanel/Hestia porque "un panel reclama los puertos 80/443/25, incompatible con Coolify".
Mailcow tiene exactamente el mismo problema (trae su propio nginx, pelea con Traefik por
80/443). Va también contra [feedback: solo features estándar de Coolify].
Joomla. Un buzón perdido no está en ningún otro sitio. 24 GB en un nodo único sin redundancia.
Si algún día se retoma (septiembre, como proyecto propio y con calma): se estrena con
feadulta, nunca con el cliente que paga.
Decisión D2 (recomendada, pendiente de confirmar precio)
Dejar el correo de feadulta en CDMON con el plan más barato. Resuelve §1 (rollback) y D2
(correo) con un solo trámite, cero migración IMAP y cero riesgo de perder buzones.
segurosabc ni se toca.
Inventario real (medido en solo lectura) — cuenta por cuenta
Antes de nada, un dato que sube la criticidad:
ediciones@ycontenido@no son "el correo de la fundación" — son la base sobre la que se prepara la carta semanal. Los bots leen ahí por IMAP (ediciones@→ escritores, fidel, marcos, mam ·contenido@→ sicre, mam). Si cae el correo, se para la carta.Con la regla mecánica ">5 MB al archivo", el uso diario baja a ~3,5 GB (los 183 correos de +5 MB son el 61% del peso).
Cuatro cosas que cambian el análisis
ediciones@(~30 MB cada domingo) y Regina Goberna = 53% decontenido@. Borrar newsletters es perder el tiempo.ediciones@(12,3) yamigos@(5,6) no cabían en los planes de ~5 GB. Con el archivo son 4,4 y 1,1. Vuelve a la mesa.amigos@NO se tira entero (lo frenó Inma, con razón): los rebotes están mezclados en el INBOX (59%), y hay 3,2 GB de correo humano debajo. Se separan con el estándar RFC 3464, no a ojo.sinli@está configurada y en uso → no tocar. Y lo del POP3, clavado.Riesgo que existe hoy, no en agosto
Ni local ni servidor están completos por separado:
EFFA_alumnos(488 MB) solo está en el disco de Inma, sin copia ni backup. Y ~3.200 correos solo en el servidor (Cajamar y TPV,+GESTION,+tramitados). Cualquier archivo tiene que beber de las dos fuentes.Preguntas para CDMON (Inma llama mañana) — ¿añades alguna?
ediciones@entra sin archivar nada)imap.feadulta.com/smtp.feadulta.com? (los bots apuntan ahí)D1 — decisión actual: Joomla legacy a estático, condicionado a paridad funcional
Decisión de Rafa tras el incidente #183: no actualizar Joomla 3.10 en producción como solución por defecto. El destino preferido de
antiguo.feadulta.comen Hetzner es un mirror HTML estático/read-only.Motivo
BACKDOOR_HtmlMonospaceShellen/web/antiguo/modules/mod_menu/sys.php(28-07-2026).Condición de aceptación (no perder funcionalidad necesaria)
Antes de retirar Joomla, construir y validar un mirror de staging contra el origen/copia forense:
Si la validación demuestra que una función dinámica sigue siendo necesaria, se abre análisis específico para reemplazar solo esa función por una alternativa segura (no reexponer Joomla EOL por defecto).
Consecuencia operativa
No migrar Joomla 3.10 tal cual a Hetzner. Preservar copia forense y datos históricos; desplegar estático una vez validada paridad. #183 mantiene la investigación del compromiso y #184 las lecciones de hardening/auditoría.
Inicio acelerado por incidente de seguridad
A raíz del incidente #183 se prioriza empezar hoy la preparación del mirror HTML read-only de
antiguo.feadulta.comen Hetzner.Objetivo inmediato: disponer de una primera copia pública verificable, sin ejecutar Joomla/PHP ni reutilizar el hosting comprometido. El legacy seguirá disponible solo durante la validación de paridad; no se borrará contenido ni se hará cutover sin comprobar URLs, contenido, imágenes, enlaces y redirecciones.
La migración es ahora una medida de remediación de seguridad, además de preservación histórica.
Plan operativo — mirror estático read-only de
antiguo.feadulta.comen HetznerResponde al arranque acelerado de #180 (comment-456) y a la decisión de remediación de #183
(comment-455). Nada de este plan se ejecuta contra producción sin la aprobación marcada en §9.
0. Restricción de calendario que manda sobre todo lo demás
El mirror se construye crawleando el origen. Si el hosting de CDMON se apaga, la fuente
desaparece. El hosting caduca el 07/08/2026 y en #180 (comment-414) consta que la renovación
estaba desactivada.
prerrequisito de todo el plan, no una tarea paralela.
N:\Backup\ Joomla_db(25 GB: incidente, pre-incidente, Akeeba). Si el Akeeba es reciente y restaurable,es la red de seguridad si perdemos el origen antes de crawlear. Verificar fecha y
restaurabilidad — no basta con que el fichero exista.
1. Principio de diseño: se copia salida HTTP, nunca el filesystem
La regla de #183 ("no trasladar Joomla, PHP, MySQL ni ficheros potencialmente comprometidos") se
implementa con una decisión técnica concreta:
El mirror se genera exclusivamente por HTTP (
wgetcontra el origen). Lo que se captura es loque Joomla renderiza, no lo que hay en disco. Ventajas:
sys.php,rr.phpni ningún.phpejecutable: el servidor devuelveHTML, no código fuente.
Corolario que sí hay que vigilar: el origen estuvo comprometido, así que el HTML renderizado
puede llevar contenido inyectado (spam SEO, JS malicioso). Por eso la Fase 4 incluye un escaneo del
propio mirror — capturar por HTTP evita el código del atacante, no su output. Este es el punto
que se suele olvidar.
Excepción explícita: el archivo histórico frío (tar de
/web/antiguo+ dump Joomla) sepreserva pero NO viaja a Hetzner. Se queda en
N:\Backup\Joomla_dbcon etiqueta de cuarentena,como evidencia y como fuente de reconstrucción. Nunca se ejecuta.
2. Fase 1 — Inventario de URLs (hoy, sin carga sobre producción)
Un crawl recursivo a pelo sobre Joomla es una trampa: K2 genera espacio de URLs infinito
(
?start=,?limit=,print=1,tmpl=component, ordenaciones…) y no garantiza cobertura. Laestrategia es construir primero una lista autoritativa de URLs y usar el crawl como
complemento, no como fuente única.
2.1 Fuentes de inventario (por orden de fiabilidad)
ew4r_k2_items.id/alias/catid,ew4r_content,ew4r_menu)scripts/ga4_report.py(repofeadulta-git)robots.txt+sitemap.xml/ OSMap2.2 Comandos
2.3 Normalización
urls-input.txt+inventory-report.jsoncon el recuento por fuentey el solapamiento. Publicar el recuento aquí antes de pasar a la Fase 2.
3. Fase 2 — Captura (requiere ventana aprobada)
3.1 El problema de Cloudflare, y cómo se resuelve
Todos los hostnames van proxied. Un crawler recibe 403 de bot-challenge — está documentado en
#164 y es exactamente lo que impidió verificar aquel bug. Opciones, en orden de preferencia:
--resolve antiguo.feadulta.com:443:134.0.10.170. Es el patrón que ya funcionó en #164 (allídesde dentro del servidor). Ventajas: sin challenge, sin caché de CF de por medio, capturamos
lo que el origen sirve de verdad. Validar con 1 petición antes de nada — si el origen está
restringido a IPs de Cloudflare, devolverá 403 y pasamos a B.
hace Inma; es la dependencia externa del plan.
tráfico adicional en el host bajo investigación.
3.2 ⚠️ Riesgo de producción: el crawl puede tumbar la web viva
antiguocomparte cuenta, CPU y PHP-FPM conwww.feadulta.com, que está en producción. Y en#168 quedó documentado que el Joomla legacy ya sufre
Allowed memory size exhausted15-47 vecesal día solo con tráfico de bots. Un crawl agresivo sobre ~16.000 items es exactamente esa carga,
multiplicada.
Mitigación obligatoria, no negociable:
--wait=1 --random-wait,--limit-rate=500k.www.feadulta.comdurante el crawl (ver §3.4).3.3 Comando de captura
Decisiones deliberadas:
--convert-links. Convertir enlaces durante la captura destruye la fidelidad deloriginal y hace imposible verificar paridad. La reescritura de enlaces es un paso posterior,
scriptado y reversible (§3.5), sobre una copia.
raw/queda inmutable.--adjust-extensionañade.html, necesario para servir estático; las URLs limpias serecuperan con
try_filesen nginx (§5.2).--reject-regexmata el espacio infinito de URLs de K2. La cobertura de listados se garantizapor la vía del inventario (F1), no por paginación.
/administratorycom_foxcontactya devuelven 403 desde el hardening de #183(comment-452); se excluyen igualmente para no ensuciar el log.
3.4 Verificación en vivo durante el crawl (en otra terminal)
3.5 Post-proceso (derivado, auditable, re-ejecutable)
4. Fase 3 — Artefactos, hashes y trazabilidad
4.1 Estructura
4.2 Manifiestos
4.3 🔒 Escaneo de seguridad del propio mirror (paso que no se salta)
El origen estuvo comprometido. Antes de publicar nada:
Regla de parada: si (1) da positivo, o (4) supera el 0,5% de páginas, la corrida se
descarta. No se publica un mirror con basura de challenge dentro: es la forma más fácil de
congelar para siempre un error transitorio.
lleve inyección previa al incidente.
5. Fase 4 — Despliegue aislado en Hetzner (requiere aprobación)
5.1 Aislamiento: qué NO se toca
feadulta.com. El mirror se publica en un hostname temporal bajoun dominio nuestro:
legacy.rafacalvo.nyc.antiguo.feadulta.com, que sigue sirviendo Joomla durante toda la validación.robots.txtconDisallow: /+ cabeceraX-Robots-Tag: noindexen el staging.5.2 Servicio (features estándar de Coolify, nada ad-hoc)
Bifurcación por tamaño medido en §4.2 — se decide con el dato, no antes:
rafa/rafacalvo-web(ya probado, con deploy key y LE automático).Coolify, contenido sincronizado por
rsync. Sigue siendo estándar (servicio + volumen delpanel), sin labels de Traefik a mano.
5.3 Prerrequisito de capacidad (pendiente de medir)
df -h /ydocker system df.(Quedó sin medir en #180 T1.1 — sigue pendiente y ahora es bloqueante: no sabemos el tamaño
del mirror ni el margen del disco.)
6. Fase 5 — Pruebas de paridad (el corazón de la aceptación)
Comparación automatizada mirror vs origen, URL a URL, sobre el inventario completo.
Qué compara, por URL:
<title><img src>referenciada resuelve 200 en el mirror.phpejecutableactiona endpoint PHP vivo → inertes o con aviso/es/y las URLs de #180/#164 se comportan como está documentadoCobertura, no solo corrección: cruzar el resultado contra F2 (GA4 12 meses). La pregunta
que decide el go/no-go no es "¿está bien lo que capturamos?" sino "¿capturamos todo lo que la
gente visita de verdad?".
6.1 Funcionalidad dinámica que se pierde (condición de #180 comment-442)
com_search)?start=7. Criterios de aceptación (medibles, sin interpretación)
El mirror se considera apto para cutover solo si:
<?php; 0 patrones de §4.3(3) sin justificar; 0 hosts externosno inventariados en
<script>.restantes, explicadas una a una en
parity-diff.csv(bloques rotativos, fechas).MANIFEST-raw.sha256salvouna lista blanca documentada de rutas dinámicas.
www.feadulta.com(§3.4 sin incidencias).8. Rollback y cutover futuro
Durante todas las fases 1-5 el rollback es trivial: no hay nada que revertir. El mirror vive en
un hostname nuestro,
antiguo.feadulta.comsigue intacto sirviendo Joomla. Esa es la razón dediseño de §5.1.
Cutover futuro (fuera del alcance de hoy, requiere aprobación explícita y §7 en verde):
antiguo.feadulta.com→ Hetzner.La retirada es el último paso, nunca simultánea al cambio de tráfico.
9. Qué se puede hacer HOY y qué requiere aprobación explícita
✅ Seguro hoy, sin aprobación (cero carga sobre producción, cero cambios)
mirror-antiguo/y escribir los 3 scripts(
build_url_inventory.py,normalize_mirror_links.py,parity_check.py).robots.txt/sitemap.xml: menos de 10peticiones, comparable a una visita.
extrapolar la duración del crawl completo.
🔐 Requiere aprobación explícita de Rafa
10. Riesgos
N:\como alternativawww.feadulta.com11. Lo que necesito para desbloquear
ejecuta esta semana a contrarreloj o con calma.
Enmienda al plan del mirror: la fuente pasa a ser la copia local, no producción
Propuesta de Rafa: "¿tirar de nuestra copia local de Joomla no sería más fácil para no cargar el
servidor?". Sí — y elimina de golpe los dos riesgos peores del plan. Pero la copia local no
está al día, así que la fuente correcta es híbrida. Datos medidos hoy en solo lectura.
1. Estado real de la copia local (medido, 29-jul)
joomla-web(:8080) +joomla-mysql— levantados, 13 días de uptimeimages/2,3 GB ·media/55 MB)ew4r_contentMAX(created)= 2026-01-08ew4r_k2_itemsMAX(created)= 2026-03-05sef=1,sef_rewrite=1,sef_suffix=1(URLs.html),caching=0live_sitehttps://farmer.taild3aaf6.ts.net/joomla← hay que cambiarlo antes de crawlearEl hueco es real, pero está acotado y es enumerable
El Joomla de producción siguió publicando hasta el cutover del 07/08-jul-2026. La copia local
se queda en enero / marzo. Faltan del orden de 4-6 meses de contenido.
La buena noticia: el hueco no hay que descubrirlo, se conoce por ID. Es exactamente
ew4r_k2_items.id > 17872yew4r_content.id > 8971. Sabemos con precisión qué URLs faltan.2. Plan revisado: fuente híbrida
joomla-web)Lo que esto elimina del plan original
www.feadulta.com. Se acabó el miedo a reventar la web viva (#168) — el crawl grande corre enlocal, a toda velocidad y sin
--wait.ser opcional: solo hace falta para unos cientos de URLs del delta, no para 16.000.
filtros y comparar corridas sin pedir permiso ni ventana. Esto convierte el criterio de
aceptación nº7 (dos corridas → mismo manifest) en algo fácil, no en un lujo.
07/08 perdemos la fuente. Ya no: el 99% de la fuente está en un disco de casa. Sigue
importando para el delta, pero deja de ser existencial.
Lo que hay que cuidar (no es gratis)
live_siteapunta al host local con prefijo/joomla. Si se crawlea así, todos los enlacesinternos y las rutas salen mal. Hay que fijarlo a la estructura de producción antes de
capturar. Cambio local y reversible.
sef_suffix=1→.html). Si difiere,toda la estructura de URLs del mirror es incorrecta. Una lectura por SSH lo confirma — es la
comprobación más barata y más crítica de esta enmienda.
conserva la muestra de paridad contra producción: si local y prod renderizan distinto, salta
ahí. No se elimina la verificación contra prod, se reduce a una muestra.
Decisión de despliegue que queda cerrada
Con 3,5 GB de árbol (2,3 GB solo de imágenes), el mirror estará en el orden de 2-3 GB →
se confirma la rama nginx + volumen persistente en Coolify, no repo git. La bifurcación de §5.2
del plan queda resuelta con dato, no con estimación.
3. 🔴 Hallazgo forense para #183 (salió al inspeccionar la copia local)
La copia local contiene
modules/mod_menu/sys.phpcon el MISMO SHA-256 que la evidencia deproducción:
La copia local se construyó de un backup: su
configuration.phpes de 1-mar y su contenidollega a enero/marzo 2026. Conclusión:
Y hay una implicación peor
En
N:\Backup\Joomla_dbestá el juego de backups del incidente de malware de mayo-2026(
feadulta-20260525-INFECTED+feadulta-clean-20260525; 34 detecciones, "Fase E pendiente").Si
sys.phpsobrevivió a aquella limpieza, entonces la limpieza de mayo fue incompleta y lamisma puerta persistente explica la re-infección de julio. Eso dejaría de ser "dos incidentes"
para ser uno solo mal cerrado.
Se puede bisecar sin esperar a CDmon
La atribución de #183 está bloqueada esperando los access logs del proveedor. Esto no. Tenemos
en local los backups que permiten datar la aparición del shell, gratis y sin tocar producción:
feadulta-20260111-pre-incidente.jpafeadulta-20260306-akeeba.jpafeadulta-20260525-INFECTED.tar.gzfeadulta-clean-20260525.tar.gz(post-limpieza)mod_menu/sys.phpen cada uno (los.tar.gzcontar tzf; los.jpacon elextractor de Akeeba). La pregunta que más importa: ¿está en
feadulta-clean-20260525?Si la respuesta es sí, la limpieza de mayo no cerró la puerta.
4. Fases actualizadas
deja de necesitar aprobación y de tocar el servidor.
live_site, crawlear, iterar.son minutos, no horas.
muestra + delta en vez de sobre el inventario completo.
5. Qué cambia en las aprobaciones (§9 del plan)
Resultado: casi todo el trabajo pesado pasa a la columna "ejecutable hoy sin tocar producción".
6. Pendiente de Rafa
mirror puede salir con toda la estructura de URLs mal.
sys.phpen los backups (§3)? Es offline, no toca nada, y puede cerrarla parte de #183 que hoy está esperando a CDmon.
Fuente definitiva del mirror: backup reciente pre-intrusión, no la copia local ni el crawl a producción
Propuesta de Rafa: "tenemos backups de hace una semana o así… como Joomla no se actualiza desde
hace un tiempo, nos valdría una copia limpia antes de la nueva infección". Correcto, y es
mejor que las dos opciones anteriores. Sustituye al híbrido de comment-465.
1. Por qué funciona: el contenido está congelado
Joomla dejó de recibir contenido en el cutover del 07/08-jul. Desde entonces
antiguo.feadulta.comsirve un archivo estático en la práctica. Por tanto:Y si además es anterior al 29-jul, no lleva el payload de la intrusión. La ventana útil es
por tanto 09-jul → 28-jul, y el backup de "hace una semana o así" cae justo dentro.
Comparativa de las tres opciones
Ventaja que no es obvia: descargar un backup lee disco y red, pero no ejecuta PHP ni MySQL.
Para el servidor compartido es muchísimo más suave que 16.000 peticiones HTTP contra Joomla — que
era justo el riesgo de #168. La opción más segura para producción resulta ser también la más
completa.
Dos aprobaciones que desaparecen
El backup trae consigo el
configuration.phpde producción y el dump de la BD. Con eso:sef_suffix,sef_rewrite) sin la lecturaSSH que pedía comment-465 §2.
2. ⚠️ "Limpia" hay que acotarlo: limpia del 29-jul, no limpia
Este es el punto en el que conviene no engañarse.
sys.phplleva en el sitio desde 2020 (confirmado hoy: la copia local de marzo ya lo tiene conel mismo SHA-256, comment-468). Ese backup lo va a contener. "Anterior a la nueva infección"
significa sin
health-check.php, sinwp-highlitsy sinrr.php— no significa un Joomla sano.El propio
README.mddel archivo de backups ya avisaba de esto para el de enero:"No es garantía de limpieza".
Consecuencias operativas (no negociables)
modules/mod_menu/sys.phpycualquier artefacto conocido antes de levantar el contenedor. No arrancar un Joomla con
una web shell viva dentro, ni siquiera en local.
publica por Tailscale ni se enlaza desde el hub.
incidentes desde 2020 y uno gordo en mayo-2026: el HTML renderizado puede llevar inyección
antigua. Capturar de un backup pre-29-jul evita ese payload, no todos.
N:como fuente y evidencia. A Hetzner soloviaja el HTML generado.
3. Criterios de aceptación del backup (verificables antes de usarlo)
Un backup sirve como fuente solo si pasa esto:
wp-content/mu-plugins/health-check.phpwp-content/plugins/wp-highlits/rr.phpniwp-cl.phpmodules/mod_menu/sys.php(hash8152c22e…)/web/antiguo/completo + dump de BD JoomlaMAX(created)deew4r_content/ew4r_k2_itemsllega al 07/08-jul4. Plan de ejecución revisado
Fase 0 — Identificar y descargar el backup 🔐
formato.
scpestuvo roto en ese host (glibc + jaula sinsftp-server, verfeadulta-server-glibc-rota); tras el arreglo del jail del 01-jul puede funcionar de
nuevo — probar primero con un fichero pequeño. Fallback probado:
ssh feadulta@134.0.10.170 'cat /ruta/backup.jpa' > backup.jpauna instantánea forense del estado pre-intrusión, útil para diferenciar exactamente qué
cambió el 29-jul.
Fase 1 — Restaurar aislado ✅ (local, sin aprobación)
live_sitea la estructura de URLs de producción (comment-465 §2.1).Fases 2-5 — Sin cambios respecto al plan (comment-459)
Inventario → captura → artefactos/hashes → despliegue aislado → paridad. Con una diferencia
importante: la paridad contra producción pasa a ser una muestra pequeña de control, solo para
confirmar que el backup renderiza igual que el sitio vivo. Decenas de peticiones, no miles.
5. Nota lateral para #183: el patrón de fechas «25 de octubre»
Al revisar el
README.mddel archivo de backups aparece un detalle que merece quedar registrado:sys.php→mtime25-oct-2020com_aimysitemapdel incidente de mayo → fechado 25-oct-2025Dos artefactos distintos, de incidentes distintos, ambos con
mtimedel 25 de octubre. Puedeser casualidad, pero encaja mejor con backdating sistemático por parte del mismo actor o el
mismo kit. Vale la pena comprobar si otros artefactos comparten ese patrón: sería un indicador útil
para buscar persistencia que se nos haya pasado.
6. Pendiente de Rafa
ventana de §3.
plan, y es una lectura secuencial, no carga de PHP.
sys.phpen los backups históricos (comment-468) — en especialfeadulta-clean-20260525, para saber si la limpieza de mayo dejó la puerta abierta.Verificación en servidor (29-jul): 3 bloqueantes caen, y no existe el backup de hace una semana
Comprobado hoy por SSH, todo en solo lectura. Corrige varios supuestos del plan.
1. ✅ SSH funciona a los dos servidores
La denegación del 16-jul era puntual, no un bloqueo permanente. Ambos responden.
Hetzner — queda cerrado T1.1 / §5.3
423 GB libres. El mirror (~2-3 GB) y el WordPress completo (~11 GB) caben con muchísimo margen.
Esta incógnita, abierta desde el 16-jul, deja de ser bloqueante.
CDMON
2. ✅ Caen los bloqueantes B2 y B3 del plan:
mysqldump,phpywp-cliestán de vueltaEl plan (#180 §3) daba por perdidos
phpCLI ywp-clidesde el resync del jail del 01-jul, yasumía que no había
mysqldump— de ahí la dependencia de UpdraftPlus para sacar la BD.Ya no aplica. Consecuencias:
Mirror: se puede volcar la BD Joomla (
fejoomla3) directamente conmysqldump, sinreconstrucciones raras ni el truco de
HEX()por tabla.Migración principal de WordPress: T2.2 se simplifica igual —
wp db exportvuelve a estardisponible. Esto afecta al plan maestro, no solo a este workstream.
Actualizar §3 (B2/B3) del plan maestro para reflejarlo.
3. ❌ No hay ningún backup reciente en el servidor
Buscado en
/entrada,/backup, directorios de salida de Akeeba y todo/web/antiguo(ficheros/backup/feadulta.com-10-10-2025-1241.zip/entrada/feadulta-com-web-clean-20260525.tar.gzN:)…/com_akeeba/backup/.log.php, los.jpaya no están4. 💡 Mejor alternativa: generar el snapshot nosotros hoy (y sale más limpio)
Si los del panel no aparecen o son incómodos de extraer, no hace falta esperar. Y hay un argumento
para preferir un snapshot de hoy incluso si aparece el de hace una semana:
contenido histórico, igual que uno de hace una semana.
sys.phpya está en cuarentena — verificado hoy en el servidor:neutralizada por la contención de #183 (comment-445).
/web/antiguo/— vivían en/web/wp-content/,otro árbol. El legacy no está afectado por el payload del 29-jul.
Cómo, sin escribir nada en el servidor
Dimensionado medido
images+media+cachesolo explican 4,7 GB de los 14. Puede haber backups anidados o material que no hacefalta para el mirror. Revisar y afinar exclusiones — bajaría bastante la descarga.
5. Nota forense para #183:
sys.phpy los temporales comparten segundo exactoAl inspeccionar
/web/antiguo/tmp/aparecen 4 ficheros con fecha del 25-oct, el mismo patrón defecha que ya señalé. Los revisé antes de sacar conclusiones: son fragmentos XML de hojas de
cálculo XLSX (temporales de PHPExcel), no un instalador de malware. Descartado como artefacto
malicioso.
Pero queda un dato que sí es relevante:
Separados por 1 segundo. Es el mismo tipo de correlación temporal que #183 usó para vincular
los dos backdoors de WordPress (44 s). Sugiere que
sys.phpy esos temporales se escribieron en lamisma operación — sea una restauración/copia masiva que preservó mtimes, sea backdating del
mismo tooling.
Detalle adicional sin explicación: el directorio
tmp/tiene mtime 2025-10-25 13:40, cincoaños después pero mismo día y mes. Lo dejo registrado sin construir una teoría encima.
6. Pendiente de Rafa
tara stdout porSSH. Es la única acción sobre producción que pide este plan.
/web/antiguopara afinar la descarga?Los 9 GB: son backups de Akeeba — pero no hay backup automático, y el último es de mayo
Rafa acertó: los ~9 GB sin explicar de
/web/antiguoson backups. Pero al listarlos completos(antes había truncado la salida con
head) aparecen dos cosas que cambian el plan.1. Dónde están los 14 GB
2. ❌ No existe backup diario ni semanal
En el directorio de salida de Akeeba solo hay tres archivos completos, y ninguno automático:
…DP_213gSrB86EMc_.jpa+.j01…1os52_Qdg5-vCycH.jpa+.j01…rPlHC3AhISdZpLnb.jpa+.j01…r_gE6hZcBq5Pd2wJ.j01(10 MB, sin.jpa)Separación de ~2 meses y el último hace más de dos meses. Eso no es una programación diaria ni
semanal: es una cadencia manual, coincidiendo con hitos e incidentes (el de mayo es justo el de
la limpieza de malware). Si hubo un cron de Akeeba, no está corriendo.
pendiente de "estrategia de backups sin dueño" de la Fase 5 del plan.
Además, los tres ya están descargados
Los tres archivos coinciden nombre por nombre con los de
N:\Backup\Joomla_db(incluido el de26-may en
feadulta-clean-20260525/akeeba/). No hay nada nuevo que traer de ahí.Y ninguno sirve como fuente del mirror
El más reciente es del 26-may, y el cutover fue el 07/08-jul → le faltan ~6 semanas de
contenido Joomla (las últimas cartas y sus artículos). Ningún backup existente tiene el archivo
completo.
3. ✅ Consecuencia buena: la descarga baja de 14 GB a ~3 GB
Excluyendo lo que no hace falta:
4. No hace falta Akeeba (ni desbloquear el admin de Joomla)
Rafa no puede lanzar un backup desde el panel porque el administrador está bloqueado a propósito
(#183 comment-452) — y no conviene reabrirlo en mitad del incidente.
No hace falta. Con
mysqldump,tar,phpywpdisponibles otra vez en la jaula, noshacemos el snapshot nosotros, sin tocar la contención:
Recordatorio de por qué el snapshot de hoy es preferible a uno de hace semanas:
sys.phpya estáen cuarentena y los backdoors de WordPress nunca tocaron este árbol.
5. 🔴 Hallazgo de seguridad para #183: los backups están dentro del árbol web
8,6 GB de backups completos del sitio — incluida la base de datos — viven dentro del directorio
servido por web. La única protección es un
.htaccessde 821 bytes en esa carpeta.Por qué importa en este incidente concreto:
El atacante tuvo escritura arbitraria de ficheros (
sys.phppermite subida;rr.phpse usópara plantar los backdoors). Con escritura, ese
.htaccessse sobrescribe o se borra, y los.jpapasan a ser descargables por HTTP.Esos archivos contienen el dump completo de
fejoomla3: los 1.182 usuarios con sushashes de contraseña, emails y datos de la etapa Joomla.
No hay evidencia de que ocurriera — pero tampoco tenemos los access logs para descartarlo
(siguen pendientes en el ticket
#cdmoncc274423).Evaluar en #183 como posible exposición de datos, no solo como mala práctica.
Sacar los
.jpafuera del árbol web (ya los tenemos duplicados enN:) — es unareducción de superficie inmediata y sin coste, independiente del mirror.
Al pedir los access logs, incluir explícitamente peticiones a
*.jpa/*.j01.6. Pendiente de Rafa
.jpadel árbol web (§5)? Están duplicados enN:, así que no se pierde nada.Esto sí es una escritura en producción y no lo hago sin tu OK.
Cerrada la incógnita del login en el mirror (§6.1)
Rafa confirma que los 1.182 usuarios no son cuentas de uso real: existen como registros de
autoría, y nadie entra al sitio salvo los webmasters e Inma.
Esto resuelve una de las condiciones de aceptación que quedaban abiertas en la tabla de §6.1 del
plan (comment-459):
El estático no pierde nada por no tener login. Con esto, de las funciones dinámicas del §6.1
quedan pendientes de medir solo buscador interno, feeds RSS y paginación — las tres por
tráfico GA4, no por opinión.
Las páginas de autor sí se conservan: son contenido renderizado (nombre, avatar, listado de
artículos), no dependen del sistema de login. Se capturan como cualquier otra página.
✅ Fase 0 ejecutada: snapshot capturado y backups retirados del árbol web
Autorizado por Rafa el 29-jul. Todo verificado.
1. Artefactos capturados
Destino:
~/joomla-migration/mirror-antiguo/source/· log completo enrun-20260729.log.2. Criterios de aceptación (§3 del plan) — todos pasan
health-check.php,wp-highlits,rr.php,wp-cl.phpsys.phppresente solo en cuarentenasys.php.quarantined-incident-183gzip -tOK en ambosVerificación del punto 7 (consultado en origen):
El último contenido publicado en Joomla es del 2-jul; el cutover fue el 7/8-jul. Coherente: en
la última semana ya no se publicó en Joomla (la carta 734 fue directa a WordPress). El archivo
está completo hasta la congelación.
Comparado con la copia local (
ew4r_content8.971 /k2_items17.872, de ene/mar), el snapshotaporta +205 contenidos y +345 items K2. Confirma que la copia local no servía y que el snapshot
fresco era la vía correcta.
3. Cambio aplicado en producción:
.jpafuera del árbol webLos 7 archivos movidos a
/entrada/akeeba-archive-retirado-20260729(fuera del directorio servidopor web). Movidos, no borrados — reversible, y además duplicados en
N:\Backup\Joomla_db.Cierra la acción de #183 comment-479/481.
4. Incidencia técnica resuelta
mysqldumpfallaba conTLS/SSL error: SSL is required, but the server does not support it. Elcliente de la jaula no acepta
--ssl-mode=DISABLED(variable desconocida); funciona con--skip-ssl. Anotado para futuros dumps desde ese host.⚠️ El primer intento produjo un
.sql.gzde 20 bytes que pasabagzip -t— un fichero vacíoválido. Por eso la verificación cuenta
CREATE TABLE, no solo integridad del contenedor: comprobarsolo que "el fichero existe y no está corrupto" habría dado por bueno un dump vacío.
5. Siguiente
Fase 1 — restauración aislada en local: cuarentena previa al arranque, escucha solo en localhost,
live_sitea la estructura de producción, y verificación de config SEF contra elconfiguration.phpreal (que ahora viene dentro del snapshot, sin necesidad de más accesos alservidor).
✅ Fase 1 completada: Joomla legacy restaurado y aislado en local
Ejecutado por agente (Sonnet) sobre el snapshot de comment-483. Log completo en
~/joomla-migration/mirror-antiguo/restore/restore-log.md.Puertas de control — 6/6 en verde
ew4r_content9.176 (max 2026-07-02 21:22:50) ·ew4r_k2_items18.217 (max 2026-07-02 18:28:29)/→ 301 a/es/→ 200 con contenido real (idéntico a producción)sys.phpsolo como.quarantined-incident-183docker port→ solo127.0.0.1:8086Config SEF real de producción (pregunta que quedaba abierta)
Las URLs terminan en
.html— confirmado además en el crawl real. Coincide con la copia local,así que la estructura de URLs del mirror es la correcta. Esto era el riesgo señalado en
comment-465 §2.2 (si difería, todo el mirror salía con rutas mal). Queda cerrado.
⚠️ Desviación: una petición llegó a producción
En el primer intento de crawl, Joomla devolvió una redirección absoluta
(
Location: http://antiguo.feadulta.com/es/) — consecuencia esperada de dejarlive_site=''. wgetresolvió ese hostname por DNS real y acabó pidiendo a la producción real, que respondió 403
(bloqueo de bot de Cloudflare).
GETbloqueado por Cloudflare. Sin datos enviados, sin credenciales, sin efectosobre el sitio.
127.0.0.1con cabeceraHost, sin anticipar que una redirección absoluta re-resolvería por DNS. Faltaba fijar laresolución desde el principio.
en la misma red que
joomla-mirror-web, con--add-host antiguo.feadulta.com:<IP interna>. Enel log consta que las conexiones fueron a la IP interna, no a producción.
de #183. Tráfico nuestro contra producción ensucia esos mismos logs. Conviene registrar la
hora exacta de esta petición para poder descartarla al analizarlos.
Crawl de prueba
206 ficheros (205
.html) en 72 s · 11 MB. Estructura SEF respetada(
es/effa/88-sec3cat/2888-secc3col01.html). Acentos correctos en el HTML capturado.Extrapolación: ~105 min solo para el HTML de ~18.000 ítems, más lo que añadan las imágenes y
CSS de
--page-requisites. El crawl completo es una tarea de ~2 h.Notas técnicas del montaje
php:7.4-apacheno traíamysqli,pdo_mysql,gd,zipnimod_rewrite(el
joomla-weboriginal los tenía instalados en caliente sobre su capa). Sin eso, 500 en todo.force_sslbajado de'2'a'0'porque el mirror local se sirve por HTTP plano. No afecta ala estructura SEF.
LIKE '%ó%'da positivos masivos porque lacollation
_cipliega acentos; repetido conLIKE BINARY. El únicoÃreal de la BD es untítulo legítimo en portugués (
CORAÇÃO FERIDO), no mojibake.Siguiente
Crawl completo (~2 h), con el patrón de contenedor aislado ya validado. Pendiente de autorización.
❌ Crawl fallido: trampa de paginacion de Joomla tumbo la VM de WSL — post-mortem
El crawl completo lanzado el 29-jul a las 15:14Z no termino. A las 21:02Z Beszel alerto por
Telegram: la maquina local dejo de reportar. Post-mortem completo.
Causa raiz
Los enlaces de paginacion de Joomla acumulan el parametro
starten vez de reemplazarlo. Cadapagina servida contiene enlaces con un
&start=mas que la anterior:Espacio de URLs infinito. wget lo siguio durante 3 horas.
Impacto en lo capturado
/es/buscadoravanzado//es/indice-multimedia/es/lista-completa-de-autores…/es/cartas33.518 ficheros / 3,5 GB, mayoritariamente inservibles. La corrida se descarta.
Cadena de fallo (log de Apache del contenedor)
Cada peticion con
start=15830fuerza a Joomla a resolver un offset de 15.830 sobre ~16.000 items:coste creciente hasta el fatal. Los workers de Apache se apilaron en peticiones colgadas y, con
memory=12GBen.wslconfigpara ~15 contenedores, la VM entera dejo de responder.Es el mismo modo de fallo de #168 (el Joomla de produccion hace OOM 15-47 veces/dia por trafico
de bots), reproducido en local a gran escala por nosotros.
Nota diagnostica:
docker inspect fea-crawlerdaOOMKilled: false. El consumidor de memoriano era wget sino PHP. La primera hipotesis (crawler desbocado en RAM) era incorrecta.
Responsabilidad
Decision mia y explicita: al lanzar el crawl quite
startylimitstartdel--reject-regexdel plan para "mejorar cobertura". Ese filtro existia exactamente para esto.
Estado tras la caida (verificado)
sha256sum -c MANIFEST-source.sha256→ OK en los dos ficheros.dmesg.Correcciones para el relanzamiento
--reject-regexoriginal (constart,limitstart) y ademas rechazar URLscon el mismo parametro repetido.
ew4r_k2_items,ew4r_content,ew4r_menu) ywget -i lista.txtsin recursion libre. Acotado por construccion: sabemos cuantas URLs deben salir.La trampa demuestra que el archivo profundo no es alcanzable navegando — el inventario no es
una alternativa mas elegante, es la unica via correcta. El plan original acertaba.
--memorytanto al crawler como al contenedor deJoomla (este es el importante: habria contenido el fatal de PHP en su contenedor en vez de
arrastrar la VM).
triptyk) para no pelear por los 12 GB.
el primer 500 salio a las 21:01 y el monitor no lo veia porque solo contaba ficheros.
📌 ESTADO AL CIERRE DEL 29-jul — punto de retomada
Documento de continuidad: todo lo necesario para reanudar sin releer el hilo. Cierra la jornada del
29-jul (incidente #183 → arranque acelerado del mirror).
Dónde estamos
Artefactos que existen (verificados tras la caída)
Fuente —
~/joomla-migration/mirror-antiguo/source/·sha256sum -c MANIFEST-source.sha256→ OKContenido:
ew4r_content9.176 filas ·ew4r_k2_items18.217 (16.323 publicados) · ambos conMAX(created) = 2026-07-02, es decir archivo completo hasta la congelación del cutover.Basura a borrar —
~/joomla-migration/mirror-antiguo/runs/20260729T151416Z/(3,5 GB, 74% páginasde paginación inútiles). Pendiente de que Rafa autorice el borrado.
Entorno: qué hay que rearrancar
Tras
wsl --shutdowntodos los contenedores de Rafa volvieron solos salvo los dos nuestros(sin política de restart):
Datos del montaje: red
joomla-migration_joomla-net· IP172.20.0.5· imagenphp:7.4-apache·BD
joomla_mirrordentro del contenedorjoomla-mysqlexistente.⚠️ La imagen base necesitó
mysqli,pdo_mysql,gd,zipymod_rewriteinstalados en caliente— se pierden si el contenedor se recrea (no si solo se para/arranca). Si hay que recrearlo, hay
que reinstalarlos.
Dato de producción ya confirmado (no hay que volver a preguntarlo)
Siguiente paso: crawl acotado por inventario
No repetir la recursión libre. Diseño corregido:
joomla_mirror, sin tocarproducción):
ew4r_k2_items+ew4r_content+ew4r_menu→ rutas SEF con sufijo.html.wget -i urls.txtsin--recursive(o--level=1solo para requisitos de página).--reject-regexrestaurado constartylimitstart, más rechazo de parámetros repetidos.--memory=2gen el crawler y enjoomla-mirror-web← la corrección que habría evitado lacaída de la VM.
triptyk) para no pelear por los 12 GB de la VM.
Criterio de cobertura: el nº de páginas de item capturadas debe acercarse a los 16.323 items
publicados. En la corrida fallida solo se capturaron 100 páginas de
/es/cartas— sirve delínea base de lo que NO puede volver a pasar.
Decisiones que siguen abiertas (no dependen de esto)
(tenemos el snapshot en local), pero sigue siendo el rollback de la migración de WordPress.
(Nota irónica: el buscador avanzado, que es lo que provocó la caída, es justo una de las
funciones cuyo uso real hay que medir antes de decidir si se replica.)
🔁 Fase 2 relanzada — crawl acotado por inventario (corrida
20260729T224708Z)Retomo desde comment-499. Resumen de lo hecho antes de lanzar y del diseño definitivo del crawl.
Autorizaciones de Rafa (antes de irse a dormir)
runs/20260729T151416Z/(3,5 GB de basura de paginación).Los ficheros eran de root (crawler en contenedor) → hubo que borrarlos como root.
mirror-antiguo/stopped-containers.txtpara rearrancarlos al terminar(
scripts/60-restaurar-entorno.sh):portfolio-tracker, yt-api-bridge, wordpress-web, joomla-web-php83, jellyfin, triptyk-local-wordpress-1, triptyk-local-db-1, yt-summaries, joomla-web, wordpress-mysql.En marcha se quedan solo:
joomla-mirror-web,joomla-mysql,gitea,hub-proxy,beszel-agent.🐛 Hallazgo de entorno: el contenedor no "se caía", lo mataba WSL
joomla-mirror-webse paraba solo ~30 s después de cadadocker start, conSIGWINCHy exit 0.No era el contenedor: la VM de WSL2 se apagaba entre comando y comando (sin
vmIdleTimeouten.wslconfig, el host la termina cuando no queda ninguna sesión abierta). Al apagarse, Docker parabasus contenedores ordenadamente; los que tienen
restart: alwaysvolvían al arrancar de nuevo, y losnuestros —sin política de reinicio— no. Eso explica también por qué al principio de la sesión todos
los contenedores aparecían con
Up 3 seconds.Dos correcciones, ambas aplicadas:
docker update --restart unless-stopped joomla-mirror-web— sobrevive a un reinicio del demoniosin recrear el contenedor, así que no se pierden las extensiones PHP instaladas en caliente
(
mysqli,pdo_mysql,gd,zip,mod_rewrite).También se le puso el límite de memoria que faltaba:
docker update --memory 2g(corrección 4 decomment-497), igualmente sin recrear.
Fase 1 — Inventario: lo genera el router del propio Joomla
En vez de reconstruir a mano las rutas SEF (frágil: K2 +
sef_suffix+ prefijo de idioma), elinventario lo produce el propio Joomla: un script en la raíz del sitio restaurado arranca el
framework por HTTP y llama a
JRoute::_()/K2HelperRoute::getItemRoute(). Las URLs son, porconstrucción, idénticas a las que el sitio imprime en su HTML.
scripts/10-build-inventory.sh→inventory/urls-input.txtDatos del sitio que salieron de paso y conviene tener escritos:
es-ES(sefes).en-GBestá en estado-2. Todo elcontenido es
language='*'. El sitio hace 301 de/loquesea→/es/loquesea, así que elinventario se genera ya con el prefijo
/es/y nos ahorramos 25.437 redirecciones.feadulta,sincategoria) y un único ítem de menú de K2: elbuscadoravanzado(Itemid 138) — que es exactamente el que provocó la caída de ayer. Las URLs deítem son
/es/buscadoravanzado/item/<id>-<alias>.html.sitemap.xmlen la raíz;robots.txttraeCrawl-delay: 60yDisallow: /images/→el crawl usa
-e robots=off(es nuestra propia copia local, no un tercero).Validación previa: muestra aleatoria de 40 URLs del inventario contra el Joomla local →
40/40 = HTTP 200, 0,68 s de media por petición.
Lote de prueba (200 URLs) — verde
<?phpDiseño del crawl definitivo — las 5 correcciones de comment-497, aplicadas
wget --input-file=<lote>, nada de--level. El espacio de URLs es finito yconocido de antemano: 25.437.
--reject-regexrestaurado y ampliado, ahora constart,limitstart,limit,print,tmpl,format,searchword,task,orderby,filter,catid,month,year.(Con
-iy sin recursión es un cinturón sobre los tirantes, pero el filtro vuelve a estar.)--memory=2gen el contenedor que sirve — la corrección que habría contenido el fatal de PHPen su contenedor en vez de arrastrar la VM entera.
scripts/22-monitor.shrevisa cada 60 s el log de Apache y aborta el crawl si pasa de 100 respuestas 500 o si la RAM
libre baja de 800 MB.
Detalle de implementación: el crawler ya no corre en un contenedor sino como
wgetnativo enWSL, con
172.20.0.5 antiguo.feadulta.comen/etc/hosts. Ventajas: los ficheros salen con dueñorafa(los de la corrida anterior eran de root y hubo que borrarlos consudo), y el árbol quedaigualmente bajo
raw/antiguo.feadulta.com/…, con las URLs canónicas de producción.El inventario se reparte en 4 lotes que se capturan en paralelo. Son conjuntos de URLs disjuntos,
así que no compiten por los mismos ficheros. Sin
--page-requisitesen este pase: los recursos vanen un pase B aparte, guiado por los enlaces que aparezcan en el HTML capturado (así el conjunto de
assets también queda inventariado y auditable, en vez de depender de la recursión de wget).
raw/es inmutable. La normalización de enlaces se hará sobre una copia (site/), como en §3.5del plan.
Estado ahora mismo
Siguientes pasos de esta noche, ya scriptados:
30-extract-links.py(assets + huecos de cobertura) →31-fetch-assets.sh→40-manifest-scan.sh(sha256 + escaneo de seguridad §4.3) →50-parity.py.No se toca producción en ningún punto. El origen del crawl es el Joomla restaurado en local.
✅ Fase 2 COMPLETA — mirror capturado, escaneado y verificado (corrida
20260729T224708Z)El crawl que quedaba pendiente está hecho. Resumen de la noche del 29→30 de julio.
Derivado
site/(link-rewrite.json): 355.818 enlaces absolutos reescritos a raíz-relativos en27.203 ficheros · 139 vistas de impresión descartadas · 5 formularios neutralizados · 614 ficheros
con la copia de nombre limpio que necesita un servidor estático.
Los cuatro pases
/ediciones/anteriorCobertura sobre el inventario: 25.435 / 25.437 = 99,99 %. Las 2 que faltan son 404 en el propio
origen (
donaciones.html→ artículo despublicado;libroresumen2.html→ su categoría está en lapapelera). Sumando los 6 ítems de menú que apuntan a componentes desinstalados (
com_surveys,com_breezingforms), el total de 404 legítimos es 8 — y no hay ni una sola URL existente sincapturar.
🔒 Escaneo de seguridad del mirror (§4.3) — verde
<?php.phpen el árbolindex.phpes un directorio, creado por 17 enlaces no-SEF/index.php/es/…)eval(,atob(,fromCharCode…)Hosts externos referenciados en
<script>/<iframe>, por volumen de páginas:Ninguno es sospechoso, pero hay una decisión que tomar (abajo, punto 1).
Paridad — muestra de 500 URLs
La primera pasada daba 194 diferencias de texto con exactamente la misma longitud en ambos lados,
lo que ya olía a contador. Al diffear una: la única línea distinta era
Read <b>1313</b> times→Read <b>1315</b> times. Es el contador de visitas de K2, queincrementa nuestra propia petición: dos lecturas seguidas de la misma página nunca coinciden. Se
normaliza y el resultado es paridad total.
Trampas encontradas (las que no estaban previstas)
1 · El contenedor "se caía solo" cada 30 s — era WSL, no el contenedor.
Sin
vmIdleTimeouten.wslconfig, la VM de WSL2 se apaga cuando termina la última sesión abiertadesde Windows. Al apagarse, Docker para sus contenedores ordenadamente (Apache recibe
SIGWINCHysale con código 0, que es justo lo que parecía un cierre limpio inexplicable). Los que tienen
restart: alwaysvolvían solos; los nuestros, sin política, no. La señal que lo delata: todos loscontenedores con
Up 3 secondsa la vez. Corregido con un proceso ancla en WSL durante toda lanoche y
docker update --restart unless-stopped(que además no recrea el contenedor, así que nose pierden las extensiones PHP instaladas en caliente). El
--memory 2gque faltaba se puso por lamisma vía.
2 · Unos 198 artículos llevan un
?dentro del alias (…-dios-nos-ama?.html). Para cualquiercliente HTTP el
?abre la query, así que la ruta real termina antes: el navegador pide…-dios-nos-ama. wget guarda el fichero con el nombre completo (…-ama?.html), que ningúnservidor estático encontrará. El derivado
site/deja también la copia con el nombre limpio.Requisito de despliegue en el punto 2 de abajo.
3 · Recursos con cache-busting (
core.js?a32fb8ee…): mismo problema, misma solución.4 · 206 ítems de menú salían como
?Itemid=NporqueJRouteno construye ruta solo con elItemid. La fuente autoritativa es la columna
pathde#__menu. Regenerados y verificados.5 · Las páginas de autor de K2 (
itemlist/user/…, 1.178) no estaban en el inventario y estánenlazadas desde cada ítem. Sin ellas el mirror tendría 1.178 enlaces rotos. Capturadas en el pase C.
6 ·
/anteriorse empezó por recursión y se corrigió a inventario. Iba bien pero se estabacomiendo el tiempo en 404: la web antigua está llena de enlaces rotos (imágenes de los
_archivos/de exportaciones de Word). Llevaba 3.050 aciertos por 2.744 fallos. Cambiado a inventario —la lista
de rutas del snapshot restaurado, no su contenido, igual que se hace con la BD— y bajó los 34.036
ficheros en 2 m 51 s con 0 errores. Es literalmente la lección de comment-497 aplicada por segunda
vez en la misma noche.
7 · 1.960 respuestas 404 en el pase C, todas explicadas. 1.769 de ellas son ítems K2 con
published=0enlazados desde el HTML del propio sitio: enlaces rotos del original, reproducidosfielmente. Se cruzó cada id contra la BD para confirmarlo; ninguno falta de la base de datos. El
resto: 137 rutas de imagen mal formadas en el contenido y los 8 menús muertos.
Prueba de humo del despliegue — hecha, y la tienes levantada
Antes de proponerte nada he montado
site/con nginx real (contenedormirror-nginx-test,256 MB,
127.0.0.1:8087) con la configuración candidata, y he comprobado las rutas críticas:/es/<title>Feadulta</title>, 44 KB?en el alias/anterior/Puedes verlo tú en http://localhost:8087 cuando te levantes. Para tirarlo:
docker rm -f mirror-nginx-test. La configuración está enmirror-antiguo/deploy/nginx-mirror.confy es la que habría que llevar a Coolify.
Lo que necesito de ti
cargan GTM y 16.792 traen los widgets sociales. En un mirror que solo se consulta, eso es
telemetría hacia terceros sin ninguna función. Mi recomendación: quitarlos en
site/(es unsedsobre el derivado,raw/no se toca). Dime si tiro por ahí.?:default_type text/htmlpara los ficheros sin extensión, ytry_files $uri $uri.html $uri/index.html.Lo dejo escrito en el README para no re-descubrirlo.
persistente en Coolify, no de app estática desde repo git. Y sigue pendiente §5.3: medir el
disco libre del Hetzner, que necesita acceso SSH de solo lectura (bloqueado desde el 16-jul).
/anterior? Lo he capturado (704 MB de los 5 GB) porque está enlazado desde elmenú principal y 623 de sus páginas se enlazan desde el contenido de Joomla. Si prefieres dejarlo
fuera, se quita del despliegue sin recapturar nada.
las URLs con tráfico real responden 200"). Requiere que abras tú la URL de OAuth, no se puede
automatizar.
Estado del entorno
Los 10 contenedores parados para el crawl están rearrancados y verificados.
joomla-mirror-websigue en pie con
restart: unless-stopped. La entrada172.20.0.5 antiguo.feadulta.comse quedó en/etc/hostsde WSL (inofensiva, y necesaria si se relanza el crawl).Método completo documentado en
mirror-antiguo/README.mdy scripts numerados por orden de ejecuciónen
mirror-antiguo/scripts/.Artefactos de la corrida
~/joomla-migration/mirror-antiguo/runs/20260729T224708Z/El snapshot fuente sigue intacto y verificado:
sha256sum -c MANIFEST-source.sha256→ OK.🔎 Addendum: verificación contra Internet Archive (fuentes F3 y §4.3)
Con el mirror ya cerrado he hecho las dos comprobaciones del plan que dependían de Wayback y que
no tocan ni el origen ni producción: son consultas de solo lectura a
web.archive.org.F3 — Inventario histórico
Cruzado contra el mirror, el dato global (52,3 %) no significa nada y conviene decirlo antes de
que alguien lo cite: Wayback conoce
feadulta.comdesde antes de que existiera el Joomla (ficheros.htmsueltos en la raíz, que hoy viven bajo/anterior/) y también conoce el WordPress actual(
/wp-content,/wp-json). Nada de eso es el legacy.Acotado a lo que el mirror sí cubre — las URLs
/es/del Joomla:/es/que Wayback conoceDe las 4.387 que faltan, 1.994 son de
/es/buscadoravanzado— coherente con los 1.769 ítems K2con
published=0que ya identificamos: contenido que el propio sitio dejó de publicar en algúnmomento de estos años. El resto es en buena medida ruido que Wayback registró tal cual:
/es/&,/es/),/es/10, rutas de carga de Fox Contact (.../task/loader.load/type/css/...), URLs porfecha. Detalle en
wayback-es-missing.txt.§4.3 — Contraste contra Wayback para descartar inyección: limpio
Era el último punto del escaneo de seguridad que quedaba sin hacer. No compara el texto (una
instantánea de hace años difiere por fuerza), sino lo que de verdad delataría una inyección: a qué
hosts externos carga cada página scripts o iframes, en nuestra captura frente a la versión
histórica.
12 páginas al azar, las 12 con snapshot disponible. Hosts presentes en nuestra captura y ausentes en
la de Wayback:
Ni un solo host desconocido. Los cuatro son integraciones del propio sitio (analítica y botones
sociales) añadidas después de esas instantáneas, y son exactamente los que ya aparecían en el
inventario de hosts externos del escaneo general.
Matiz honesto: esto descarta que se colara un script de un tercero desconocido; no prueba que las
instantáneas sean contemporáneas de la captura. Combinado con los otros tres resultados —0 ficheros
con
<?php, 0 páginas de challenge, y los 3 ficheros marcados identificados como jQuery, MooTools yNivo Slider— el §4.3 queda cerrado en verde.
Informe completo en
wayback-contraste.json,wayback-report.jsonywayback-es-report.json.🧹 Botones sociales retirados · decisiones de Rafa sobre el mirror
Respuestas de Rafa a las decisiones de comment-502, y lo ya ejecutado.
/anteriorBotones sociales — retirados de
site/,raw/intactoEl bloque de K2 resultó ser uniforme:
<div class="itemSocialSharing">contenía solo el botónde Twitter, el de Facebook y un clearfix — comprobado sobre 400 páginas antes de tocar nada, 0
variantes. Se elimina el bloque entero, así no quedan huecos ni botones a medias.
Quedan 2 coincidencias de
itemSocialSharing: la regla CSSdiv.itemSocialSharing {padding:8px 0;}en
k2.css. Inocua, es estilo de una clase que ya no aparece.Prueba de humo repetida tras el cambio: las 10 rutas críticas siguen en 200 con el Content-Type
correcto y la portada sigue midiendo 44.722 bytes.
MANIFEST-site.sha256regenerado;site/pasa de5,0 a 4,9 GB.
❓ Google Tag Manager: qué es exactamente lo que hay
No es un contenedor de GTM propiamente dicho (
GTM-XXXX), esgtag.jsservido desdegoogletagmanager.com, que es el cargador de Google Analytics. En el mirror hay dos IDs:
G-6RT9ZRS4LWUA-32008163-1UA-32008163-1está muerto: Universal Analytics dejó de procesar datos en julio de 2023. Es pesomuerto que se lleva arrastrando en 27.374 sitios del HTML.
El dato que decide, y por eso lo he comprobado antes de opinar:
G-6RT9ZRS4LWes el mismo ID demedición que usa el WordPress vivo de feadulta (aparece en el WordPress local y en el repo). No es
una propiedad separada del legacy.
Consecuencia concreta si el mirror se publica tal cual: cada visita al archivo histórico mandaría
un pageview a la misma propiedad GA4 con la que estamos decidiendo los redirects del cutover. Se
mezclaría el tráfico del archivo con el del sitio vivo justo en los datos que usamos para decidir.
Y encima, en un sitio de solo lectura, la analítica no tiene ninguna función.
Recomendación: quitarlo, igual que los sociales. Si en algún momento interesa saber qué URLs
legacy siguen recibiendo visitas, sale mejor de los logs de nginx o de una propiedad GA4 aparte
creada para el archivo — sin contaminar la del sitio principal.
Lo que se quedaría (y por qué)
cdnjs.cloudflare.comaparece en 27.393 páginas, pero no es telemetría: sonsimple-line-icons(los iconos) ymagnific-popup(el visor de imágenes). Si se quitan, el archivopierde iconos y la ampliación de fotos. Lo razonable a medio plazo es descargarlos y servirlos
desde el propio mirror, para que no dependa de un CDN externo dentro de 10 años. Lo dejo apuntado,
no lo toco sin decírtelo.
📊 Analytics: se QUEDA — decisión de Rafa, y lo que hay que ajustar para que sirva
Decisión: el tag de Google Analytics se queda en el mirror. El motivo de Rafa es que quiere
medir quién entra en la web antigua, y el tráfico va a quedar diferenciado porque el dominio es
distinto (
antiguo.feadulta.com).El razonamiento es correcto a nivel de datos: GA4 registra la dimensión
hostNameen cadaevento, así que los pageviews del archivo y los del sitio vivo son separables dentro de la misma
propiedad
G-6RT9ZRS4LW. No hace falta una propiedad aparte. Retiro la recomendación decomment-504.
Pero hay tres cosas que conviene dejar atadas para que esa medición sirva de verdad:
1. Nuestra herramienta de informes NO sabe filtrar por hostname
scripts/ga4_report.py(reporafacalvo-web) tiene presets con dimensionespagePath,pageTitle,landingPagePlusQueryString,sessionSourceMedium… y un filtro por regexp sobrepagePath. No usahostNameni por dimensión ni por filtro.Consecuencia práctica: hoy, cualquier informe que saquemos suma el archivo y el sitio vivo, y
como las rutas del legacy (
/es/buscadoravanzado/item/…) no existen en WordPress, se colarían en loslistados de páginas como si fueran del sitio principal. La separación existe en GA4 pero no en lo que
nosotros miramos.
Es un cambio pequeño y acotado: añadir
hostNamecomo dimensión y un--host-filter. Lo hago sime dices que sí — es otro repo y prefiero abrir issue antes de tocarlo.
2. El tráfico de la validación también va a contar
Durante las fases 4 y 5 el mirror va a vivir en
legacy.rafacalvo.nyc, no enantiguo.feadulta.com. Todas las pruebas que hagamos ahí van a mandar pageviews a la mismapropiedad, bajo ese hostname. No rompe nada y se filtra igual de fácil, pero hay que saberlo antes
de mirar los números, no después.
3.
UA-32008163-1es peso muerto y se puede quitarAdemás del GA4, el HTML arrastra el tag de Universal Analytics en 27.374 sitios. UA dejó de
procesar datos en julio de 2023: no mide nada, solo hace la petición. Quitarlo no afecta a lo
que Rafa quiere medir (eso lo hace
G-6RT9ZRS4LW) y adelgaza 27.374 fragmentos de HTML.No lo toco sin luz verde, pero es la limpieza más obvia que queda.
Estado del mirror tras esta ronda
Prueba de humo repetida: 10/10 rutas críticas en 200 con el Content-Type correcto.
MANIFEST-site.sha256regenerado.Paso 1 del cutover del legacy: el mirror ya se sirve desde Hetzner
https://legacy.rafacalvo.nycsirve el archivo completo desde el servidor Hetzner. Producciónintacta:
antiguo.feadulta.comsigue apuntando a CDMON y sirviendo el Joomla. No se ha tocadoninguna zona DNS de
feadulta.com, ni Cloudflare, ni el hosting.Decisión: migración escalonada en dos pasos
Rafa plantea desacoplar la migración del legacy de la del WordPress: estrenar Hetzner con el
estático esta semana y dejar el WordPress para la del 03-ago. Es lo correcto, y además ataca B1
de rebote: el cambio de A record de
antiguoes el mismo procedimiento que el del 03-ago, peroensayado sobre el sitio que no importa. Si Inma no puede el día 3, lo sabremos ahora.
El orden importa y no es el que parecía:
legacy.rafacalvo.nycy validarloantiguo.feadulta.com→ 188.40.120.157 (grey cloud).htaccessdeny + parar, sin borrar)El paso 4 no puede adelantarse al 2. El rollback del cambio de DNS es revertir el A record, y
eso exige que el Joomla siga vivo al otro lado. Para reconstruir el mirror ya no dependemos de
CDMON (el snapshot está capturado y verificado, comment-502), pero para revertir el DNS sí. El
§8 del plan operativo decía 7-14 días antes de retirar el Joomla; con #183 encima y la caducidad
del 07/08, 48-72 h es el equilibrio razonable.
Por qué
legacy.rafacalvo.nycy no un hostname bajofeadulta.comDos razones, ambas comprobadas:
Rafa no tiene acceso a Cloudflare, solo Inma. Y el panel DNS de cdmon no sirve: los NS de la
zona son de Cloudflare, así que cualquier registro creado ahí queda inerte.
La validación necesita escanear sin Cloudflare delante.
rafacalvo.nycestá en Spaceship,sin proxy: el tráfico va directo a Traefik. Con CF delante estaríamos validando contra un
bot-challenge, que es exactamente lo que impidió diagnosticar el bug de #164.
Nota para el paso 2: grey cloud. Un archivo estático inerte no necesita WAF, y así no se mete
el modo SSL de la zona como variable nueva en el mismo cambio.
Qué se ha montado en Coolify
Recurso estándar del panel, nada de docker compose manual en
/home/rafa/apps/— que es dedonde salió la trampa del
traefik.docker.networkque dejó los doscrm.*caídos en junio.feadulta·fciz9qr5sf3u1ypd77y4bbonproduction·mghhda6qe0e0v85qm06vvlz3feadulta-antiguo-static·puv5wtxabpkx0sfa1vvqa3gbnginx:1.29-alpine· puerto 80https://legacy.rafacalvo.nyc/data/feadulta-antiguo/site/antiguo.feadulta.com→/usr/share/nginx/htmldefault.confde nginx (contenido en la BD de Coolify, editable desde el panel)Las labels de Traefik las ha generado Coolify solo. No hay ninguna escrita a mano:
El contenedor está en una sola red (
coolify), así que la trampa de las dos redes no puede darse.Transferencia:
tar | ssh | taren streaming, sin fichero intermedio. Disco del server al 5%(418 GB libres), o sea que los ~11 GB del WordPress siguen cabiendo de sobra.
Verificación
Integridad — la comprobación que decide:
Los 65.038 ficheros tienen el mismo sha256 en los dos lados. No es que pese lo mismo: es el mismo
contenido, fichero a fichero.
Prueba de humo — las 201 URLs de
smoke/urls.txt:Rutas estructurales y tipos MIME:
//es//anterior/…/10591-antes-de-que-sea-tarde(sin.html)try_filesfunciona.pdfapplication/pdf.csstext/css.jpgcon espacios en el nombreimage/jpeg.swf?file=…(alias con?literal)Cabeceras y TLS: cert Let's Encrypt
CN=legacy.rafacalvo.nycválido hasta el 28-oct-2026(renovación automática de Coolify),
http://→ 302 ahttps://,X-Robots-Tag: noindex, nofollowy
robots.txtconDisallow: /— el aislamiento del §5.1 se mantiene.De paso queda aclarado un desajuste que arrastrábamos: los 44.722 bytes documentados en
comment-504 son los de
/es/, no los de/. Las dos cifras coinciden con el fichero local.⚠️ Corrección al §6 del plan operativo (comment-459)
En el plan, el parity check ataca el origen de CDMON (
--origin-resolve …:134.0.10.170) y poreso llevaba aviso de ventana y rate limit. El script que acabamos construyendo no hace eso.
scripts/50-parity.pycompara el fichero capturado contra el Joomla restaurado en local(
127.0.0.1:8086, contenedorjoomla-mirror-web). Cero peticiones a producción. El riesgo del§3.2 —que el parity degrade
www.feadulta.com— no existe: se resolvió solo al restaurar el Joomlaen local. Lo único que hay que cuidar es no ahogar la WSL (lección del crawl que tumbó la VM).
Parity completo lanzado con
scripts/50b-parity-full.py(nuevo): las 25.437 URLs delinventario entero, no una muestra de 500, secuencial con pausa de 0,35 s,
nice/ionice, JSONLincremental y reanudable. Ritmo ~2/s, ETA ~3,5 h. Publico el resultado cuando termine. Recordatorio
del sample previo: 500/500, 0 títulos distintos, 0 hashes de texto distintos.
Pendientes
antiguo.feadulta.com→188.40.120.157, grey cloud. Yaprovechando: un token de API de Cloudflare con DNS edit sobre la zona (B1). Es lo que decide
si el 03-ago hay cutover del WordPress o no; pedirlo ahora, con la excusa inofensiva del archivo,
es mejor que descubrirlo el día 3.
404.html.error_page 404 /404.htmlapunta a un fichero que no existe en el mirror, así quelos 404 caen en la página por defecto de nginx (153 bytes, en inglés). Conviene una página del
tipo "esto es el archivo histórico de feadulta, ve a feadulta.com". No bloquea.
UA-32008163-1sigue en 27.374 fragmentos de HTML, muerto desde julio de 2023 (comment-505§3). Sin luz verde no lo toco.
--host-filterenscripts/ga4_report.py(reporafacalvo-web): mientras no esté, losinformes GA4 mezclan el archivo con el sitio vivo. Ahora además hay un hostname más en la mezcla,
legacy.rafacalvo.nyc, por el tráfico de esta validación (comment-505 §2).feadulta-antiguo-statica la monitorización (rafa/server#5).Paridad completa: 25.383 páginas comparadas, 0 diferencias
Resultado del parity anunciado en comment-506, ya sobre el inventario entero (25.437 URLs) en
vez de la muestra de 500. Recordatorio del §6: esto va contra el Joomla restaurado en local
(
127.0.0.1:8086), no contra CDMON. Producción no ha recibido ni una petición.Contra los criterios de aceptación del §7:
Y ahora sobre las 25.383 páginas reales, no sobre una muestra.
Los 54
falta_fichero, desglosadosNo son 54 huecos. Comprobado uno a uno:
<title>idéntico al del Joomla vivo. El script busca el fichero conos.path.exists()sobre la ruta cruda del inventario, y esos nombres llevan¿ é à ï ’ ç: en disco se guardaron con otra codificación. nginx los resuelve; elos.path.exists()no./es/donaciones.htmly/es/libroresumen2.html— los 404 del propio origen ya conocidos de comment-502. No falta nada: no existen.Los 51 no me fío de darlos por buenos solo por el 200 —podría estar sirviendo el fichero
equivocado—, así que se contrastó el
<title>servido porlegacy.rafacalvo.nyccontra el delJoomla vivo, URL a URL: 51 de 51 idénticos.
El único hueco real
El Joomla vivo la sirve en 200; el mirror da 404. Lleva un
%20literal en el slug — la terceravez que la codificación de nombres muerde en este proyecto (antes fueron los alias de K2 con
?literal y la doble pasada del filtro de sociales).
Decisión pendiente de Rafa: recuperarla del Joomla local y añadirla implica regenerar
MANIFEST-site.sha256, y con él la igualdad de manifiestos local↔Hetzner que se acaba deverificar en comment-506. Por lo que es —un slug roto con un espacio dentro, sin enlaces entrantes
conocidos— la alternativa razonable es dejarla como 404 documentado. 1 página de 25.437.
Nota de ejecución
Primera pasada a 1 hilo con pausa de 0,35 s: 2,16 URL/s, ETA 195 min. Con la máquina local casi
sin carga se subió a 8 hilos sin pausa: ~25 URL/s de pico, terminado en ~35 min. Los dos
contenedores (
joomla-mirror-web+joomla-mysql) llegaron a ~700% de CPU sobre 800% disponibles,con la memoria estable (322 MiB de un tope de 2 GiB) y PSI de memoria a 0,00 en las tres
ventanas — el
buff/cacheque sube es page cache reclamable de leer los 25.437 HTML capturados, nopresión real. Ni un solo código distinto de 200 en las 23.983 peticiones al Joomla local.
scripts/50b-parity-full.pyquedó con dos mejoras sobre50-parity.py: registra el código HTTPdel Joomla local (sin eso, un 500 por carga se contaría como "el mirror difiere" y ensuciaría el
informe con diferencias falsas) y escribe JSONL incremental reanudable, para poder cortar y
retomar sin repetir trabajo.
Pendiente de arreglo en el script: el
falta_ficherodebe aplicar la misma resolución que hacenginx (
try_files $uri $uri.html $uri/index.html) antes de declarar un fichero ausente. Si no, cadacorrida futura escupirá los mismos 51 falsos positivos y habrá que volver a descartarlos a mano.
Informe completo:
runs/20260729T224708Z/parity-report-full.json· detalle por URL enparity-full.jsonl.Paso 2 hecho:
antiguo.feadulta.comya se sirve desde HetznerInma ha cambiado el A record. Con eso el paso 2 de comment-506 está cerrado, pero llegó antes de
que el vhost estuviera montado, así que hubo una ventana en la que el archivo estuvo caído:
Traefik no tenía router para ese hostname y devolvía su 404. Ya está resuelto.
Qué se ha hecho para levantarlo
El dominio se ha añadido a la misma aplicación de Coolify (
feadulta-antiguo-static,puv5wtxabpkx0sfa1vvqa3gb), no a un recurso nuevo. Un soloPATCHdedomainsy redeploy; laslabels las ha vuelto a generar Coolify:
legacy.rafacalvo.nycsigue apuntando al mismo contenedor. Los dos hostnames sirven el mismomirror, lo que deja el hostname de pruebas disponible para validar sin tocar el de producción.
Certificado Let's Encrypt emitido por el challenge HTTP en cuanto hubo router:
Verificación sobre el hostname real
Prueba de humo, las 201 URLs de
smoke/urls.txtcontrahttps://antiguo.feadulta.com://es//anterior/…/10591-antes-de-que-sea-tarde(sin.html)http://https://El rollback sigue disponible
Comprobado atacando el origen directamente con
--resolve(el DNS ya no lleva ahí):El Joomla de CDMON está intacto y sirviendo. Revertir el A record devuelve el sitio a producción sin
más pasos. Por eso el paso 4 —desactivar el Joomla— sigue sin poder adelantarse: mientras dure
la ventana de 48-72 h, el rollback es ese Joomla.
⚠️ Decisión que ahora sí urge: el
noindexestá en producciónEl mirror se montó con el aislamiento del §5.1, pensado para un hostname de pruebas:
Eso era correcto en
legacy.rafacalvo.nyc. Enantiguo.feadulta.comno: es el hostname quehasta hace un rato indexaba Google. El
Disallowes además el más agresivo de los dos, porqueimpide el rastreo y con él la lectura del propio
noindex.Según comment-504 §4, Rafa quiere medir el tráfico del archivo por GA4 — lo que implica que
espera visitas orgánicas, y con esta configuración van a desaparecer en cuestión de días.
Tres opciones:
feadulta.comsea lo único visible en GoogleSea cual sea, la configuración debe quedar distinta por hostname:
legacy.rafacalvo.nyctieneque conservar el
noindex(es un duplicado exacto del contenido, y dos hostnames indexando lo mismoes contenido duplicado). Es un
mapde nginx sobre$hosten el mismo file mount, sin tocar nadamás.
Estado del cutover
antiguo.feadulta.com→ 188.40.120.157, grey cloud.htaccessdeny + parar, sin borrar)Pendiente de Inma: el token de API de Cloudflare con DNS edit sobre la zona (B1). Sigue siendo
lo que decide si el 03-ago hay cutover del WordPress; que el A record del archivo lo haya hecho ella
a mano no resuelve B1, solo demuestra que el procedimiento funciona.
Lo demás sigue igual que en comment-509:
404.htmlpropio,UA-32008163-1,--host-filterenga4_report.py, Beszel, y la página del%20.B1 cerrado: token de Cloudflare operativo — y radiografía DNS previa al cutover
Inma entregó el token el 30-jul. Verificado contra la API: estado
active, zonafeadulta.comvisible (
5aa15f0f9cf7d45f3710177bb4a66aba), lectura de registros DNS correcta. B1 deja de serbloqueante para el cutover del 03-ago.
El valor no se escribe aquí ni en ningún comando: vive en
~/.hermes/profiles/feadulta/.envcomoFEA_CF_API_TOKEN. Script de comprobación:~/scripts/cf-verificar.sh.Cómo está la zona ahora mismo
11 registros A en total. Los tres que importan:
antiguo.feadulta.com188.40.120.157(Hetzner)feadulta.com134.0.10.170(CDMON)www.feadulta.com134.0.10.170(CDMON)⚠️ Lo que esto cambia para el 03-ago
Los dos registros que hay que mover están en nube naranja, al revés que el archivo. El archivo
se pasó a Hetzner en gris justamente para no meter el proxy de Cloudflare como variable; con
feadulta.comywwwno tenemos esa salida, porque el sitio vivo sí quiere el proxy.Consecuencia: el modo SSL de la zona entra en juego, y hay que mirarlo ANTES del cutover. Si
está en Flexible, Cloudflare habla HTTP con el origen mientras Traefik redirige todo HTTP a HTTPS
→ bucle de redirección y el sitio caído. Con el hosting de CDMON puede llevar años en Flexible
sin que se note; contra Traefik + Let's Encrypt hace falta Full (strict).
Es exactamente el tipo de variable que nos mordió en #164. Añadir al checklist de T2:
/zones/{id}/settings/ssl) y, si no es Full (strict),decidir con Inma si se cambia antes o durante la ventana.
verificada la lectura: comprobar la escritura obliga a tocar un registro real, así que
queda pendiente de que Rafa autorice un TXT de usar y tirar
(
_test-token.feadulta.com, creado y borrado en el momento).Estado del cutover del legacy
antiguo.feadulta.com→ Hetzner, grisDecisión tomada sobre el
noindexdel archivo: se queda permanente. El WordPress es lamigración del mismo contenido, así que el archivo es un duplicado y no debe competir en Google con
feadulta.com. Cae por tanto la prioridad del--host-filterdega4_report.py: el archivo va amedir prácticamente cero tráfico orgánico.
legacy.rafacalvo.nycse ha retirado — cumplió su función de validar que el servidor respondía. Laaplicación de Coolify se queda con
antiguo.feadulta.comcomo único dominio.Limpieza del archivo: UA muerto fuera, 404 propio, y decisiones cerradas
Cuatro decisiones de Rafa (30-jul) y su ejecución. El mirror desplegado queda actualizado y
reverificado de punta a punta.
Decisiones
%20(1772-apocalipsis-11-19…)falta_ficheroen50b-parity-full.pyUA-32008163-1UA-32008163-1retirado — 27.393 ficherosSe quita el bloque
<script>entero, no solo la línea del ID: dejar el_gaq.pushsuelto noahorra la petición al
ga.js, que es lo que realmente cuesta. UA no procesa datos desde julio de2023, así que cada carga pedía un script muerto a
google-analytics.com.El GA4 (
G-6RT9ZRS4LW) se queda, según la decisión 4 de comment-504. Los dos tags viven en elmismo
<head>, así que el filtro comprueba fichero a fichero que el GA4 sobrevive antes deescribir, y aborta el fichero si no. Cero pérdidas. Verificado también en vivo:
UA=0 GA4=2en laspáginas servidas, y ninguna petición a
google-analytics.com/ga.js.raw/queda intacto como captura fiel del original, igual que se hizo con los botones sociales.Script:
scripts/60-quitar-ua.py.Página de 404
deploy/404.html→ servida comoerror_page 404 /404.html. Español arriba, inglés debajo, sinrecursos externos (renderiza siempre, aunque falle todo lo demás). Explica que esto es un archivo
histórico de solo lectura donde no funcionan ni la búsqueda ni los formularios, y ofrece dos
salidas: el inicio del archivo y
feadulta.com.Antes caía en el 404 por defecto de nginx: 153 bytes y en inglés. La página del
%20que se diopor perdida aterriza ahora aquí.
Verificación tras los cambios
Integridad — la comprobación que decide:
Los 65.039 ficheros tienen el mismo sha256 en los dos lados (uno más que antes: el
404.html).Prueba de humo, las 201 URLs, contra
https://antiguo.feadulta.com:Transferencia:
rsync -a --delete, 27.394 ficheros actualizados, 1 creado, 0 borrados. 2,80 GBlógicos en 233 MB de red gracias al delta. El script (
scripts/80-sync-hetzner.sh) aborta si elorigen tiene menos de 60.000 ficheros: un
--deletecon el origen vacío borraría el sitio entero,y ese guardarraíl es barato.
⚠️ Beszel: ya monitorizaba el contenedor, pero no habría visto la caída de hoy
El agente del Hetzner tiene
/var/run/docker.sockmontado en solo lectura, así que recogemétricas de todos los contenedores del host automáticamente, incluido el del archivo. No hacía
falta añadir nada. Aparece como
puv5wtxabpkx0sfa1vvqa3gb-203919397276— el UUID de Coolify,ilegible en el panel; el nombre
feadulta-antiguo-staticsolo vive en las etiquetas y Beszel no laslee.
Lo importante es lo que Beszel NO cubre. Mide CPU, RAM, disco y estado de contenedores. Cuando
antiguo.feadulta.comestuvo caído esta tarde —A record ya apuntando a Hetzner y todavía sin routeren Traefik— el contenedor estaba vivo y sano. Beszel lo habría dado todo en verde mientras el
sitio devolvía 404.
Para eso hace falta un chequeo HTTP de verdad, que es otra herramienta. Uptime Kuma está en el
catálogo one-click de Coolify y cubriría el archivo,
feadulta.com, Gitea y el CRM con la mismaalerta de Telegram. Pendiente de decisión de Rafa; encaja mejor en
rafa/server#5que aquí.Sigue pendiente, del
rafa/server#5: las notificaciones de Beszel y los umbrales, que se configuranen la UI con el navegador de Rafa.
Criterio para el 03-ago: la migración del WordPress tiene que ser LIMPIA
Requisito nuevo de T2, y viene del #183. El WordPress que se migra el lunes es exactamente el que
tiene los backdoors confirmados (
health-check.phpenwp-content/mu-plugins, pluginwp-highlits). Si el traslado copia el árbol de código tal cual, se van a Hetzner con él: seestrenaría infraestructura nueva con el problema dentro.
Retirar el Joomla no cubre esto. El Joomla era superficie —tenía el
sys.php— y quitarlo está bien,pero el compromiso confirmado está en el WordPress, y la shell
rr.phpdesde la que se plantótodo vivía en
/web, la raíz del WordPress.Qué se copia y qué no
wp-content/uploads.phpdonde solo debería haber medios)wp-content/pluginswp-content/themeswp-content/mu-pluginshealth-check.php. Recrear a mano solo los nuestros (fea-*) desde el repo.phpsuelto en la raízrr.php,wp-cl.phpy compañía salieron de ahíCon esto el vector de entrada deja de importar para la migración: aunque no sepamos cómo entraron,
no se arrastra nada ejecutable de producción.
Sobre las credenciales: qué corta el cambio de servidor y qué no
Rafa señala, con razón, que Hetzner estrena credenciales propias y que las de CDMON no se migran.
Es correcto y es una ganancia real:
Lo que sí viaja, porque va dentro de la base de datos y la base de datos se importa:
health-check.phpiteraba administradores y hacíawp_set_auth_cookie(): si el atacante llegó ausar o crear una cuenta, esa cuenta se importa con el dump.
wp_options.Añadir a T2, antes del import:
uploadsen busca de.phpantes de copiarlo.Esto no obliga a reabrir la investigación forense, que sigue pausada por decisión de Rafa. Es
higiene de la migración: cortar lo que se puede cortar barato, aprovechando que de todas formas hay
que montar el WordPress de cero.
Chequeo del archivo estático — primer día completo en Hetzner (31-jul)
Revisión de cómo va sirviendo
antiguo.feadulta.comdesde que se movió al Hetzner, con los logs de nginx del contenedor y con GA4.Salud: bien
Ventana de ~15 h (desde el redeploy del 30-jul ~21:00 UTC):
.htmldistintas servidasNo es tráfico residual: la web antigua está funcionando de verdad.
404 del log: 286 rutas únicas, y la causa raíz
De los 4.851 404, la mayoría eran assets. Causa: el crawler seguía enlaces HTML, así que los ficheros referenciados solo desde CSS nunca se pidieron —
media/system/css/system.css, los fondos de la plantillafe_adulta_1(history-bg.jpg,box_bg_or3.jpg,button.png,post_s.png…) yratingstars.gifde K2. Efecto: cosmético (fondos e iconos), la navegación nunca estuvo rota.Repuestos con un script nuevo,
mirror-antiguo/scripts/91-repone-assets404.sh, que respeta el principio de diseño de la fase 2: descarga por HTTP desde el Joomla local aislado (127.0.0.1:8086), nunca copiando el filesystem, y escanea PHP embebido antes de copiar nada asite/.Resultado sobre las 286 rutas:
80-sync-hetzner.sh(196 creados, 0 borrados) → verificadas una a una en producción: 196/196 en 200.image001.gifde/anterior, restos de documentos Word exportados). No es un fallo del mirror./%22/media/..., artefactos de HTML mal formado del Joomla.Manifiestos regenerados:
raw65.305 /site65.235. El guardarraíl de los 60.000 ficheros del script de sync sigue en su sitio.El resto de 404 son ruido esperable: 425 de
/cdn-cgi/*(Cloudflare) y escaneo de bots contra/.env,/.git/config,/wp-config.php,/phpinfo.php— todos 404, como debe ser.noindex— decisión cerrada: SE QUEDALo había marcado como decisión abierta y urgente en el comment-510, entendiendo que el
noindex+robots.txt Disallow: /era aislamiento heredado del hostname de pruebas que se había colado en producción. Me equivocaba: es la política buscada. El archivo es el mismo contenido que el WordPress vivo en otro formato (HTML de Joomla); lo que se quiere indexado es la web nueva. No hay nada que arreglar y no se vuelve a plantear.GA4: ya se puede separar archivo de sitio vivo
La propiedad
G-6RT9ZRS4LWrecoge varios hostnames a la vez, yga4_report.pyno sabía filtrar porhostName→ cualquier informe los sumaba en una cifra sin significado. Añadidos--host,--host-noty el presethosts.Últimos 7 días:
El archivo recibe más sesiones que el sitio vivo, pero con 1,5 páginas por sesión frente a 9: entra gente desde enlaces viejos, mira una página y se va. El WordPress es el que retiene.
Dos cabos sueltos detectados de paso
info.feadulta.comhacia URLs que el archivo no tiene (p. ej./es/buscadoravanzado/item/8116-posso-mudar.html). Ese hostname no estaba en el radar de la migración — hay que ver qué es y si entra en el cutover.legacy.rafacalvo.nycsigue recibiendo hits pese a estar retirado: falta borrar el A record en Spaceship.Commits
joomla-migration@feat/mirror-repone-assets404— los 91 scripts del mirror pasan a estar versionados (vivían solo en disco); los 16 GB de datos que generan quedan fuera vía.gitignore.feadulta-git@feat/ga4-host-filter— filtro de host + doc endocs/analytics/ga4-setup.md.Sin push todavía.
Ensayo del cutover del WordPress: destino montado e import verificado (31-jul, noche)
Hasta ahora todo el trabajo de estos días había sido el mirror del Joomla legacy. Esto es el
arranque real de las fases 2 y 3 del plan para el WordPress, que es lo que se juega el lunes.
Bloque A — los agujeros de la propia migración limpia
El criterio del comment-516 dice recrear los mu-plugins "desde el repo". Comparados por MD5 los que
había en los dos sitios, 26 de 26 coinciden byte a byte (ninguno manipulado). Pero aparecieron
cuatro huecos que la regla literal se habría comido:
fea-security-blocked-users.phpfea-subir-avatar-api.phpcarta-semana-plugin.php,stop-redirects.phpfea-fea-*los deja fuerafea-support-campaign.phpLos dos no versionados se leyeron enteros antes de tocarlos: son legítimos y limpios. Ya están en el
repo, y el despliegue del lunes no usa un glob, usa un manifiesto explícito.
feat/mu-plugins-no-versionados(91a2122+5c5ea60), condocs/cutover/mu-plugins-manifest.txt:los 28 ficheros exactos con su sha256.
Bloque B — destino montado
feadulta-wpcreado en Coolify (r2ssjifwj0r0ghyoqd528uaa), one-click estándarwordpress-with-mysqlen el proyectofeadulta. Contenedoreshealthy. MySQL 8.4.10,character_set_server=utf8mb4,collation_server=utf8mb4_0900_ai_ci.WORDPRESS_CONFIG_EXTRAconWP_HOME/WP_SITEURL→https://nuevo.feadulta.com.nuevo.feadulta.comcreado en Cloudflare, A → 188.40.120.157, grey cloud, TTL 300.Bloque C — el import, ensayado y cronometrado (T2.8)
*_bakEl obstáculo era real y ya está resuelto. El origen corre MariaDB 11.8.6 (no por decisión de
nadie: es lo que sirve el hosting de CDMON, y WordPress lo etiqueta como "MySQL" en todos sus
paneles). Sus dumps traen 20 tablas con collations
uca1400_*, que solo existen en MariaDB 11+ yMySQL 8.4 rechaza en el
CREATE TABLE. El saneo las mapea antes de importar:Convertir
utf8mb3→utf8mb4es seguro aquí: para caracteres del BMP los bytes son idénticos, no serecodifica nada, y los índices largos del esquema ya venían con prefijo
(191), muy por debajodel límite de 3072 bytes de MySQL 8. No hubo ningún
ERROR 1071.Resultado: 29 tablas (34 menos las 5
*_bakexcluidas), 0 errores, 0 restos deuca1400yde
utf8mb3.Verificación del import
Contra el dump, que es la fuente real, y no contra el WordPress vivo (cuyas herramientas filtran
tipos y cuyo contenido sigue cambiando):
(La primera fila de cada
INSERTva pegada alVALUES; de ahí el sumando. El# Approximate rows expectedde UpdraftPlus decía 64.160 parawp_posts: es la estimación deInnoDB, imprecisa, y por eso el fichero la llama approximate. No falta ni un post.)
Repetido contra una base de datos limpia da exactamente el mismo número y cero errores — el
import es reproducible, que es lo que hace falta para poder repetirlo el lunes con datos frescos.
Sobre el mojibake
Cero. La comprobación inicial marcaba 36.114 títulos "sospechosos", pero era un falso positivo
mío: la collation
utf8mb4_0900_ai_cies accent-insensitive, así queLIKE '%Ã%'empareja concualquier "a" acentuada. Repetido con
COLLATE utf8mb4_bin, los 467 títulos que contienenÃdeverdad son portugués legítimo — NOVA EVANGELIZAÇÃO, CORAÇÃO FERIDO. Y las 105 líneas con
â€ya estaban en el dump crudo: son preexistentes de producción, no las introduce el import.Comprobación de bytes:
Navegación→...6369C3B36E.ó=C3B3, UTF-8 correcto (el mojibakedaría
C383C2B3).🔴 Lo que necesita mano humana
PATCH /api/v1/services/{uuid}confqdndevuelve
422 "This field is not allowed"— el mismo límite ya documentado con Beszel. Hay queentrar al panel y poner
https://nuevo.feadulta.comen el serviciofeadulta-wp. Hastaentonces Traefik solo enruta el hostname
sslip.iopor defecto y el staging no es navegable(el trabajo por
wp-clino se ve afectado y sigue).Full (strict)o no). Nuestro token no puede leerlo. O se amplía aZone Settings, o lo confirma Inma. Si está en Flexible, el lunes hay bucle de redirección.
*.feadulta.com→ apex, proxied. Al mover el A del apex, todo subdominio nodeclarado viaja con él y Traefik responderá su 404:
info.feadulta.com(el cabo suelto delcomment-520),
wp-nuevo.feadulta.comy lo que haya. Hay que inventariarlo antes del lunes.Pendiente en curso
Pre-sync de
uploads(5,9 GB / 47.922 ficheros), instalación del código limpio (7 plugins — sin losdos
fg-joomla-to-wordpress-premium*, que eran el importador—, tema y los 28 mu-plugins), rotaciónde contraseñas de los 6 administradores y deshabilitado de las 3 del incidente, y el runbook.
Las application passwords no se tocan:
icalvotorrees la que usanteologasbot,escritoresbotyrecordatoriobot. Se inventarían y se deja el procedimiento escrito, paraejecutarlo coordinando con Inma. (Rotar la contraseña de un usuario no revoca sus application
passwords, así que la rotación de administradores no afecta a los bots.)
Ensayo completo: el WordPress de staging está montado, importado y verificado
Continuación del comment-531. El staging ya es navegable: https://nuevo.feadulta.com
Rafa fijó el dominio en el panel de Coolify (la API lo rechaza con
422 fqdn not allowed, el mismotope conocido de Beszel). Anotar ese paso manual para el lunes.
Código limpio instalado — 4 min 19 s
wp-content/wp-cli.phar(dentro del volumen persistente, no en/usr/local/bin)twentytwentyfive1.5docs/cutover/mu-plugins-manifest.txtNo se instalaron
fg-joomla-to-wordpress-premiumni su módulo K2: eran el importador de lamigración de Joomla, ya cumplida.
Seguridad aplicada — y es un script, no una tarea manual
scripts/cutover/post-import-seguridad.sh. Tiene que ejecutarse después de CADA import: el dumptrae las cuentas de administrador con sus hashes, así que reimportar revive las tres del #183.
Las tres del incidente son rechazadas incluso usando su contraseña correcta: el mu-plugin
fea-security-blocked-users.phpcorta la autenticación y el reset, y además se les ha retirado elrol. Quedan 3 administradores:
calvo,eqpyk,icalvotorre.Las contraseñas nuevas viven en
~/.hermes/profiles/feadulta/.env(clavesFEA_WP_PASS_*), nuncaen el repo ni aquí. También se han borrado 4 cron huérfanos de Yoast SEO y WP Profile Builder,
plugins desinstalados hace tiempo.
Application passwords: solo hay UNA en todo el sitio
publicabot, del usuarioicalvotorre(ID 1048). Creada el 27-jun-2026, último uso el29-jul-2026 desde
213.94.23.1. Es la de los bots de la carta.No se ha tocado. La coordinación con Inma es sobre una única credencial, no sobre un lote: se
genera la nueva y se actualiza en el bot en el mismo momento. (Rotar la contraseña del usuario no
la revoca, así que el paso de seguridad de arriba no la ha afectado.)
Smoke test del staging
El índice FULLTEXT sobrevivió al cambio de collation — era uno de los riesgos señalados:
El truco que ahorra la pasada más lenta del lunes
No se hace
search-replace. La base de datos ya traesiteurl/home=https://www.feadulta.com,que es la URL final; el staging se consigue con
WORDPRESS_CONFIG_EXTRA(constantesWP_HOMEyWP_SITEURL) en la env de Coolify. El lunes se borra esa variable y se redespliega.Verificado: la BD conserva intactas las 4.035 URLs de
www.feadulta.comen el contenido. Losdatos quedan idénticos a como estarán en producción.
Entregables (rama
feat/mu-plugins-no-versionados, sin mergear)docs/cutover/RUNBOOK-cutover-wordpress.md— los pasos del lunes, copiables, con los tiemposreales medidos, el rollback, y los tres verdes obligatorios previos.
docs/cutover/mu-plugins-manifest.txt— los 28 ficheros con su sha256.scripts/cutover/post-import-seguridad.shtools/e2e/sites/nuevo.json— la suite E2E apuntando al staging.Pendiente
uploads: 47.920 ficheros / 5,9 GB ya extraídos del origen, subiéndose al volumen.🔴 Sigue necesitando mano humana
Flexible, el lunes hay bucle de redirección. Lo confirma Inma.
*.feadulta.com→ apex, proxied. Al mover el A del apex, todo subdominio nodeclarado se va con él y Traefik devolverá su 404. Falta saber qué es
info.feadulta.com.scheduled_database_backupsvacía, sin cron. No es delcutover, pero no puede quedarse sin dueño.
Ensayo cerrado: el staging está completo y verificado de punta a punta
Cierre de los comment-531 y comment-534. El ensayo completo del cutover está en verde.
uploadssincronizado⚠️ La primera pasada murió a mitad y en silencio (36.289 de 47.920, sesión SSH cortada, sin
error en el log). Reanudada con
--partialy keepalives: 27 m 51 s para 3,07 GB (1,84 MB/s).Lección para el lunes: no dar por buena una transferencia larga porque "terminó" — hay que
comparar el recuento en los dos lados. Es lo que la destapó.
🟢 El delta del lunes: medido, y es casi nada
Como el pre-sync ya está hecho, el lunes solo viaja lo que se publique el fin de semana. Deja de
ser una fase de la ventana para ser un trámite de segundos.
Verificación final con todo en su sitio
/es/→ 301, correcto)audio/mpeg(473 metas de audio en la BD)fea_account_disabledLas imágenes "rotas" no las rompió la migración
El E2E marcó dos imágenes como rotas (
autor-775.png,pausa_999999000529.jpg) y se atribuyó alsync en curso. Terminado el sync seguían fallando, así que lo comprobé:
Ya estaban rotas antes de migrar. De paso queda demostrado que
fea-legacy-redirect.phpfuncionaen el servidor nuevo: redirige el upload inexistente a
antiguo.feadulta.com(301), que respondecon la página de 404 del archivo.
Wordfence instalado limpio — y por qué
Wordfence no estaba instalado en producción, pese a que el #183 comment-492 pedía reinstalarlo
desde fuente limpia. Instalado ahora en el staging: 8.2.2 desde wordpress.org, activado y
verificado (portada 200,
wp-login200, sin fatales).fea-cloudflare-realip.phpestá presente, sinel cual vería todas las visitas como si vinieran de Cloudflare.
El motivo de fondo importa: CDMON tiene ModSecurity a nivel de hosting —fue lo que detectó las
subidas de
wp-cl.phpyhealth-check.php(comment-445/446)— y Hetzner con Traefik no tiene WAFninguno. Sin esto, el servidor nuevo estrenaría con una capa defensiva menos que la que tenía el
sitio comprometido.
Dos decisiones pendientes, ninguna para la ventana:
limit-login-attempts-reloadedsolapan en el bloqueo de login. Decidir cuál manda.auto_prepend_filedesde su asistente: después delcutover.
Un susto que no lo era
6.195
guidde adjuntos apuntan al WordPress local (IP de Tailscale). Es cosmético: elguidnosirve imágenes. Lo hace
_wp_attached_file, y sus 7.755 entradas son rutas relativas, 0absolutas, con
upload_pathyupload_url_pathvacías. Nada que tocar.Igualmente, los enlaces absolutos a
www.feadulta.comdel menú son correctos por diseño: tras elcutover ese hostname es este servidor. Es justo lo que permite no ejecutar
search-replace.Estado del plan
uploads+ delta medidoCamino crítico de la ventana: ≈ 3 min 30 s (dump 25 s + subida 74 s + saneo 2 s + import 107 s),
más segundos de delta de uploads.
🔴 Lo que sigue sin depender de mí
Flexible: bucle de redirección y sitio caído.
*.feadulta.com→ al mover el apex, todo subdominio no declarado se va con él.Falta saber qué es
info.feadulta.com.Desde el lado de Inma/bots: SSL confirmado + aviso sobre
wp-nuevo✅ SSL/TLS de la zona: Full (confirmado por Inma en el panel, 02-ago)
Current encryption mode: Full— no Flexible. Sin cambios desde hace ~250 días ("Automatic mode disabled · 250 days ago"). El bucle de redirección que temías queda descartado. (Si para Hetzner+Let's Encrypt prefieres subirlo a Full (strict) tras el cutover, dinos y lo miramos contigo.)⚠️ Inventario del wildcard:
wp-nuevo.feadulta.comes CRÍTICO para los botsDel aviso 3 del comment-531: al mover el A del apex,
wp-nuevoviaja con el wildcard. Ese hostname es la puerta por la que publican TODOS los bots de la carta (POST a la API REST de WP; porwwwlos bloquea el WAF de Cloudflare — regla "bloquear bots 1"). La application passwordpublicabotque inventariaste (último uso 29-jul desde 213.94.23.1) se usa contra ese host.Para el martes 04-ago está prevista la composición de la carta 738 ya en el servidor nuevo, así que necesitamos una de estas dos:
wp-nuevo.feadulta.comcomo alias del servicio WordPress en Coolify/Traefik (como hasta ahora: misma instalación, host alternativo), oWP_BASE_URLen el.envde los bots en el momento.En cuanto el cutover esté hecho, desde aquí correremos un smoke test de la cadena completa de los bots (lectura REST + publicación de borrador + endpoints
fea/v1) y reportamos en este issue.(El seguro §1 va en marcha: Inma ha enviado hoy los tickets a CDMON — qué pasa exactamente el día 7, dominio intacto, y cambio a plan MASTER pagadero mensual si se puede, como rollback y hogar del correo.)
🔴 Bloqueante para la carta 738: las application passwords están desactivadas en el WP nuevo
Verificación hecha desde el lado de los bots (02/03-ago, solo lectura, sin escribir nada en ningún servidor). Dos resultados: uno bueno y uno que bloquea.
✅
wp-nuevoestá bien declaradoSaltándome el DNS (que aún apunta a CDMON) con
curl --resolve wp-nuevo.feadulta.com:443:188.40.120.157:<title>Fe adulta – feadulta.com).id 55344, HALÍK PROPÕE UMA IGREJA…).Traefik enruta el hostname correctamente. El único detalle es que presenta
CN=TRAEFIK DEFAULT CERTpara ese nombre — esperable, porque Let's Encrypt no puede validarlo mientraswp-nuevoresuelva a CDMON; debería emitirse solo tras el cutover. Y como los bots entran vía Cloudflare (proxied por el wildcard, modo Full), no es bloqueante. ⚠️ Sí lo sería si en algún momento se sube la zona a Full (strict) antes de que Traefik tenga cert propio parawp-nuevo.🔴 Lo que bloquea: el WP nuevo no acepta autenticación por application password
Comparando el índice REST de los dos servidores:
GET /wp-json/→authentication{"application-passwords": {"endpoints": {"authorization": ".../authorize-application.php"}}}[]— ningunoY probando
GET /wp-json/wp/v2/users/meconHost: wp-nuevo.feadulta.comcontra 188.40.120.157:El mismo error con credencial válida y con credencial falsa descarta que sea un problema de contraseña: el servidor no está ofreciendo ese método de autenticación. Es
wp_is_application_passwords_available()devolviendofalse.Causa más probable: WordPress detrás de Traefik no ve la conexión como HTTPS (
is_ssl()falso, porque Traefik termina TLS y habla HTTP al contenedor) y WordPress exige HTTPS para habilitar application passwords.Fix habitual, en
WORDPRESS_CONFIG_EXTRA:Segundo sospechoso, si tras eso sigue fallando: Apache no reenviando la cabecera
Authorizational PHP (SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1), que da exactamente el mismo síntoma.Cómo verificar que quedó arreglado (5 segundos, sin tocar nada): que
GET /wp-json/vuelva a anunciarapplication-passwordsenauthentication. Avisadnos y lo comprobamos + corremos el smoke test completo de la cadena de bots.Impacto y calendario
--parte1/--vivoenteros y los endpointsfea/v1(carta-id,crear-autor,subir-avatar)..envde los bots.Secuela del cutover: #191 — 36 cartas traducidas devolvían 500 (las mismas 9, en fr/it/en/pt).
fea-homepage.phpllamaba aurl_to_postid()por cada enlace interno al reescribirlos al idioma activo; con una URL que no encaja en ninguna regla de reescritura, esa función se traía las 32.311 entradas con su contenido y agotaba los 256 MB de PHP. En español no pasaba porque el filtro se salta el español, y contra CDMON las mismas URLs daban 200: regresión nuestra.Arreglado y desplegado (
78879ae). Verificado: 116/116 cartas traducidas en 200, E2E 13/13, 0 errores PHP y 0 5xx después.Queda pendiente de Inma purgar la caché de Cloudflare de esas 36 URLs: nuestro token solo tiene permiso de DNS. Detalle y lista en #191.