← Tu laboratorio en cualquier parteSerie Remoto · paso 3 de 11

Publicar lo que corre en tu portátil sin abrirle un puerto: Cloudflare → Nginx → Tailscale

Nuestro arnés de agentes vive en un portátil Windows y se usa desde el iPhone con HTTPS y login. El portátil no está en internet. Este es el camino de seis saltos que lo hace posible y por qué cada salto está ahí.

Dificultadmedia
Tiempo1–2 horas
IngredientesCloudflare DNS · Nginx · Certbot · Tailscale · un bridge con login

El problema: tienes un servicio web en una máquina que no debe estar en internet —tu portátil, una GX10— y quieres usarlo desde el móvil en cualquier parte, con una URL normal, HTTPS y sin instalar nada raro en el móvil.

La solución que usamos para todo (el arnés de agentes, la interfaz de Pi, el panel del laboratorio) es siempre la misma cadena:

móvilhttps://agente.tudominio.es
→
CloudflareDNS · A record al servidor
→
Nginx del servidorTLS · WebSocket · Origin
→
Tailscalered privada cifrada
→
bridge con loginen la máquina destino
→
serviciosolo en 127.0.0.1

Parece mucho. Cada salto hace una cosa y por eso se puede razonar sobre él.

Salto 1 · El DNS apunta al servidor, nunca a Tailscale

El registro A de agente.tudominio.es apunta a la IP pública del servidor (la que mantiene el DNS dinámico de la guía 1). Nunca pongas una IP 100.x en un DNS público: no funciona desde fuera y además le cuenta a cualquiera cómo es tu red interna.

Salto 2 · Nginx termina el HTTPS y reenvía por Tailscale

En el servidor, un vhost por servicio. Lo importante está en los comentarios:

server {
    listen 443 ssl http2;
    server_name agente.tudominio.es;

    ssl_certificate     /etc/letsencrypt/live/agente.tudominio.es/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/agente.tudominio.es/privkey.pem;

    location / {
        # La IP de Tailscale del portátil y el puerto del BRIDGE, no del servicio.
        proxy_pass http://100.x.y.z:3084;

        # WebSocket y streaming: sin esto, los agentes se cortan a los 60 s.
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_buffering off;
        proxy_read_timeout 3600s;

        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto https;
        # Safari puede no mandar Origin en un POST del mismo sitio (ver abajo).
        proxy_set_header Origin https://agente.tudominio.es;
    }
}

proxy_buffering off y el timeout largo no son opcionales: un agente que piensa dos minutos antes de escribir el primer token parece, para un proxy con valores por defecto, una conexión muerta.

Salto 3 · El bridge: login antes que nada

El servicio de verdad (el arnés, la interfaz de Pi) no se expone ni siquiera a Tailscale. Delante hay un pequeño servidor Node, el bridge, que:

  • pide usuario y contraseña (los mismos para todos los servicios del laboratorio) y guarda una cookie Secure y HttpOnly de hasta 30 días si marcas «mantener sesión»;
  • sin cookie, responde 401 a todo lo que no sea la pantalla de login;
  • reenvía HTTP y WebSocket a 127.0.0.1 del servicio real;
  • reescribe la cabecera Host al valor que espera el servicio.

Ese último punto nos costó una tarde: el arnés solo acepta ciertos endpoints si Host es 127.0.0.1:3080; al reenviar agente.tudominio.es, los menús de configuración devolvían 403 y parecía un fallo del arnés. No lo era.

Salto 4 · El servicio solo escucha en loopback

127.0.0.1:3080   el arnés
127.0.0.1:3082   su bridge (o en la IP de Tailscale, si Nginx llega directo)

Si alguien consiguiera saltarse el bridge, desde la red no hay nada a lo que conectarse.

La trampa de Safari y el Origin

Síntoma: la pantalla de login carga, metes la contraseña y el bridge responde 403 Invalid origin. Solo en iPhone.

Causa: Safari en iOS puede omitir Origin y Referer en un formulario del mismo sitio, y un bridge con protección CSRF que exige una de las dos rechaza una petición legítima (P-100). Como el bridge solo acepta conexiones del proxy conocido, el proxy fija el Origin canónico (la última línea del vhost). No relajes la comprobación para cualquiera: fíjala en el único sitio en el que confías.

Otra trampa: el nginx -t equivocado

Si tu servidor tiene un panel tipo aaPanel, hay dos Nginx instalados y nginx -t valida el que no está sirviendo (P-043). Valida con el binario y la configuración del panel antes de recargar.

Comprobación desde fuera

# Sin sesión: HTML → pantalla de login (200); cualquier otra cosa → 401
curl -sS -o /dev/null -w '%{http_code}\n' -H 'Accept: text/html' https://agente.tudominio.es/
curl -sS -o /dev/null -w '%{http_code}\n' https://agente.tudominio.es/api/algo

# Desde el servidor, ¿llega al bridge por Tailscale?
curl --max-time 8 -sS -o /dev/null -w '%{http_code}\n' http://100.x.y.z:3084/

Si Cloudflare da timeout pero la tercera orden funciona, el problema está entre el borde y tu servidor, no en el portátil.

Qué no hemos hecho, a propósito

  • No hay puertos abiertos en el router salvo el 443 del servidor.
  • No hay Tailscale Funnel: nada del portátil se publica directamente.
  • Las notificaciones push con la app cerrada necesitarían un emisor de servidor y claves VAPID. No lo hemos montado sin una revisión de seguridad aparte: hoy el aviso de «un agente pide permiso» solo funciona con la web abierta.

Siguiente: cómo se despliega esta misma web con un git push y nada más.