truenas-burnin/app/terminal.py
Brandon Walter 5a802bff2e feat: live SSH terminal in drawer (xterm.js + asyncssh WebSocket)
Adds a Terminal tab to the log drawer with a full PTY session bridged
over WebSocket to the TrueNAS SSH host. xterm.js loaded lazily on
first tab open. Supports resize, paste, full color, and reconnect.

- app/terminal.py: asyncssh PTY ↔ WebSocket bridge
- routes.py: @router.websocket("/ws/terminal")
- dashboard.html: Terminal tab + drawer panel
- app.js: xterm.js lazy load, init, onData, resize observer, reconnect
- app.css: terminal panel styles (no padding, overflow hidden)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 09:30:56 -05:00

143 lines
4.9 KiB
Python

"""
WebSocket → asyncssh PTY bridge for the live terminal drawer tab.
Protocol
--------
Client → server: binary = raw terminal input bytes
text = JSON control message, e.g. {"type":"resize","cols":80,"rows":24}
Server → client: binary = raw terminal output bytes
"""
import asyncio
import json
import logging
import asyncssh
from fastapi import WebSocket, WebSocketDisconnect
log = logging.getLogger(__name__)
async def handle(ws: WebSocket) -> None:
"""Accept a WebSocket connection and bridge it to an SSH PTY."""
await ws.accept()
from app.config import settings # late import — avoids circular at module level
# ── Guard: SSH must be configured ──────────────────────────────────────
if not settings.ssh_host:
await _send(ws,
b"\r\n\x1b[33mSSH not configured.\x1b[0m "
b"Set SSH Host in \x1b[1mSettings \u2192 SSH\x1b[0m first.\r\n"
)
await ws.close(1008)
return
connect_kw: dict = dict(
host=settings.ssh_host,
port=settings.ssh_port,
username=settings.ssh_user,
known_hosts=None,
)
if settings.ssh_key.strip():
try:
connect_kw["client_keys"] = [asyncssh.import_private_key(settings.ssh_key)]
except Exception as exc:
await _send(ws, f"\r\n\x1b[31mBad SSH key: {exc}\x1b[0m\r\n".encode())
await ws.close(1011)
return
elif settings.ssh_password:
connect_kw["password"] = settings.ssh_password
else:
await _send(ws,
b"\r\n\x1b[33mNo SSH credentials configured.\x1b[0m "
b"Set a password or private key in Settings.\r\n"
)
await ws.close(1008)
return
await _send(ws,
f"\r\n\x1b[36mConnecting to {settings.ssh_host}\u2026\x1b[0m\r\n".encode()
)
# ── Open SSH connection ─────────────────────────────────────────────────
try:
async with asyncssh.connect(**connect_kw) as conn:
process = await conn.create_process(
term_type="xterm-256color",
term_size=(80, 24),
encoding=None, # raw bytes — xterm.js handles encoding
)
await _send(ws, b"\r\n\x1b[32mConnected\x1b[0m\r\n\r\n")
stop = asyncio.Event()
async def ssh_to_ws() -> None:
try:
async for chunk in process.stdout:
await ws.send_bytes(chunk)
except Exception:
pass
finally:
stop.set()
async def ws_to_ssh() -> None:
try:
while not stop.is_set():
msg = await ws.receive()
if msg["type"] == "websocket.disconnect":
break
if msg.get("bytes"):
process.stdin.write(msg["bytes"])
elif msg.get("text"):
try:
ctrl = json.loads(msg["text"])
if ctrl.get("type") == "resize":
process.change_terminal_size(
int(ctrl["cols"]), int(ctrl["rows"])
)
except Exception:
pass
except WebSocketDisconnect:
pass
except Exception:
pass
finally:
stop.set()
t1 = asyncio.create_task(ssh_to_ws())
t2 = asyncio.create_task(ws_to_ssh())
_done, pending = await asyncio.wait(
[t1, t2], return_when=asyncio.FIRST_COMPLETED
)
for t in pending:
t.cancel()
try:
await t
except asyncio.CancelledError:
pass
except asyncssh.PermissionDenied:
await _send(ws, b"\r\n\x1b[31mSSH permission denied.\x1b[0m\r\n")
except asyncssh.DisconnectError as exc:
await _send(ws, f"\r\n\x1b[31mSSH disconnected: {exc}\x1b[0m\r\n".encode())
except OSError as exc:
await _send(ws, f"\r\n\x1b[31mCannot reach {settings.ssh_host}: {exc}\x1b[0m\r\n".encode())
except Exception as exc:
log.exception("Terminal WebSocket error")
await _send(ws, f"\r\n\x1b[31mError: {exc}\x1b[0m\r\n".encode())
finally:
try:
await ws.close()
except Exception:
pass
async def _send(ws: WebSocket, data: bytes) -> None:
"""Best-effort send — silently swallow errors if the socket is already gone."""
try:
await ws.send_bytes(data)
except Exception:
pass