Laravel WebSockets en Forge con SSL


Victor Arana Flores

19 Jul 2022

Parece que la documentación y o la explicación sobre cómo utilizar Laravel WebSockets en Forge con SSL no es suficientemente clara. Algunos han pedido instrucciones en profundidad sobre cómo instalar Laravel WebSockets en Laravel y desplegarlo en Forge con SSL (ya sea a través de Let's Encrypt o Cloudflare).

En este post intentaré explicar lo mejor posible cómo lo he hecho, pero ten en cuenta que estoy usando un proyecto nuevo de Laravel que no tiene código real. Pero la esencia debería ser la misma para todos los proyectos y debería ayudarte a configurarlo, pero sé crítico con el código que copias y asegúrate de que se aplica a ti.

Nota: Estoy asumiendo algunas cosas en esta "guía". Que el lector sabe cómo usar Composer, instalar Laravel (y paquetes) y cómo configurar un servidor Forge (con Daemons, reglas de firewall y un certificado Let's Encrypt).

Con todo esto fuera del camino, ¡vamos a entrar!

Configuración

Estoy usando el instalador de Laravel para instalar una nueva instalación de Laravel. Las instrucciones sobre cómo hacerlo se pueden encontrar en la documentación oficial de Laravel.

Después de ejecutar laravel new laravel-ws-example en mi máquina local. Me meto de lleno a instalar el paquete Laravel WebSockets usando la guía de instalación para instalar el paquete, publicar las migraciones y el archivo de configuración.

Agrego un poco de código en la plantilla app.blade.php antes del cierre de la etiqueta </body> para poder utilizarlo más adelante.

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <meta name="csrf-token" content="{{ csrf_token() }}">
		...
        
    </head>
    <body class="font-sans antialiased">
        
        ...

		<script>
            window.PUSHER_APP_KEY = "{{ env('PUSHER_APP_KEY') }}";
	        window.APP_ENV = "{{ env('APP_ENV') }}";
        </script>
    </body>
</html>

También añadí los siguientes ajustes de configuración a mi archivo .env que configura el ID, la clave y el secreto que utiliza Laravel WebSockets:

BROADCAST_DRIVER=pusher
PUSHER_APP_ID=PFKJ5W3TYTFnv7p5yVRWsBPd
PUSHER_APP_KEY=wttTXkwAPaP8pu2M25MFNv2u
PUSHER_APP_SECRET=4czbE8JbHNDRSZTUSGdEw9QQ

Note: El ID, la clave y el secreto aquí son valores de ejemplo, por favor genere los suyos propios usando por ejemplo random.org.

¿Por qué las claves .env llevan el prefijo PUSHER_ y por qué usamos el driver de difusión de Pusher? Esto es porque Laravel WebSockets está destinado a ser un reemplazo de Pusher.

Los valores que añadimos al .env se utilizan en el archivo de configuración websockets.php y también en el archivo broadcasting.php.

Es importante no cambiar ningún otro valor en websockets.php. Podrías pensar que necesitas configurar un archivo de certificado SSL ahí, ¡no es así! Vamos a utilizar NGINX para la terminación SSL, eso significa que NGINX maneja la parte SSL y reenvía el tráfico HTTP plano al servidor de websockets, más sobre esto más adelante.

Agrego la siguiente conexión al archivo broadcasting.php, esto asume que estoy ejecutando mi servidor de websockets en el puerto 6001 en la misma máquina en la que se está ejecutando la aplicación (que es la predeterminada y lo será en este ejemplo):

'pusher' => [
    'driver' => 'pusher',
    'key' => env('PUSHER_APP_KEY'),
    'secret' => env('PUSHER_APP_SECRET'),
    'app_id' => env('PUSHER_APP_ID'),
    'options' => [
        'cluster' => env('PUSHER_APP_CLUSTER'),
        'encrypted' => true,
        'host' => '127.0.0.1',
        'port' => 6001,
        'scheme' => 'http'
    ],
],

Luego procedo a instalar Laravel Echo siguiendo la documentación oficial. Mi Echo se ve así:

window.Echo = new Echo({
    broadcaster: 'pusher',
    key: window.PUSHER_APP_KEY,
    wsHost: window.location.hostname,
    wsPort: window.APP_ENV === 'production' ? 6002 : 6001,
    wssPort: window.APP_ENV === 'production' ? 6002 : 6001,
    forceTLS: window.APP_ENV === 'production',
    disableStats: true,
 });

Es importante tener en cuenta que he añadido wssPort y establecido forceTLS dependiendo de lo que tengamos almacenado en la variable APP_ENV definida en el archivo app.blade.php. Ya que queremos la bondad de SSL, pero no localmente.

Notarás que obtengo la clave de la aplicación de window.PUSHER_APP_KEY y el estado de depuración de window.APP_ENV que he establecido en app.blade.php usando el siguiente fragmento (antes de cargar mi JavaScript). Lee la clave de la aplicación desde la conexión de transmisión que configuramos antes.

<script>
	window.PUSHER_APP_KEY = "{{ env('PUSHER_APP_KEY') }}";
    window.APP_ENV = "{{ env('APP_ENV') }}";
</script>

Procedo a añadir una prueba de concepto de código que simplemente contará todos los usuarios en un canal de presencia, añadí esto a mi app.js, no olvides ejecutar npm run dev después de modificar tus archivos JS.

let onlineUsers = 0;

function update_online_counter() {
    document.getElementById('online').textContent = '' + onlineUsers;
}

window.Echo.join('common_room')
    .here((users) => {
        onlineUsers = users.length;

        update_online_counter();
    })
    .joining((user) => {
        onlineUsers++;

        update_online_counter();
    })
    .leaving((user) => {
        onlineUsers--;

        update_online_counter();
    });

Esto es toda la configuración necesaria para poner en marcha nuestra aplicación de ejemplo.

Nota: Se supone que los canales de presencia deben ser autenticados, pero para este ejemplo no quería hacer un inicio de sesión de usuario, así que llevé mi pregunta a Google y encontré algo de orientación en un post de Stack Overflow y lo adapté para nuestro caso de uso. Esto permite un canal common_room no autenticado al que todos y pueden unirse. No hagas esto en tu aplicación a menos que sepas lo que estás haciendo.

¡Vamos a Forge!

Así que el siguiente paso será desplegar esta aplicación en Forge, subí mi aplicación a GitHub. También creé un sitio en un servidor de Forge para ello y lo desplegué utilizando la función de aplicaciones de Forge. Después de que se desplegó añadí un certificado de Let's Encrypt. Todo esto se puede hacer desde la interfaz de Forge y no requiere SSH (¡Forge es genial!).

Hay dos maneras de proceder, la primera es usar el mismo dominio que la aplicación pero un puerto diferente al 443 (que es lo que haremos) o usar un (sub)dominio separado.

La primera es más fácil ya que no requiere ninguna configuración extra excepto algunas adiciones a la configuración de NGINX (que es sólo un poco de copypasta, que es alrededor del 99% de su trabajo de todos modos).

La segunda forma requiere que crees un dominio separado (o sitio en términos de Forge) para que el servidor de socket viva en él, lo que te permite ejecutarlo en un servidor completamente diferente o ejecutarlo en el puerto 443 si ese es tu atasco.

Utilizaremos el dominio en el que está alojada tu aplicación y haremos que el servidor de websocket esté disponible en un puerto diferente.

Para ello edita la configuración de NGINX para tu sitio en Forge y haz lo siguiente. Duplique el bloque del servidor que está allí y modifique el puerto de 443 a 6002 (por qué no 6001 se explicará más adelante) y reemplace el bloque location / { /* ... */ } con la configuración de la documentación de Laravel WebSockets.

Si has hecho eso la configuración de NGINX debería ser algo parecido a esto:

# FORGE CONFIG (DO NOT REMOVE!)
include forge-conf/laravel-ws-example.bouma.blog/before/*;

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name laravel-ws-example.bouma.blog;
    server_tokens off;
    root /home/forge/laravel-ws-example.bouma.blog/public;

    # FORGE SSL (DO NOT REMOVE!)
    ssl_certificate /etc/nginx/ssl/laravel-ws-example.bouma.blog/123456/server.crt;
    ssl_certificate_key /etc/nginx/ssl/laravel-ws-example.bouma.blog/123456/server.key;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers TLS13-AES-256-GCM-SHA384:TLS13-CHACHA20-POLY1305-SHA256:TLS_AES_256_GCM_SHA384:TLS-AES-256-GCM-SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS-CHACHA20-POLY1305-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA;
    ssl_prefer_server_ciphers on;
    ssl_dhparam /etc/nginx/dhparams.pem;

    add_header X-Frame-Options "SAMEORIGIN";
    add_header X-XSS-Protection "1; mode=block";
    add_header X-Content-Type-Options "nosniff";

    index index.html index.htm index.php;

    charset utf-8;

    # FORGE CONFIG (DO NOT REMOVE!)
    include forge-conf/laravel-ws-example.bouma.blog/server/*;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location = /favicon.ico { access_log off; log_not_found off; }
    location = /robots.txt  { access_log off; log_not_found off; }

    access_log off;
    error_log  /var/log/nginx/laravel-ws-example.bouma.blog-error.log error;

    error_page 404 /index.php;

    location ~ \.php$ {
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass unix:/var/run/php/php8.0-fpm.sock;
        fastcgi_index index.php;
        include fastcgi_params;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }
}

server {
    listen 6002 ssl http2;
    listen [::]:6002 ssl http2;
    server_name laravel-ws-example.bouma.blog;
    server_tokens off;
    root /home/forge/laravel-ws-example.bouma.blog/public;

    # FORGE SSL (DO NOT REMOVE!)
    ssl_certificate /etc/nginx/ssl/laravel-ws-example.bouma.blog/123456/server.crt;
    ssl_certificate_key /etc/nginx/ssl/laravel-ws-example.bouma.blog/123456/server.key;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers TLS13-AES-256-GCM-SHA384:TLS13-CHACHA20-POLY1305-SHA256:TLS_AES_256_GCM_SHA384:TLS-AES-256-GCM-SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS-CHACHA20-POLY1305-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA;
    ssl_prefer_server_ciphers on;
    ssl_dhparam /etc/nginx/dhparams.pem;

    add_header X-Frame-Options "SAMEORIGIN";
    add_header X-XSS-Protection "1; mode=block";
    add_header X-Content-Type-Options "nosniff";

    index index.html index.htm index.php;

    charset utf-8;

    # FORGE CONFIG (DO NOT REMOVE!)
    include forge-conf/laravel-ws-example.bouma.blog/server/*;

    location / {
        proxy_pass             http://127.0.0.1:6001;
        proxy_read_timeout     60;
        proxy_connect_timeout  60;
        proxy_redirect         off;
        
        # Allow the use of websockets
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }

    location = /favicon.ico { access_log off; log_not_found off; }
    location = /robots.txt  { access_log off; log_not_found off; }

    access_log off;
    error_log  /var/log/nginx/laravel-ws-example.bouma.blog-error.log error;

    error_page 404 /index.php;

    location ~ \.php$ {
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass unix:/var/run/php/php8.0-fpm.sock;
        fastcgi_index index.php;
        include fastcgi_params;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }
}

# FORGE CONFIG (DO NOT REMOVE!)
include forge-conf/laravel-ws-example.bouma.blog/after/*;

Nota: los documentos incluyen métodos para ejecutar el servidor de websockets en el mismo dominio que tu aplicación pero en una ruta diferente, pero personalmente no me gusta porque puede colisionar con las rutas de tu aplicación. Sin embargo, es una opción.

Lo siguiente es abrir el puerto 6002 en el firewall para que sea alcanzable desde internet. Puedes hacerlo desde la pestaña Network de tu servidor Forge y añadir una regla llamada "Websockets" o algo descriptivo y el puerto 6002 y dejar el campo de IP's en blanco (todas las IP's están permitidas para conectarse a este puerto).

Nota: Si su servidor está alojado en AWS EC2 asegúrese de que el grupo de seguridad asignado también permite el tráfico TCP entrante al puerto 6002.

¡Después de esto sólo queda 1 cosa por hacer! Inicie el servidor de websockets como un Daemons para que se reinicie automáticamente en caso de que se bloquee o el servidor se reinicie.

Para hacer esto en Forge vaya a la pestaña Daemons de su servidor y añada un nuevo daemon con el comando php artisan websockets:serve y establezca el directorio donde su sitio se encuentra en el servidor, en mi caso es /home/forge/laravel-ws-example.bouma.blog. Si está desplegando con Envoyer/Deployer o algo similar puede que necesite una ruta como /home/forge/laravel-ws-example.bouma.blog/currentpara apuntar a la ruta actual de su aplicación.

Nota: Este daemon no se reinicia cada vez que se despliega la aplicación y probablemente no sea una buena idea (porque cada reinicio del servidor de websockets desconecta a todos los clientes) pero ten en cuenta que las nuevas claves de la aplicación o las actualizaciones del paquete Laravel WebSockets requieren que se reinicie el daemon para que surta efecto, algo a tener en cuenta.

Para completar, para ir con un (sub)dominio separado para que tu servidor de sockets sea servido puedes añadir un nuevo sitio (por ejemplo socket.yourapp.com) y el único cambio que necesitas hacer en ese sitio después de habilitar Let's Encrypt (ni siquiera necesitas desplegar el código de tu aplicación en el sitio, es sólo un marcador de posición de configuración) es reemplazar el bloque de ubicación en la configuración de NGINX con el de la documentación de Laravel WebSockets. Y utilizar el puerto 443 en lugar del 6002 en tu cliente Pusher/Echo.

Pero por qué el puerto 6000, 6002 y 433, ¡qué lío!

Te escucho. Déjame explicarte un poco, espero que todo tenga sentido después.

Aquí está la cosa, la apertura de un puerto en su servidor sólo se puede hacer por una aplicación a la vez (técnicamente no es cierto, pero vamos a mantenerlo simple aquí). Así que si dejamos que NGINX escuche en el puerto 6001 no podemos iniciar nuestro servidor de websockets también en el puerto 6001 ya que entrará en conflicto con NGINX y al revés, por lo tanto dejamos que NGINX escuche en el puerto 6002 y dejamos que proxy (NGINX es un proxy inverso después de todo) todo ese tráfico al puerto 6001 (el servidor de websockets) a través de http simple. Eliminando el SSL para que el servidor de websockets no necesite saber cómo manejar el SSL.

Así que NGINX se encargará de toda la magia del SSL y reenviará el tráfico en http plano al puerto 6001 de tu servidor donde el servidor de websockets está escuchando peticiones.

La razón por la que no configuramos ningún SSL en la configuración de websockets.php y definimos el esquema en nuestro broadcasting.php como http y usamos el puerto 6001 es para evitar a NGINX y comunicarnos directamente con el servidor de websockets localmente sin necesidad de SSL, lo cual es más rápido (y más fácil de configurar y mantener).

Una nota sobre los puertos

Los Websockets no pueden conectarse en ningún puerto que se te ocurra... como Stack Overflow descubrió muchos de ellos están bloqueados (por los navegadores) y mientras probaba usé el puerto 6000, que también parece bloqueado, por eso usé el puerto 6002 que funciona bien.

Me costó mucho averiguar qué estaba pasando ya que no se lanzan errores visibles cuando se usa un puerto que está bloqueado por el navegador :( Pero mirando los eventos del pusher vi el error que murmuraba que el puerto que elegí no estaba permitido para una conexión websockets.

Puedes ver los eventos del cliente Pusher ejecutando window.Echo.connector.pusher.connection.timeline.events en la consola de desarrollador e inspeccionando las entradas.

¡Házmelo saber!

Espero que esto te haya ayudado a configurar tu propio servidor de websockets en Forge en tu aplicación Laravel. Hazme saber cómo te fue y dónde te atascaste/desatrasaste.


1 comentarios

Inicia sesión para comentar

Comentarios:

  • Juan Miranda

    Juan Miranda hace 1 año

    Gracias por el post, me sirvio mucho. Segui adelante  ??

    • Victor Arana Flores hace 1 año

      Que bueno que te sirvió :D