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.
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.
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.
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.
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.
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).
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.
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.
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.
--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.
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.
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.
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é.
Este proyecto se apoya directamente en estos dos — el LXC de ttyd se crea con uno y se configura con el otro.
Referencia de uso y mantenimiento — sin tener que ir a buscarlo al repo.
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.
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
# 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
# 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
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.
Este proyecto no tiene repositorio propio — el LXC se declara en homelab-terraform y se configura desde homelab-ansible: