Hace tiempo tenías ganas de escribir sobre este tema, y no hallaba el tiempo. Hoy finalmente lo pude realizar.
La pregunta: ¿Cómo hacen los hostings para alojar varias webs con distintas URLs?
Hace varios años empecé experimentando en mi propia computadora sirviendo varios servicios al mismo tiempo tales como un servidor HTTP, uno FTP, un servidor de bases de datos y otras cosas más. Hasta aquí todo bien. Y para poder acceder a cada uno de ellos primero accedía a través de algún puerto asignado para tal fin: 80, 21, 5432 (postgres) respectivamente.
Sin embargo, el problema empezó a ocurrir cuando quise levantar otro servicio web. Allí ocurrió la pregunta lógica de quien incursiona con todo esto en forma autodidacta: ¿Cómo hacen los web servers para servir decenas, cientos o miles de páginas web desde un mismo servidor?
Está claro que abrir puertos indiscriminadamente en el router no es la mejor solución: además de inseguro es un problema estar pasando a los usuarios una dirección del tipo: http://mi-web.com:12345. Además es poco escalable: hay una cantidad limitada de puertos y, por lo tanto, de servicios que podemos servir desde un equipo.
Emplear subdominios tampoco es una solución adecuada para el problema que se nos plantea. A veces son proyectos completamente diferentes o simplemente no cubre el caso de uso que necesitamos resolver.
Leyendo e investigando mucho al respecto, encontré que hay servidores HTTP como Nginx que responden a esta pregunta: se trata de utilizar un proxy inverso. Y solo abrimos dos puertos: 80 (HTTP) y 443 (HTTPS).
Qué es un proxy inverso
Un proxy inverso es un servidor que recibe los pedidos que llegan desde internet y los reenvía, por dentro de la red al servicio que corresponde. Para quien está afuera hay un solo servidor, que responde en el puerto 80 y en el 443. Detrás podemos tener servicios como Jellyfin escuchando en el puerto 8096, un Nextcloud en el 8080 y un Home Assistant en el 8123 y servir cada uno de estos servicios con URLs distintas.
Internet entra por una sola puerta, el router reenvía únicamente el 80 y el 443 al equipo que corre nginx, y nginx decide a quién entregarle cada pedido según el nombre con el que llegó: jellyfin.casa.ar va para un lado y nube.casa.ar para otro. Misma IP, mismo puerto, distinto destino. Esa es exactamente la respuesta a lo que hacen los proveedores de hosting para servir miles de sitios desde una misma máquina, y la respuesta a la pregunta que me hacía años atrás.
Inverso ¿respecto de qué?
Respecto del proxy «común», el que ya apareció en este blog en Agregar proxy utilizando la consola en Linux. Ese es un proxy de salida: lo configuran los clientes de una red, como una universidad o una oficina, para que todo lo que navegan pase por un intermediario que filtra, guarda en caché o registra. El cliente sabe que existe; el servidor de destino, en general, no.
El proxy inverso está en la otra punta. Lo instala quien tiene los servidores, no quien navega. El cliente no sabe que existe: cree que habla con jellyfin.casa.ar y en realidad habla con nginx, que a su vez habla con Jellyfin. De ahí lo de «inverso»: el mismo intermediario, pero puesto del lado del servidor y trabajando para él.
Otros nombres
Al leer sobre el tema es importante aclarar que se emplean varios términos que señalan la misma pieza, o alguna de sus funciones:
- Servidor de borde o edge server: el equipo que está en el borde entre la red propia e internet. Es el que “da la cara” a internet.
- Gateway, o API gateway cuando lo que hay detrás son APIs: el mismo concepto, con el énfasis puesto en que es la única puerta de entrada.
- Terminación TLS (TLS termination): una de las funciones del proxy inverso. El cifrado HTTPS «termina» en nginx, que es quien tiene el certificado, y hacia adentro de la red el tráfico puede seguir en HTTP plano. Lo vamos a ver más adelante.
- Balanceador de carga: un proxy inverso que además reparte los pedidos entre varias copias del mismo servicio. Para un servidor de casa no hace falta, pero es la misma herramienta.
Por qué nginx
La respuesta honesta: porque fue el primer servidor con el que interactué para este propósito. Pero no es el único:
Apache hace lo mismo con mod_proxy; Caddy lo hace con menos configuración y consigue el certificado solo; Traefik está pensado para contenedores y se configura leyendo las etiquetas de Docker. Todos sirven. nginx tiene a favor que está en los repositorios de cualquier distribución, que su configuración se lee de corrido, y hay mucha documentación al respecto.
Manos a la obra
Vamos a hacer un hands on e implementar un reverse proxy para hospedar dos servicios: Jellyfin y Nextcloud bajo el dominio casa.ar. De modo que al final podamos acceder a través de jellyfin.casa.ar y nextcloud.casa.ar al servicio correspondiente.
Instalación y la configuración mínima
nginx está en los repositorios de cualquier distribución. En Debian o Ubuntu:
sudo apt install nginx
En Fedora o similares, sudo dnf install nginx y después sudo systemctl enable --now nginx, porque a diferencia de Debian no inicia solo. Para comprobar que responde alcanza con abrir el navegador e introducir la IP del servidor.
El archivo de configuración lo encontramos en /etc/nginx/. El archivo principal es nginx.conf y, en general, no hace falta tocarlo: su última parte incluye otros archivos, y ahí es donde escribimos lo nuestro. Dónde exactamente depende de la distribución:
- Debian y Ubuntu usan
sites-available/, donde se escriben los archivos, ysites-enabled/, donde se activan con un enlace simbólico. Permite dejar un sitio escrito pero apagado. - Fedora, RHEL y derivados usan
conf.d/: todo archivo que termine en.confse carga.
El criterio que uso es un archivo por servicio, con el nombre del servicio: jellyfin.conf, nextcloud.conf. Así cuando algo falla ya sabemos dónde mirar, y cuando un servicio se da de baja se borra un archivo y listo.
Dos comandos que van a aparecer después de cada cambio:
sudo nginx -t # revisa la sintaxis sin tocar nada
sudo systemctl reload nginx # aplica la configuración nueva
Server blocks: el vhost de nginx
Si venimos de Apache, el concepto se llama virtual host o vhost, y mucha gente llega a nginx buscando esa palabra. En nginx se llama server block: un bloque server { ... } por cada nombre que atendemos, cada uno con su server_name.
Lo interesante es cómo nginx elige el bloque. Cuando un navegador pide https://jellyfin.casa.ar/, el nombre viaja dos veces: primero en el saludo TLS, en un campo que se llama SNI (Server Name Indication) y que va en claro justamente para que el servidor sepa qué certificado presentar; después, ya dentro de la conexión cifrada, en la cabecera Host del pedido HTTP. nginx compara ese nombre con el server_name de cada bloque y entrega el pedido al que coincide. Misma IP, mismo puerto 443, y cada nombre termina en un servicio distinto.
Este es un server block completo para Jellyfin, todavía sin TLS (es decir, aún sin HTTPS). En /etc/nginx/sites-available/jellyfin.conf debe quedar:
server {
listen 80;
server_name jellyfin.casa.ar;
location / {
proxy_pass http://127.0.0.1:8096;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
proxy_pass es la directiva que convierte a nginx en proxy inverso: todo lo que entre por location / se reenvía a Jellyfin, que escucha en el 8096 de la misma máquina. Si corre en otro equipo de la red, va la IP de ese equipo en lugar de 127.0.0.1.
Las cuatro líneas de proxy_set_header tienen un rol muy importante también: Sin ellas, el servicio de atrás recibe un pedido que parece venir de nginx y no del visitante:
Host: el nombre que pidió el navegador. Sin esto, el servicio ve127.0.0.1:8096y arma mal sus enlaces y redirecciones.X-Real-IP: la IP del visitante. Para nginx el cliente es el navegador; para Jellyfin, sin esta cabecera, el cliente sería siempre nginx.X-Forwarded-For: lo mismo, pero como lista. Si hay más de un proxy en el camino, cada uno agrega la IP que vio;$proxy_add_x_forwarded_forconserva lo que ya venía y suma la nuestra.X-Forwarded-Proto: si el visitante entró porhttpohttps. Cuando activemos TLS, el tramo entre nginx y Jellyfin va a seguir siendo HTTP plano, y con esta cabecera el servicio sabe que hacia afuera está en HTTPS.
Para activarlo en Debian, el enlace a sites-enabled/, y la revisión y recarga de siempre:
sudo ln -s /etc/nginx/sites-available/jellyfin.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
Todavía no tenemos DNS, pero ya se puede probar desde la misma máquina, simulando el nombre con la cabecera Host:
curl -I -H "Host: jellyfin.casa.ar" http://127.0.0.1/
Si responde Jellyfin, y no la página de bienvenida de nginx, el bloque está eligiéndose bien.
DNS: un nombre por servicio
Para que jellyfin.casa.ar llegue a nuestro router hace falta que ese nombre resuelva a nuestra IP pública. Eso se configura en el panel del proveedor donde tenemos registrado el dominio, y son registros de tipo A, uno por servicio, todos apuntando a la misma IP:
jellyfin.casa.ar. A 190.0.0.10
nextcloud.casa.ar. A 190.0.0.10
home.casa.ar. A 190.0.0.10
Una variante equivalente es un registro A para el nombre principal y un CNAME por servicio apuntando a ese nombre: si algún día cambia la IP, se corrige en un solo lugar. Y el atajo es un registro comodín, *.casa.ar, que resuelve cualquier subdominio a esa IP sin tener que agregar uno por servicio. Es cómodo, aunque después cada nombre que no tenga server block va a caer en el bloque por defecto de nginx, y eso hay que tenerlo en cuenta.
Si la conexión de casa no tiene IP fija (lo más común), el nombre principal se mantiene al día con un servicio de DNS dinámico, y el resto cuelga de él por CNAME. No lo desarrollo acá porque es un tema aparte.
Para comprobar que el nombre ya resuelve, desde cualquier equipo:
dig +short jellyfin.casa.ar
Tiene que devolver nuestra IP pública. Un registro nuevo puede tardar unos minutos en verse, según el tiempo de vida que el proveedor le ponga.
Certificado con Let’s Encrypt
Hasta acá, todo lo que entra y sale viaja en claro. La contraseña de Jellyfin, la sesión de Nextcloud y cada pedido a Home Assistant cruzan internet legibles. HTTPS no es una prolijidad opcional: es lo que hace que exponer un servicio propio sea razonable.
Let’s Encrypt emite certificados gratis y certbot los pide por nosotros. Antes de emitir uno, Let’s Encrypt necesita comprobar que controlamos el dominio, y la forma más simple es el desafío HTTP-01: nos pide publicar un archivo con un contenido determinado bajo http://jellyfin.casa.ar/.well-known/acme-challenge/, y lo va a buscar por el puerto 80. Por eso ese puerto queda abierto en el router aunque todo lo demás vaya por el 443.
certbot trae un complemento para nginx que hace todo el trabajo: resuelve el desafío, pide el certificado, edita el server block para activar TLS y deja programada la renovación. Vamos a usar ese modo. Lo que hace por debajo, paso a paso y a mano, da para un artículo propio, y no hace falta entenderlo para tener un proxy andando.
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d jellyfin.casa.ar
La primera vez pide un correo para los avisos de vencimiento y aceptar las condiciones del servicio. Después busca el server block cuyo server_name coincide con el nombre pedido que cargamos antes.
Cuando termina, jellyfin.conf quedó así. Las líneas marcadas con # managed by Certbot son las que agregó:
server {
server_name jellyfin.casa.ar;
location / {
proxy_pass http://127.0.0.1:8096;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/jellyfin.casa.ar/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/jellyfin.casa.ar/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
Nuestro bloque pasó a escuchar en el 443 con TLS. fullchain.pem es el certificado y privkey.pem la clave privada; las dos líneas siguientes traen una configuración de TLS razonable, mantenida por el equipo de certbot, así que no tenemos que decidir versiones de protocolo ni suites de cifrado.
Fijate que proxy_pass sigue apuntando a http://, no https://. El cifrado termina en nginx: es él quien tiene el certificado y quien descifra. De ahí a Jellyfin el tráfico va en claro, pero dentro de la red local, y esa es una de las ventajas de la arquitectura: un solo certificado, en un solo lugar, en vez de uno por servicio.
Los certificados de Let’s Encrypt duran 90 días, y certbot instala un temporizador de systemd que los renueva solo cuando falta poco. Como usamos el complemento de nginx, después de cada renovación también recarga nginx para que tome el certificado nuevo. Conviene comprobar que el temporizador existe, y ensayar la renovación sin que emita nada:
systemctl list-timers | grep certbot
sudo certbot renew --dry-run
Si el ensayo termina sin errores, podemos olvidarnos del tema. Para los otros servicios se repite el mismo comando con su nombre, o se piden todos de una vez con varios -d.
Redirigir todo el 80 al 443
Falta que quien entre por http:// termine en https://. certbot también lo resuelve: al terminar, deja en el mismo archivo un segundo bloque para el puerto 80 que redirige todo al 443.
server {
if ($host = jellyfin.casa.ar) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name jellyfin.casa.ar;
return 404; # managed by Certbot
}
La forma es un poco rebuscada, porque certbot edita con cuidado de no romper lo que había, pero el efecto es simple: cualquier pedido por el 80 con ese nombre recibe un 301 hacia la misma ruta en https://, y $host$request_uri conserva el nombre y la ruta completa, así que un enlace viejo a http://jellyfin.casa.ar/web/ sigue llegando a donde iba.
El puerto 80 no se puede cerrar en el router: es por donde Let’s Encrypt comprueba el nombre en cada renovación. certbot se ocupa de atender ese pedido cuando hace falta, sin que la redirección lo estorbe.
Comprobar que funciona
Después del último reload, la comprobación desde afuera se hace con curl, que dice más que el navegador:
curl -I http://jellyfin.casa.ar/
curl -I https://jellyfin.casa.ar/
La primera tiene que devolver 301 con una cabecera Location: https://jellyfin.casa.ar/. La segunda, 200, o el 302 con el que Jellyfin manda a su interfaz en /web/. Si curl se queja del certificado, -k lo ignora para poder ver el resto de la respuesta, y curl -vI muestra quién emitió el certificado y hasta cuándo vale: tiene que decir Let’s Encrypt y una fecha a menos de 90 días.
Cuando algo no anda, hay tres lugares donde mirar, en este orden:
sudo nginx -t
sudo tail -f /var/log/nginx/access.log /var/log/nginx/error.log
sudo journalctl -u nginx -e
El access.log es especialmente útil porque muestra qué llegó a nginx: si el pedido aparece ahí, el problema está entre nginx y el servicio; si no aparece, está antes, en el DNS o en el router.
La última prueba es la más simple: probar en el navegador que entra al sitio seguro (preferentemente desde otra red o la red de datos del móvil).
Lo que falta
Con esto hay un proxy inverso funcionando: un solo equipo expuesto, dos puertos abiertos, un nombre por servicio y un certificado válido que se renueva solo. Para muchos servicios, no hace falta nada más.
Sin embargo, para otros servicios sí falta algo más: Hay cosas que se rompen puntualmente detrás de un proxy y que no aparecen hasta que se usan: WebSockets, que hacen que la interfaz de Jellyfin cargue pero no se actualice; la IP real del visitante, que en los registros del servicio aparece siempre como la de nginx; las subidas grandes a Nextcloud, que fallan con un error sin explicación; los tiempos de espera. Y una cuestión que no es un error sino un pendiente: que los servicios dejen de escuchar hacia afuera, restringir a que solo escuchen lo que viene desde el reverse proxy.
De todo eso trata la segunda parte: Lo que se rompe detrás de un proxy inverso.
Texto e imágenes de Cristian Bottazzi bajo CC BY-NC-SA 4.0.
