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>
143 lines
4.9 KiB
Python
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
|