# RUNBOOK DE IMPLEMENTACIÓN A PRODUCCIÓN — `CL032BACK`

> **Qué es este documento.** La secuencia ejecutable para poner el portal de Reservaciones en
> Línea en producción sobre `vpsmx-2025092.vpshostgator.com`. Nueve fases, en orden, cada una con
> objetivo, precondición, comandos, verificación y reversa.
>
> **Qué NO es.** No es la validación del servidor —esa es
> [IMPLEMENTACION_PRODUCCION.md](IMPLEMENTACION_PRODUCCION.md), y este runbook depende de ella—,
> ni la arquitectura ([ARQUITECTURA_SERVIDORES.md](ARQUITECTURA_SERVIDORES.md)), ni el stack
> ([STACK_CL032BACK.md](../../STACK_CL032BACK.md)).

**Fecha de redacción:** 7 de septiembre de 2026
**Base:** validación de 21 verificaciones + 6 comprobaciones complementarias, ejecutadas el
7-sep-2026 sobre el servidor real. **Nada de aquí es supuesto sobre el entorno**: todo lo que se
afirma del servidor está verificado. Lo que sí queda como supuesto son **dos decisiones**, y están
marcadas como tales en §2.

---

## 1. Estado de partida (verificado)

| Componente | Estado | Consecuencia en este runbook |
|-----------|--------|------------------------------|
| AlmaLinux 9.8 · cPanel 134 · 4 vCPU · 7.5 GB RAM · 171 GB libres | ✅ | Sin restricciones de recursos |
| **PHP 8.4** | ❌ Ausente (hay 8.0–8.3) · `ea-php84 8.4.24` **disponible en EA4** | Fase 2 |
| `disable_functions` | ✅ **Vacío** — `proc_open` disponible | El scheduler puede lanzar tareas |
| `memory_limit` / `max_execution_time` | ✅ 512M / 0 (CLI) | Suficiente |
| `date.timezone` del sistema | ⚠️ `UTC` | Ver §9.3 |
| **composer** | ❌ Ausente | Fase 2 |
| **MyISAM** | ⚠️ No es MySQL · `utf8mb3` · **engine por defecto `MyISAM`** | Fases 4 y 5 — **el punto más delicado** |
| **Redis 6.2.22** | ✅ Instalado, activo, solo loopback · ❌ sin contraseña, sin `maxmemory` | Fase 3 |
| **Redis en uso hoy** | ✅ **Por nadie.** `raquetacam` usa `file`/`database` | **Ventana limpia para asegurarlo** |
| `ea-php84-php-redis` | ❌ **No existe en el repo EA4** | Se usa `predis` — ver §Fase 2.4 |
| **supervisor** | ✅ Instalado, `enabled`, con 2 programas de `raquetacam` | Fase 6 — se sigue esta convención |
| **ZeroTier** | ✅ ONLINE en `fsg_intranet`, IP `192.168.191.69/24` | Fase 7 |
| **Imunify360 8.8.6** | ✅ Presente · ❌ sin CSF | Fase 1 — **no instalar CSF a ciegas** |
| **MariaDB en `0.0.0.0:3306`** | ❌ Abierta a Internet, sin firewall | Fase 1 |
| Cuenta destino | ✅ `reservacionvilla` / `reservaciones.villastur.com.mx`, **vacía** | Fase 4 |
| AutoSSL Let's Encrypt | ✅ Habilitado | Fase 8 |

---

## 2. Decisiones asumidas en este runbook

Tres ya están cerradas por la validación. **Dos son supuestos**: si alguno cambia, cambia el
runbook, y se indica dónde.

| # | Decisión | Estado | Efecto si cambia |
|---|----------|--------|------------------|
| **D1** | Plataforma **compartida** con `raquetacam` y futuros proyectos, documentada como desviación de §4 de la arquitectura | ⚠️ **Supuesto** | Si se opta por VPS dedicado, las Fases 1, 3 y 6 se simplifican mucho (sin coordinación, sin ACL de Redis, sin lista blanca) |
| **D2** | Se **acepta MariaDB 10.11** en lugar de MySQL 8.4, con InnoDB forzado y `utf8mb4` explícito | ✅ Recomendada y verificada | Sustituir el motor afectaría a `raquetacam`; no se contempla |
| **D3** | Scheduler con **`schedule:work` bajo supervisor** | ✅ Coherente con `routes/console.php` | Con cron también funciona (Laravel 11+); habría que corregir los comentarios del código |
| **D4** | El **back va en el subdominio `back.reservaciones.villastur.com.mx`** y el front (`dist/` ya compilado) en `reservaciones.villastur.com.mx` | ⚠️ **Supuesto** | Es el patrón de la casa: `raquetacam` ya usa `back.raquetacam.com`. Si el front va en otro servidor, cambian `CORS_ALLOWED_ORIGINS` y `SANCTUM_STATELESS_DOMAINS` |
| **D5** | El front **no se compila aquí** (no hay `node`/`npm`); se sube el `dist/` construido | ✅ Recomendada | Evita instalar la cadena de Node en la máquina expuesta |

> **D4 es el supuesto de mayor alcance.** Confírmalo antes de la Fase 4: es el que fija dominios,
> rutas y CORS.

---

## 3. Parámetros del despliegue

Rellenar **antes de empezar**. Todos los comandos del runbook usan estos nombres.

| Parámetro | Valor | Nota |
|-----------|-------|------|
| `CUENTA` | `reservacionvilla` | Ya existe |
| `DOMINIO_FRONT` | `reservaciones.villastur.com.mx` | Dominio principal de la cuenta |
| `DOMINIO_BACK` | `back.reservaciones.villastur.com.mx` | Subdominio a crear (D4) |
| `APP_DIR` | `/home/reservacionvilla/apps/cl032back` | Fuera de `public_html` — ver §Fase 4.2 |
| `DOCROOT_BACK` | `/home/reservacionvilla/apps/cl032back/public` | Document root del subdominio |
| `PHP` | `/opt/cpanel/ea-php84/root/usr/bin/php` | Ruta absoluta, obligatoria en supervisor |
| `BD` | `reservacionvilla_cl032` | La crea cPanel con prefijo |
| `BD_USER` | `reservacionvilla_cl032` | |
| `BD_PASS` | `__GENERAR__` | 32+ caracteres |
| `REDIS_PASS_DEFAULT` | `__GENERAR__` | Usuario `default` de Redis |
| `REDIS_PASS_CL032` | `__GENERAR__` | Usuario ACL `cl032` |
| `PUERTO_REVERB` | `6003` | 6002 lo ocupa `raquetacam` |
| `IP_ZT_INTERMEDIO` | `__POR DEFINIR__` | Fase 7 — ver §7.1 |

Generar contraseñas: `openssl rand -base64 36 | tr -d '/+=' | cut -c1-40`

---

## 4. Antes de empezar

### 4.1 Coordinación previa — no es opcional

Tres acciones de este runbook **tocan servicios compartidos**. Avisar al responsable de
`raquetacam` antes, no después:

| Acción | Fase | Impacto en `raquetacam` |
|--------|------|------------------------|
| Restringir el acceso remoto a MariaDB | 1 | **Alto.** Cinco usuarios suyos conectan desde 3 IPs externas |
| Poner contraseña a Redis | 3 | **Ninguno hoy** (no lo usa), **pero sí en el futuro**: hay que entregarle sus credenciales |
| Instalar `ea-php84` | 2 | **Ninguno.** Es aditivo; su cuenta sigue en `ea-php83` |

### 4.2 Respaldo antes de tocar nada

```bash
mkdir -p /root/respaldo-cl032-$(date +%F)
cd /root/respaldo-cl032-$(date +%F)
cp /etc/redis/redis.conf                 redis.conf.orig
cp -r /etc/supervisord.d                 supervisord.d.orig
cp /etc/my.cnf                           my.cnf.orig 2>/dev/null
iptables-save                          > iptables.orig
mysqldump --all-databases --routines --triggers | gzip > mysql-completo.sql.gz
echo "Respaldo en $(pwd)"; ls -la
```

### 4.3 Ventana sugerida

Las Fases 2 a 6 no interrumpen a `raquetacam`. **La Fase 1 sí puede**, si se equivoca la lista
blanca. Hacer la Fase 1 en horario en el que alguien del otro equipo pueda confirmar que sigue
teniendo acceso.

---

# FASE 1 · Cerrar la exposición de MariaDB

> **Va primera porque es riesgo activo, no preparación.** El puerto 3306 está abierto a Internet
> en la máquina que ya tiene ruta a la red privada por ZeroTier.

## 1.1 Precondición: saber a quién se le corta el acceso

La validación reveló que **no se puede simplemente cerrar el puerto**. Hay usuarios remotos
legítimos:

| Usuarios | Desde | Lectura |
|----------|-------|---------|
| `raquetacam`, `raquetacam_diego`, `raquetacam_esau`, `raquetacam_montse`, `raquetacam_useradmin` | `189.150.238.139`, `189.150.250.65`, `189.165.212.120` | Equipo del otro proyecto, conectando desde sus oficinas o casas |
| Los mismos + `reservacionvilla` | `108.174.154.63`, `vpsmx-2025092...` | La propia IP del servidor: tráfico local |

**`bind-address = 127.0.0.1` rompería a esos cinco usuarios.** Es la razón por la que este runbook
**no** hace eso.

> ⚠️ Las tres direcciones `189.x` son rangos de ISP mexicano: muy probablemente **dinámicas**. Una
> lista blanca de IP fijas se va a romper sola cada vez que a alguien le cambie la IP. Es una
> solución correcta *hoy* y frágil *a plazo*; la duradera es que el acceso remoto a la base vaya
> por VPN o túnel SSH. Eso es decisión del otro equipo, pero conviene plantearlo.

## 1.2 Confirmar el estado de Imunify360

Ya hay un producto de seguridad instalado (8.8.6). **Instalar CSF encima sin revisarlo es pedir un
conflicto de reglas.**

```bash
imunify360-agent version
imunify360-agent config show | head -40
iptables -L INPUT -n --line-numbers | head -30
nft list ruleset 2>/dev/null | head -30
```

**Decidir con esta salida:** si Imunify360 ya gestiona el firewall, se configura ahí. Si solo hace
reputación de IP y malware —que es lo más común—, se añaden reglas propias.

## 1.3 Restringir 3306 (opción reglas propias)

```bash
# Loopback y la propia maquina
iptables -A INPUT -i lo -p tcp --dport 3306 -j ACCEPT
iptables -A INPUT -s 108.174.154.63 -p tcp --dport 3306 -j ACCEPT

# Las tres IPs del equipo de raquetacam
for ip in 189.150.238.139 189.150.250.65 189.165.212.120; do
  iptables -A INPUT -s $ip -p tcp --dport 3306 -j ACCEPT
done

# Todo lo demas, fuera
iptables -A INPUT -p tcp --dport 3306 -j DROP
```

**Cerrar también lo que nadie usa** (FTP y rpcbind quedaron expuestos y ningún componente de este
proyecto los necesita — confirmar con el otro equipo si usan FTP):

```bash
iptables -A INPUT -p tcp --dport 111 -j DROP
iptables -A INPUT -p udp --dport 111 -j DROP
```

## 1.4 Verificación

```bash
iptables -L INPUT -n --line-numbers | grep -E "3306|111"
# Desde fuera del servidor, en otra maquina:
#   nc -zv 108.174.154.63 3306   ->  debe fallar
```

**Y confirmar con el otro equipo que siguen entrando a la base.** No dar la fase por cerrada sin
esa confirmación.

## 1.5 Persistir

```bash
dnf -y install iptables-services
service iptables save
systemctl enable iptables
```

## 1.6 Reversa

```bash
iptables-restore < /root/respaldo-cl032-FECHA/iptables.orig
```

---

# FASE 2 · PHP 8.4 y composer

## 2.1 Descubrir los paquetes disponibles

No asumir nombres: listarlos.

```bash
dnf -q list --available "ea-php84*" | awk '{print $1}' | sort
```

## 2.2 Instalar

```bash
dnf -y install ea-php84 ea-php84-php-cli ea-php84-php-fpm ea-php84-php-mysqlnd \
  ea-php84-php-mbstring ea-php84-php-xml ea-php84-php-gd ea-php84-php-zip \
  ea-php84-php-intl ea-php84-php-bcmath ea-php84-php-opcache ea-php84-php-sodium \
  ea-php84-php-posix ea-php84-php-curl
```

> Ajustar la lista a lo que arroje 2.1. Si un paquete no existe con ese nombre exacto, casi
> siempre es porque la extensión viene dentro de `ea-php84-php-common`.

## 2.3 Verificar extensiones — la comprobación que quedó pendiente en la validación

```bash
B=/opt/cpanel/ea-php84/root/usr/bin/php
$B -v
for e in pdo_mysql mbstring openssl tokenizer xml ctype json bcmath curl fileinfo intl zip gd session filter dom sodium opcache; do
  $B -m | grep -qix "$e" && echo "  OK    $e" || echo "  FALTA $e"
done
$B -i | grep -E "^(disable_functions|memory_limit|date.timezone)"
```

**Esperado:** todas en `OK` y `disable_functions` vacío (ya se confirmó vacío en 8.3, pero hay que
verificarlo en 8.4: la configuración es por versión).

⚠️ **`proc_open` es la que importa.** Si apareciera deshabilitada, el `schedule:work` de la Fase 6
no ejecuta ninguna tarea y falla en silencio. Detenerse ahí y corregir el `php.ini` antes de
seguir.

## 2.4 La extensión `redis`: no está, y no hace falta

`dnf list --available "ea-php84-php-redis"` **no devuelve nada**: el paquete no existe en el
repositorio de EasyApache, aunque `raquetacam` sí declare `REDIS_CLIENT=phpredis` sobre 8.3.

Se resuelve **sin instalar nada**: `predis/predis ^3.5` ya está en
[composer.json](../composer.json). Se usa el cliente en PHP puro:

```
REDIS_CLIENT=predis
```

Confirmar si existe para alguna versión, por si conviene retomarlo después:

```bash
dnf -q list --available "ea-php8*-php-redis" 2>/dev/null || echo "no disponible en EA4 para ninguna version"
```

> **La diferencia de rendimiento es real pero no bloqueante.** `predis` es más lento que la
> extensión en C, y el endpoint de disponibilidad es el más golpeado del portal. Si más adelante se
> vuelve un cuello de botella, la salida es compilar `phpredis` por PECL. **No** se hace ahora:
> compilar extensiones a mano en un servidor con cPanel crea una dependencia que se rompe en cada
> actualización de EasyApache.

## 2.5 Asignar PHP 8.4 al subdominio

Después de crear el subdominio (Fase 4). Se anota aquí para no olvidarlo:

```bash
whmapi1 php_set_vhost_versions version=ea-php84 vhost-0=back.reservaciones.villastur.com.mx
whmapi1 php_get_vhost_versions | grep -A3 back.reservaciones
```

## 2.6 Composer

```bash
cd /usr/local/src
curl -sS https://getcomposer.org/installer -o composer-setup.php
HASH=$(curl -sS https://composer.github.io/installer.sig)
/opt/cpanel/ea-php84/root/usr/bin/php -r "
  if (hash_file('sha384','composer-setup.php') === '$HASH') { echo 'Instalador integro'.PHP_EOL; }
  else { echo 'FIRMA INVALIDA - NO CONTINUAR'.PHP_EOL; unlink('composer-setup.php'); exit(1); }"
/opt/cpanel/ea-php84/root/usr/bin/php composer-setup.php --install-dir=/usr/local/bin --filename=composer
rm -f composer-setup.php
composer -V
```

**No omitir la verificación de firma.** Es un binario que va a ejecutar código descargado en el
servidor público.

## 2.7 Reversa

`dnf -y remove ea-php84\*` · `rm /usr/local/bin/composer`. Ninguna de las dos toca a `raquetacam`.

---

# FASE 3 · Asegurar Redis

> **El momento es ahora.** La validación confirmó que Redis está instalado y **no lo usa nadie**:
> `raquetacam` corre con `CACHE_STORE=file`, `SESSION_DRIVER=file`, `QUEUE_CONNECTION=database`.
> Ponerle contraseña hoy no rompe nada. Hacerlo después de que el vecino migre convierte cinco
> minutos de configuración en una coordinación entre equipos con ventana de mantenimiento.

## 3.1 Configuración base

```bash
cp /etc/redis/redis.conf /etc/redis/redis.conf.bak-$(date +%F)

cat >> /etc/redis/redis.conf <<'FIN'

# ---- CL032 · endurecimiento (7-sep-2026) ----
requirepass __REDIS_PASS_DEFAULT__
aclfile /etc/redis/users.acl
maxmemory 768mb
maxmemory-policy volatile-lru
FIN

touch /etc/redis/users.acl
chown redis:redis /etc/redis/users.acl
chmod 640 /etc/redis/users.acl
```

**Por qué `volatile-lru` y no `allkeys-lru`.** Este Redis va a guardar tres cosas con vidas
distintas: caché y sesiones (con TTL) y **trabajos encolados (sin TTL)**. Con `allkeys-lru`, al
llegar al tope Redis desalojaría cualquier llave — incluidos trabajos en cola, que en este sistema
son correos de confirmación y la salida hacia el mundo privado. `volatile-lru` desaloja
**únicamente llaves con expiración**, así que la cola nunca se pierde por presión de memoria.

`768mb` sobre 7.5 GB de RAM deja margen de sobra para MariaDB y PHP-FPM.

## 3.2 Usuario ACL para el proyecto

```bash
systemctl restart redis
redis-cli -a '__REDIS_PASS_DEFAULT__' --no-auth-warning ACL SETUSER cl032 on \
  '>__REDIS_PASS_CL032__' '~cl032_*' '&cl032_*' \
  +@all -flushall -flushdb -keys -shutdown -config -acl -debug -replicaof -module
redis-cli -a '__REDIS_PASS_DEFAULT__' --no-auth-warning ACL SAVE
redis-cli -a '__REDIS_PASS_DEFAULT__' --no-auth-warning ACL LIST
```

`~cl032_*` limita al usuario a las llaves con ese prefijo **en cualquier índice**, que es la
garantía que un `REDIS_DB` distinto no da: el índice ordena, no aísla.

## 3.3 Consecuencia asumida: `cache:clear` deja de funcionar

Con `-flushdb`, `php artisan cache:clear` fallará con un error de permisos de ACL. **Es
deliberado**: es lo que impide que un despliegue de cualquier proyecto vacíe la caché y las
sesiones de los demás.

Sustituto para limpiar solo lo propio:

```bash
redis-cli -u "redis://cl032:__REDIS_PASS_CL032__@127.0.0.1:6379" --scan --pattern 'cl032_*' | \
  xargs -r -n 500 redis-cli -u "redis://cl032:__REDIS_PASS_CL032__@127.0.0.1:6379" DEL
```

> Si el equipo prefiere conservar `cache:clear`, se quita `-flushdb` de la ACL y se acepta que la
> protección contra un borrado ajeno pasa a depender de la disciplina: las ACL de Redis **no
> pueden restringir `SELECT`**, así que cualquier cliente puede cambiar de índice y vaciar el de
> otro. Lo que **sí** queda garantizado en ambos casos es la confidencialidad — nadie lee llaves
> ajenas. Lo que se negocia es la disponibilidad.

## 3.4 Verificación

```bash
redis-cli ping                                    # -> NOAUTH ... (correcto)
redis-cli -u "redis://cl032:__REDIS_PASS_CL032__@127.0.0.1:6379" SET cl032_prueba ok
redis-cli -u "redis://cl032:__REDIS_PASS_CL032__@127.0.0.1:6379" GET cl032_prueba
redis-cli -u "redis://cl032:__REDIS_PASS_CL032__@127.0.0.1:6379" SET otro_prefijo x   # -> NOPERM
redis-cli -u "redis://cl032:__REDIS_PASS_CL032__@127.0.0.1:6379" FLUSHDB              # -> NOPERM
systemctl is-enabled redis && systemctl is-active redis
```

Las dos respuestas `NOPERM` son el resultado esperado: significa que la ACL funciona.

## 3.5 Entregable para el otro equipo

Cuando `raquetacam` migre a Redis necesitará su propio usuario. Dejarlo escrito en el registro de
la plataforma:

```bash
redis-cli -a '__REDIS_PASS_DEFAULT__' --no-auth-warning ACL SETUSER padel on \
  '>SU_CONTRASENA' '~padel_*' '&padel_*' +@all -flushall -flushdb -keys -shutdown -config -acl
```

## 3.6 Reversa

```bash
cp /etc/redis/redis.conf.bak-FECHA /etc/redis/redis.conf && systemctl restart redis
```

---

# FASE 4 · Cuenta, subdominio y base de datos

## 4.1 Crear el subdominio del back (D4)

```bash
mkdir -p /home/reservacionvilla/apps/cl032back/public
chown -R reservacionvilla:reservacionvilla /home/reservacionvilla/apps

uapi --user=reservacionvilla SubDomain addsubdomain \
  domain=back rootdomain=reservaciones.villastur.com.mx \
  dir=/home/reservacionvilla/apps/cl032back/public

uapi --user=reservacionvilla DomainInfo list_domains
```

**Por qué la app va fuera de `public_html`.** El `document_root` apunta a `public/`, así que todo
lo demás —`.env`, `storage/`, `vendor/`, `app/`— queda **fuera del árbol servido por Apache**. Es
la diferencia entre que una mala configuración exponga un archivo de código y que exponga los
cuatro secretos HMAC.

## 4.2 Base de datos — con el charset corregido

```bash
uapi --user=reservacionvilla Mysql create_database name=reservacionvilla_cl032
uapi --user=reservacionvilla Mysql create_user name=reservacionvilla_cl032 password='__BD_PASS__'
uapi --user=reservacionvilla Mysql set_privileges_on_database \
  user=reservacionvilla_cl032 database=reservacionvilla_cl032 privileges=ALL
```

⚠️ **cPanel crea la base con el charset por defecto del servidor, que es `utf8mb3`.** Corregirlo de
inmediato, antes de la primera migración:

```bash
mysql -e "ALTER DATABASE reservacionvilla_cl032 CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
mysql -NBe "SELECT DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME
            FROM information_schema.SCHEMATA WHERE SCHEMA_NAME='reservacionvilla_cl032';"
```

**Esperado:** `utf8mb4  utf8mb4_unicode_ci`. Si sale `utf8mb3`, no continuar: nombres con acentos y
cualquier emoji en un comentario de reservación se truncarían.

## 4.3 Verificación

```bash
mysql -u reservacionvilla_cl032 -p'__BD_PASS__' -e "SELECT DATABASE(), USER(), VERSION();" reservacionvilla_cl032
```

## 4.4 Reversa

```bash
uapi --user=reservacionvilla Mysql delete_database name=reservacionvilla_cl032
uapi --user=reservacionvilla SubDomain delsubdomain domain=back.reservaciones.villastur.com.mx
```

---

# FASE 5 · Código, `.env` y migraciones

## 5.1 Corregir `config/database.php` — **antes de subir el código**

Este es el cambio que evita el fallo más caro del despliegue.

**Problema:** el servidor tiene `default_storage_engine = MyISAM` y
[config/database.php:81](../config/database.php#L81) tiene `'engine' => null`. Laravel no emite
cláusula `ENGINE=` y las tablas nacerían en **MyISAM: sin transacciones**. Toda la defensa
antisobreventa de la arquitectura (§8) es una transacción con `lockForUpdate`, y sobre MyISAM
`beginTransaction()` no falla — **no hace nada, en silencio**.

En la conexión `mariadb` de `config/database.php`:

```php
'engine' => env('DB_ENGINE', 'InnoDB'),
```

## 5.2 Subir el código

```bash
# Como root, dejando la propiedad correcta
cd /home/reservacionvilla/apps/cl032back
# (subir por SVN, rsync o zip desde File Manager)
chown -R reservacionvilla:reservacionvilla /home/reservacionvilla/apps/cl032back
```

## 5.3 Dependencias

```bash
cd /home/reservacionvilla/apps/cl032back
sudo -u reservacionvilla /opt/cpanel/ea-php84/root/usr/bin/php /usr/local/bin/composer \
  install --no-dev --optimize-autoloader --no-interaction
```

`--no-dev` deja fuera Scribe, Larastan, PHPUnit, Pail y Collision. **En el servidor público no
tienen nada que hacer.**

## 5.4 El `.env` de producción

Ver el archivo completo en el **Anexo A**. Crear y asegurar:

```bash
cd /home/reservacionvilla/apps/cl032back
# (crear .env con el contenido del Anexo A)
chown reservacionvilla:reservacionvilla .env
chmod 600 .env
chmod 750 /home/reservacionvilla/apps/cl032back
sudo -u reservacionvilla /opt/cpanel/ea-php84/root/usr/bin/php artisan key:generate
```

## 5.5 Repetir la prueba de aislamiento que quedó aplazada

En la validación esta prueba no pudo concluirse porque no existía el `.env`. **Ahora sí, y es
requisito de la fase:**

```bash
sudo -u raquetacam cat /home/reservacionvilla/apps/cl032back/.env 2>&1 | head -2
```

**Esperado: `Permission denied`.** Si imprime contenido, **detener el despliegue**: el vecino puede
leer los cuatro secretos HMAC y las direcciones de la red privada.

## 5.6 Migraciones

```bash
cd /home/reservacionvilla/apps/cl032back
sudo -u reservacionvilla /opt/cpanel/ea-php84/root/usr/bin/php artisan migrate --force
```

**Verificación obligatoria — que ninguna tabla haya nacido en MyISAM:**

```bash
mysql -NBe "SELECT TABLE_NAME, ENGINE FROM information_schema.TABLES
            WHERE TABLE_SCHEMA='reservacionvilla_cl032' AND ENGINE <> 'InnoDB';"
```

**Esperado: sin filas.** Si aparece alguna, el arreglo de 5.1 no se aplicó. Corregir y:

```bash
mysql -NBe "SELECT CONCAT('ALTER TABLE \`',TABLE_NAME,'\` ENGINE=InnoDB;')
            FROM information_schema.TABLES
            WHERE TABLE_SCHEMA='reservacionvilla_cl032' AND ENGINE<>'InnoDB';" | \
  mysql reservacionvilla_cl032
```

## 5.7 Permisos y optimización

```bash
cd /home/reservacionvilla/apps/cl032back
chmod -R 775 storage bootstrap/cache
chown -R reservacionvilla:reservacionvilla storage bootstrap/cache
sudo -u reservacionvilla /opt/cpanel/ea-php84/root/usr/bin/php artisan storage:link
sudo -u reservacionvilla /opt/cpanel/ea-php84/root/usr/bin/php artisan config:cache
sudo -u reservacionvilla /opt/cpanel/ea-php84/root/usr/bin/php artisan route:cache
sudo -u reservacionvilla /opt/cpanel/ea-php84/root/usr/bin/php artisan view:cache
sudo -u reservacionvilla /opt/cpanel/ea-php84/root/usr/bin/php artisan event:cache
```

⚠️ Tras `config:cache`, **los cambios en `.env` dejan de leerse** hasta volver a ejecutarlo.
Cualquier ajuste posterior del `.env` exige repetir `config:cache`.

## 5.8 Reversa

`rm -rf /home/reservacionvilla/apps/cl032back` + eliminar la base (4.4).

---

# FASE 6 · Supervisor: los tres procesos

> **Se sigue la convención que la máquina ya tiene** (`/etc/supervisord.d/*.ini`), no se introduce
> systemd en paralelo. Ver H2 de la validación.

## 6.1 Los tres programas

```bash
cat > /etc/supervisord.d/cl032-schedule.ini <<'FIN'
[program:cl032-schedule]
command=/opt/cpanel/ea-php84/root/usr/bin/php /home/reservacionvilla/apps/cl032back/artisan schedule:work
directory=/home/reservacionvilla/apps/cl032back
autostart=true
autorestart=true
user=reservacionvilla
redirect_stderr=true
stdout_logfile=/home/reservacionvilla/apps/cl032back/storage/logs/schedule.log
stopwaitsecs=60
FIN

cat > /etc/supervisord.d/cl032-queue.ini <<'FIN'
[program:cl032-queue]
command=/opt/cpanel/ea-php84/root/usr/bin/php /home/reservacionvilla/apps/cl032back/artisan queue:work --queue=notifications-high,notifications,notifications-low,default --sleep=3 --tries=3 --timeout=120 --max-time=3600
directory=/home/reservacionvilla/apps/cl032back
autostart=true
autorestart=true
user=reservacionvilla
redirect_stderr=true
stdout_logfile=/home/reservacionvilla/apps/cl032back/storage/logs/queue.log
stopwaitsecs=3600
FIN

cat > /etc/supervisord.d/cl032-reverb.ini <<'FIN'
[program:cl032-reverb]
command=/opt/cpanel/ea-php84/root/usr/bin/php /home/reservacionvilla/apps/cl032back/artisan reverb:start --host=127.0.0.1 --port=6003
directory=/home/reservacionvilla/apps/cl032back
autostart=true
autorestart=true
user=reservacionvilla
redirect_stderr=true
stdout_logfile=/home/reservacionvilla/apps/cl032back/storage/logs/reverb.log
FIN

supervisorctl reread && supervisorctl update && supervisorctl status
```

**Detalles que no son cosméticos:**

- `--timeout=120` en la cola porque el `.env` declara `NOTIFICATION_QUEUE_TIMEOUT=120`.
- `stopwaitsecs=3600` copia la práctica del vecino: deja que un trabajo en curso termine antes de
  matar el worker. En una cola que lleva reservaciones al mundo privado, matar a mitad es peor que
  esperar.
- `--max-time=3600` recicla el worker cada hora, contra fugas de memoria en procesos largos.
- Los nombres de cola salen del `.env` (`NOTIFICATION_QUEUE*`). Si cambian ahí, cambian aquí.

## 6.2 Rotación de logs — supervisor no rota

```bash
cat > /etc/logrotate.d/cl032 <<'FIN'
/home/reservacionvilla/apps/cl032back/storage/logs/*.log {
    daily
    rotate 14
    missingok
    notifempty
    compress
    delaycompress
    copytruncate
    su reservacionvilla reservacionvilla
}
FIN

logrotate -d /etc/logrotate.d/cl032
```

`copytruncate` es obligatorio aquí: supervisor mantiene el descriptor de archivo abierto, así que
un `rotate` normal dejaría el log escribiendo en un archivo ya renombrado.

> Conviene hacer lo mismo para `raquetacam`, que tampoco tiene rotación. Es cortesía de plataforma
> y evita que el disco se llene por un proyecto ajeno.

## 6.3 Verificación

```bash
supervisorctl status cl032-schedule cl032-queue cl032-reverb
ss -lntp | grep 6003
tail -20 /home/reservacionvilla/apps/cl032back/storage/logs/schedule.log
# El ping de 10 s debe dejar rastro en cache:
sudo -u reservacionvilla /opt/cpanel/ea-php84/root/usr/bin/php \
  /home/reservacionvilla/apps/cl032back/artisan tinker --execute="dump(cache()->get('conexion:intermedio'));"
```

## 6.4 Reversa

```bash
supervisorctl stop cl032-schedule cl032-queue cl032-reverb
rm /etc/supervisord.d/cl032-*.ini && supervisorctl reread && supervisorctl update
```

---

# FASE 7 · ZeroTier y el Intermedio

## 7.1 Identificar al Intermedio — pendiente

El barrido de la red `fsg_intranet` encontró **17 nodos vivos**:

```
.57  .59  .63  .68  .69(este servidor)  .79  .96  .102  .142
.165 .170 .173 .200 .202 .211 .240 .242
```

**Ninguno está identificado todavía.** La mayoría son, con toda probabilidad, equipos de oficina
unidos a la VPN. Para encontrar cuál sirve HTTP:

```bash
for ip in 57 59 63 68 79 96 102 142 165 170 173 200 202 211 240 242; do
  r=$(curl -sS -o /dev/null -m 3 -w '%{http_code}' "http://192.168.191.$ip/" 2>/dev/null)
  echo "192.168.191.$ip -> ${r:-sin respuesta}"
done
```

> ⚠️ **Es posible que el Intermedio simplemente no exista aún.**
> [ARQUITECTURA_SERVIDORES.md](ARQUITECTURA_SERVIDORES.md) §2 dice que `CL032APIDISPONIBILIDAD`
> *"es nuevo. No existe hoy"*. Si el barrido no encuentra un servidor de disponibilidad, **esta
> fase se detiene** y hay que construirlo primero — es el camino crítico del proyecto entero, y no
> se resuelve desde este servidor.

## 7.2 Probar el salto

```bash
curl -sS -o /dev/null -w 'HTTP %{http_code} en %{time_total}s\n' \
  --max-time 10 "http://IP_ZT_INTERMEDIO/api/salud"
```

## 7.3 Restringir la salida por ZeroTier (mitigación de H1)

Solo si se confirmó D1 = plataforma compartida.

```bash
# PROBAR ANTES DE PERSISTIR
iptables -A OUTPUT -o ztwdjgcshs -m owner --uid-owner root             -j ACCEPT
iptables -A OUTPUT -o ztwdjgcshs -m owner --uid-owner reservacionvilla -j ACCEPT
iptables -A OUTPUT -o ztwdjgcshs -j REJECT

# Verificar que el portal sigue alcanzando al Intermedio
sudo -u reservacionvilla curl -sS -o /dev/null -w '%{http_code}\n' -m 5 http://IP_ZT_INTERMEDIO/api/salud
# Y que otro inquilino ya no
sudo -u raquetacam curl -sS -o /dev/null -w '%{http_code}\n' -m 5 http://IP_ZT_INTERMEDIO/api/salud   # -> debe fallar
```

Esto es lo que impide que un compromiso de cualquier otra cuenta del servidor alcance la red
privada. `zerotier-one` corre como `root`, por eso las dos reglas de aceptación.

## 7.4 Verificación de extremo a extremo

```bash
cd /home/reservacionvilla/apps/cl032back
sudo -u reservacionvilla /opt/cpanel/ea-php84/root/usr/bin/php artisan disponibilidad:salud-intermedio
```

---

# FASE 8 · TLS, HTTPS y WebSockets

## 8.1 AutoSSL

```bash
whmapi1 start_autossl_check_for_one_user username=reservacionvilla
sleep 60
whmapi1 fetch_ssl_certificates_for_fqdns fqdn-0=back.reservaciones.villastur.com.mx
```

## 8.2 Proxy de WebSockets para Reverb

Reverb escucha en `127.0.0.1:6003`; el navegador necesita `wss://` sobre 443. En cPanel esto va
por un *include* de vhost, no en `.htaccess`:

```bash
D=/etc/apache2/conf.d/userdata/ssl/2_4/reservacionvilla/back.reservaciones.villastur.com.mx
mkdir -p "$D"
cat > "$D/reverb.conf" <<'FIN'
RewriteEngine On
RewriteCond %{HTTP:Upgrade} =websocket [NC]
RewriteRule /app/(.*) ws://127.0.0.1:6003/app/$1 [P,L]
ProxyPass        /apps/ http://127.0.0.1:6003/apps/
ProxyPassReverse /apps/ http://127.0.0.1:6003/apps/
FIN

/scripts/ensure_vhost_includes --user=reservacionvilla
/scripts/restartsrv_httpd
```

Requiere `mod_proxy_wstunnel` en EasyApache. Verificar:

```bash
httpd -M 2>/dev/null | grep -E "proxy_wstunnel|rewrite|proxy_http"
```

## 8.3 Endurecimiento (S1–S8 del stack)

| # | Medida | Cómo queda |
|---|--------|-----------|
| S1 | Usuario de BD con permisos mínimos | Fase 4.2 — solo sobre su base |
| S2 | Sin conexiones a Tlalpan/Reservaciones | Verificar que `config/database.php` no las tenga |
| S3 | Rate limiting con Redis | El `.env` ya define `*_RATE_LIMIT` |
| S4 | HTTPS forzado | `APP_URL` con `https://` + redirección en cPanel |
| S5 | `APP_DEBUG=false` | Anexo A |
| S6 | Idempotencia en reservaciones | Ya implementado en el código |
| S7 | Verificación de origen | Laravel 13 |
| S8 | Sin Telescope ni Horizon públicos | `--no-dev` los deja fuera |

Verificación:

```bash
grep -n "reservaciones\|tlalpan\|siasturiano" /home/reservacionvilla/apps/cl032back/config/database.php || echo "OK: sin conexiones privadas (R1)"
curl -sI https://back.reservaciones.villastur.com.mx | head -5
curl -sS https://back.reservaciones.villastur.com.mx/api/ruta-inexistente | head -3   # no debe filtrar stack trace
```

## 8.4 Hostname de WHM

Pendiente heredado de la validación: se administra el servidor por `http://108.174.154.63:2086`,
sin TLS. Cambiar el hostname a uno propio con certificado **antes** de volver a capturar
credenciales de producción en ese panel.

---

# FASE 9 · Pruebas de aceptación

Ninguna fase se da por cerrada hasta que estas doce respondan lo esperado.

| # | Prueba | Comando | Esperado |
|---|--------|---------|----------|
| 1 | PHP correcto | `/opt/cpanel/ea-php84/root/usr/bin/php -v` | 8.4.x |
| 2 | `proc_open` disponible | `... -i \| grep disable_functions` | vacío |
| 3 | Todas las tablas InnoDB | consulta de §5.6 | sin filas |
| 4 | Charset de la base | consulta de §4.2 | `utf8mb4` |
| 5 | Redis exige auth | `redis-cli ping` | `NOAUTH` |
| 6 | ACL activa | `... SET otro_prefijo x` | `NOPERM` |
| 7 | `.env` ilegible por el vecino | §5.5 | `Permission denied` |
| 8 | Los tres procesos vivos | `supervisorctl status` | 3 × `RUNNING` |
| 9 | Reverb escuchando | `ss -lntp \| grep 6003` | una línea |
| 10 | Scheduler ejecutando | `tail storage/logs/schedule.log` | actividad reciente |
| 11 | Salto al Intermedio | §7.4 | `HTTP 200` |
| 12 | HTTPS sin fugas | §8.3 | sin stack trace |

Y una prueba funcional que vale por las doce: **crear una reservación de prueba de extremo a
extremo** y confirmar que sube por la cola, que Reservaciones V2 la revalida y que el veredicto
regresa. Es lo único que demuestra que la cadena antisobreventa está viva.

---

# 10. Operación posterior

| Frecuencia | Qué revisar |
|-----------|-------------|
| Diario | `supervisorctl status` · `storage/logs/laravel.log` · cola sin acumulación |
| Diario | Reservaciones atascadas en `Confirmando` (la tarea `reservaciones:alertar-confirmando` avisa por correo) |
| Semanal | `df -h` · `redis-cli INFO memory` contra el tope de 768 MB |
| Semanal | Bitácora de reconciliación de cupo (`reservaciones:reconciliar-cupo`) |
| Mensual | Vigencia de los certificados · vigencia de las IPs en la lista blanca de 3306 |
| Cada alta de cuenta nueva en el servidor | Revisar las mitigaciones de H1 — ver el registro de plataforma |

**Sentry sigue sin configurar.** `sentry/sentry-laravel` está en `composer.json` pero no hay DSN.
En el servidor público eso significa operar a ciegas ante errores: conviene resolverlo antes de
abrir al socio.

---

# ANEXO A · `.env` de producción

Diferencias respecto al `.env` de desarrollo, con el porqué de cada una.

```dotenv
APP_NAME="Reservaciones en Linea"
APP_ENV=production
APP_KEY=                                  # php artisan key:generate
APP_DEBUG=false
APP_URL=https://back.reservaciones.villastur.com.mx
APP_LOCALE=es
APP_FALLBACK_LOCALE=es
APP_FAKER_LOCALE=es_MX

LOG_CHANNEL=stack
LOG_STACK=daily
LOG_LEVEL=warning

DB_CONNECTION=mariadb
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=reservacionvilla_cl032
DB_USERNAME=reservacionvilla_cl032
DB_PASSWORD=__BD_PASS__
DB_ENGINE=InnoDB
DB_CHARSET=utf8mb4
DB_COLLATION=utf8mb4_unicode_ci

CACHE_STORE=redis
SESSION_DRIVER=redis
SESSION_LIFETIME=120
SESSION_ENCRYPT=true
SESSION_DOMAIN=.villastur.com.mx
QUEUE_CONNECTION=redis

REDIS_CLIENT=predis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_USERNAME=cl032
REDIS_PASSWORD=__REDIS_PASS_CL032__
REDIS_DB=10
REDIS_CACHE_DB=11
REDIS_PREFIX=cl032_
CACHE_PREFIX=cl032_

BROADCAST_CONNECTION=reverb
REVERB_APP_ID=fsg001reverb
REVERB_APP_KEY=__REGENERAR__
REVERB_APP_SECRET=__REGENERAR__
REVERB_HOST=back.reservaciones.villastur.com.mx
REVERB_PORT=443
REVERB_SCHEME=https
REVERB_SERVER_HOST=127.0.0.1
REVERB_SERVER_PORT=6003

SANCTUM_STATELESS_DOMAINS=reservaciones.villastur.com.mx
CORS_ALLOWED_ORIGINS=https://reservaciones.villastur.com.mx

DISPONIBILIDAD_INTERMEDIO_URL=http://__IP_ZT_INTERMEDIO__
DISPONIBILIDAD_HMAC_SECRET=__REGENERAR__
DISPONIBILIDAD_TIMEOUT=10

SINCRONIZACION_HMAC_SECRET=__REGENERAR__
SINCRONIZACION_IPS_PERMITIDAS=__IP_ZT_RESERVACIONES_V2__
INVITADOS_HMAC_SECRET=__REGENERAR__
INVITADOS_IPS_PERMITIDAS=__IP_ZT_TLALPAN__
PADRON_HMAC_SECRET=__REGENERAR__
PADRON_PORTAL_URL=__URL_PORTAL_PUBLICO__

PADRON_REPLICA_HABILITADA=false
```

## Las decisiones del anexo, explicadas

| Clave | Por qué así |
|-------|-------------|
| `APP_DEBUG=false` | **Medida S5.** Con `true`, cualquier stack trace publica el `.env` entero — los cuatro secretos HMAC incluidos. |
| `DB_CONNECTION=mariadb` | D2. Laravel 13 tiene driver propio; usar `mysql` contra MariaDB funciona pero desaprovecha las diferencias que el framework ya conoce. |
| `DB_ENGINE=InnoDB` | Ver §5.1. **Sin esto no hay transacciones y la protección antisobreventa es decorativa.** |
| `REDIS_CLIENT=predis` | `ea-php84-php-redis` no existe en EA4 (§2.4). |
| `REDIS_USERNAME=cl032` | ACL de la Fase 3. Es lo que aísla del resto de la plataforma. |
| `REDIS_PREFIX` / `CACHE_PREFIX` | Deben coincidir con el patrón `~cl032_*` de la ACL, o **toda operación devuelve `NOPERM`**. |
| `REVERB_HOST` = dominio, `PORT=443` | Lo que anuncia el navegador. `REVERB_SERVER_*` es donde escucha el proceso. Confundirlos es el error clásico de Reverb. |
| `SESSION_ENCRYPT=true` | Las sesiones viven en un Redis compartido con otros proyectos. |
| `SESSION_DOMAIN=.villastur.com.mx` | Para que la sesión valga entre el front y el subdominio del back. |
| `LOG_LEVEL=warning` | Con `debug` y sin rotación previa, el disco se llena. |
| `*_HMAC_SECRET` | **Regenerar los cuatro.** Un secreto que estuvo en una máquina de desarrollo dejó de ser secreto. |
| `PADRON_REPLICA_HABILITADA=false` | Lo pide el propio [routes/console.php](../routes/console.php): arranca apagada hasta verificar los lectores de correo y celular. |
| `*_IPS_PERMITIDAS` | En desarrollo son `127.0.0.1`. **Dejarlas así rechaza todo el tráfico legítimo** de Tlalpan y Reservaciones V2. |

## Sobre la zona horaria — revisar, no copiar

`config/app.php` fija `America/Mexico_City`; el sistema y MariaDB están en **UTC**
(`time_zone = SYSTEM`). Mientras todo pase por Eloquent es consistente, porque Laravel convierte al
escribir y al leer.

**El riesgo está en el SQL crudo.** Cualquier consulta que use `NOW()` o `CURDATE()` de MariaDB
obtendrá **UTC**, no hora de México — seis o siete horas de diferencia según el horario de verano.
En un sistema donde una noche de hotel es `[llegada, salida)`, eso puede desplazar un día entero.

Antes de abrir al público:

```bash
grep -rn "NOW()\|CURDATE()\|CURRENT_TIMESTAMP" app/ database/ --include="*.php" | grep -v vendor
```

Cada resultado hay que revisarlo a mano. **No es un cambio de configuración, es una revisión de
código.**

---

# ANEXO B · Registro de la plataforma (actualizado)

## Puertos

| Puerto | Ocupa | Estado |
|--------|-------|--------|
| 3306 | MariaDB | Restringido en Fase 1 a 4 IPs |
| 6379 | Redis | Loopback + ACL |
| 6002 | Reverb `raquetacam` | En uso |
| **6003** | **Reverb CL032** | Este despliegue |
| 6004+ | Libre | Siguiente proyecto |

## Redis

| Proyecto | DB | Prefijo | Usuario ACL |
|----------|-----|---------|-------------|
| `raquetacam` | — | `padel_` (previsto) | `padel` (por crear cuando migre) |
| **CL032** | 10 / 11 | `cl032_` | `cl032` |

## Supervisor

| Proyecto | Programas | PHP |
|----------|-----------|-----|
| `raquetacam` | `padel-queue`, `padel-reverb` + cron `schedule:run` | `ea-php83` |
| **CL032** | `cl032-schedule`, `cl032-queue`, `cl032-reverb` | `ea-php84` |

## Acceso remoto a MariaDB (lista blanca)

`189.150.238.139` · `189.150.250.65` · `189.165.212.120` · `108.174.154.63`
**Probablemente dinámicas.** Revisar mensualmente.

---

# ANEXO C · Lo que queda fuera de este runbook

| Pendiente | Por qué no está aquí |
|-----------|---------------------|
| **Construir el Intermedio** | Es otro servidor y otro proyecto (`CL032APIDISPONIBILIDAD`). **Camino crítico**: sin él, el portal arranca pero degrada a "no disponible" en cada consulta de cupo. |
| **Despliegue de `CL032FRONT`** | Depende de confirmar D4. El `dist/` se sube a `public_html` del dominio principal. |
| **Decisión D1** | Es de negocio. Este runbook asume plataforma compartida y aplica las mitigaciones. |
| **Política de respaldos** | No existe para este servidor. §4.2 cubre el respaldo puntual del despliegue, no la operación. |
| **DSN de Sentry** | Sin definir. |
| **VPN o túnel para el acceso remoto a MariaDB** | Decisión del equipo de `raquetacam`; la lista blanca de IPs es un parche, no la solución. |
| **Rotación de logs de `raquetacam`** | Fuera de alcance, pero conviene proponerlo: su disco es el mismo que el nuestro. |

---

**Documentos relacionados:**
[IMPLEMENTACION_PRODUCCION.md](IMPLEMENTACION_PRODUCCION.md) (validación) ·
[ARQUITECTURA_SERVIDORES.md](ARQUITECTURA_SERVIDORES.md) ·
[STACK_CL032BACK.md](../../STACK_CL032BACK.md) ·
[routes/console.php](../routes/console.php)
