hjs.es ← inicio

Terminal SSH en el navegador con ttyd

Un LXC ligero con ttyd, creado con Terraform y configurado con Ansible, expuesto por una regla más en el Cloudflare Tunnel que ya uso para Proxmox — mismo patrón, mismo doble factor con Cloudflare Access. Resultado: una terminal Linux completa y funcional desde cualquier navegador, sin cliente SSH instalado.

ttyd LXC Terraform Ansible Cloudflare Tunnel Cloudflare Access Zero Trust

Contexto

Problema concreto: en ciertas redes restringidas (como las corporativas) no se puede instalar ningún cliente SSH ni VPN — la misma restricción que ya resolví para acceder a Proxmox con Cloudflare Tunnel. Pero Proxmox tiene su propia web de gestión; el resto de máquinas del homelab (LXC, VMs Linux) solo se administran por SSH, y ahí el acceso desde esa red se cortaba del todo.

Solución con el mismo patrón que ya funcionaba, sin inventar uno nuevo: un LXC dedicado con un servidor de terminal web, colgado del túnel de Cloudflare que ya existe, protegido por la misma capa de Access con doble factor. Sin agente, sin cliente, sin puerto abierto en el router — solo un navegador.

Diseño

01

ttyd sobre Wetty

Dos candidatos evaluados: ttyd (binario único en Go, sin dependencias, footprint mínimo) y Wetty (Node.js + xterm.js, más pesado). Ganó ttyd por simplicidad — un solo binario de unos pocos MB, sin runtime que mantener, coherente con querer un LXC que solo hace una cosa.

02

Creado en código, no a mano

El LXC nace de un terraform apply — perfil de tamaño minimo (1 core/512MB/2GB, creado para este caso concreto: un solo binario no necesita el flavor por defecto de 10GB), IP fija y VMID siguiendo el mismo esquema del resto del homelab. Configuración (usuarios, hardening) vía el site.yml de Ansible, con el matiz de que la primera vez que se toca una máquina recién salida de Terraform, el usuario de gestión todavía no existe — detalle completo en la ficha de Ansible.

03

Reutilizar el túnel existente, no crear uno nuevo

En vez de un segundo Cloudflare Tunnel, este servicio se añade como una published application route más dentro del túnel que ya gestiona el acceso a Proxmox — mismo LXC de cloudflared, una entrada más. Menos piezas que mantener, un solo túnel que vigilar.

04

Autenticación en dos capas, no una

Cloudflare Access delante (misma política de IP autorizada + OTP por email que ya protege Proxmox) y, dentro ya del propio ttyd, el login real de PAM contra las cuentas del sistema. Access decide quién llega a ver la terminal; el login de Linux decide quién entra de verdad. Ninguna de las dos capas sustituye a la otra — ambas obligatorias a la vez (Require, no Include, en la política de Access).

05

Servicio systemd, sin depender de una sesión abierta

ttyd corre como servicio (systemctl enable --now), no como proceso lanzado a mano — sobrevive a un reinicio del LXC sin intervención, igual que cloudflared en el otro extremo del túnel. El comando que ejecuta es login, no una shell directa: cualquiera que llegue hasta ttyd (ya filtrado por Access) todavía tiene que autenticarse como un usuario real del sistema, con contraseña.

Problemas por el camino

✗

La terminal no aceptaba ninguna tecla — ni usuario, ni contraseña, ni pegar

Con el servicio ya corriendo y accesible, la web mostraba el prompt login: perfectamente, pero no había forma de escribir nada — ningún carácter llegaba, tampoco pegar con el ratón. El temporizador de inactividad del login saltaba igual, aunque no se hubiera tecleado ni una letra.

Causa: el propio log de arranque lo avisaba desde el principio, sin que se le prestara atención: The --writable option is not set, will start in readonly mode. Sin ese flag, ttyd sirve la sesión en modo solo lectura — el navegador puede ver la terminal, pero ningún carácter tecleado llega al proceso real. El timeout, mientras tanto, es del lado del servidor y no depende de si el cliente puede escribir o no.
Solución: añadir --writable al ExecStart del servicio (ttyd -p 7681 --writable login) y reiniciar. No es una relajación de seguridad — el modo solo lectura de ttyd está pensado para compartir una sesión en modo "mirar sin tocar", no para el caso de acceso remoto real que se buscaba aquí; Access y el propio login siguen siendo las capas de seguridad de verdad.
✗

Orden de despliegue: la ruta pública quedó activa antes que Access

Al añadir la published application route de ttyd.hjs.es, un curl -I de comprobación devolvió HTTP/2 200 directo — sin ninguna redirección por delante. Durante ese margen, cualquiera en Internet que conociera la URL llegaba al prompt de login del LXC, sin la capa de Access todavía puesta.

Causa: el túnel y la aplicación de Access son dos configuraciones independientes en Cloudflare — publicar la ruta no crea la política de Access automáticamente, hay un paso manual entre medio, y ese hueco queda expuesto mientras no se complete.
Solución para la próxima vez: crear la aplicación de Access antes de publicar la ruta pública, o verificar con curl -I inmediatamente después de cada paso — un 200 directo en vez de un 302 hacia cloudflareaccess.com es la señal inequívoca de que el servicio sigue desnudo. En este caso el riesgo real era acotado (el login de PAM seguía exigiendo credenciales válidas, no acceso libre), pero es exactamente el tipo de ventana que no debería depender de la suerte.

Resultado

Cadena completa funcionando de extremo a extremo: navegador → Cloudflare Access (email + PIN, restringido a una IP autorizada) → Cloudflare Tunnel (sin ningún puerto abierto en el router) → ttyd → login real de Linux → shell completa, con los mismos permisos que tendría delante del teclado físico. El LXC y el servicio sobreviven a un reinicio sin intervención manual.

El despliegue en sí —cómo se creó el LXC, cómo se configuró, los problemas de esa parte (bootstrap del usuario de gestión, tags de Ansible, el campo started declarativo)— vive en las fichas de Terraform y Ansible, no aquí — este proyecto es la capa de encima: qué se construyó con esas piezas y por qué.

Proyectos relacionados

Este proyecto se apoya directamente en estos dos — el LXC de ttyd se crea con uno y se configura con el otro.

Notas rápidas

Referencia de uso y mantenimiento — sin tener que ir a buscarlo al repo.

Credenciales: cómo entrar y cómo salir

login autentica contra cuentas reales de Linux con contraseña puesta en ese LXC — no hay ninguna cuenta "de ttyd" aparte. Una cuenta con solo clave SSH autorizada (sin contraseña), como la de gestión de Ansible, no sirve aquí: PAM la rechaza siempre, da igual lo que se teclee.

Poner contraseña a un usuario (root o cualquier otro):

passwd <usuario>    # ej. passwd root, passwd hugo

Comprobar si ya tiene una puesta, sin cambiarla:

passwd -S <usuario>

El segundo campo de la salida lo dice todo: P (contraseña usable, puede entrar por login), L (bloqueada — típico en cuentas que solo se pensaron para clave SSH), NP (sin contraseña en absoluto). Solo P sirve para entrar por aquí.

Al terminar, exit o logout dentro de la terminal — no basta con cerrar la pestaña, eso deja la sesión colgada del lado del servidor hasta que expire por timeout en vez de cerrarse al momento. Tras un exit correcto, ttyd relanza un login nuevo automáticamente, listo para la siguiente autenticación.

Instalación de ttyd — script y pasos

Binario estático oficial, sin dependencias — se descarga directo en el LXC:

wget https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.x86_64
mv ttyd.x86_64 /usr/local/bin/ttyd
chmod +x /usr/local/bin/ttyd

Servicio systemd — nótese --writable, imprescindible (ver Problemas más arriba) y el comando login, no una shell directa:

cat > /etc/systemd/system/ttyd.service << 'EOF'
[Unit]
Description=ttyd - terminal web
After=network-online.target

[Service]
Type=simple
ExecStart=/usr/local/bin/ttyd -p 7681 --writable login
Restart=always
RestartSec=2

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable --now ttyd
Comprobar que sobrevive a un reinicio
# El LXC arranca solo con el nodo Proxmox:
pct config <vmid> | grep onboot        # debe decir "onboot: 1"

# El servicio arranca solo con el LXC:
systemctl is-enabled ttyd              # debe decir "enabled"
systemctl is-enabled cloudflared       # en el LXC del túnel — mismo criterio
Diagnosticar o actualizar el servicio
# Estado y logs en vivo:
systemctl status ttyd
journalctl -u ttyd -f

# Actualizar a una versión nueva:
systemctl stop ttyd
wget https://github.com/tsl0922/ttyd/releases/download/<version>/ttyd.x86_64 -O /usr/local/bin/ttyd
chmod +x /usr/local/bin/ttyd
systemctl start ttyd
Verificar que Access sigue delante, no solo la primera vez
curl -I https://ttyd.hjs.es

Debe devolver 302 con location hacia *.cloudflareaccess.com. Un 200 directo significa que la ruta pública está sirviendo sin la capa de Access delante — comprobarlo después de cualquier cambio en la configuración del túnel, no solo al desplegar por primera vez.

Ver el código

Este proyecto no tiene repositorio propio — el LXC se declara en homelab-terraform y se configura desde homelab-ansible: