diff --git a/app/routes.py b/app/routes.py index 6186e3b..8d89f08 100644 --- a/app/routes.py +++ b/app/routes.py @@ -5,7 +5,7 @@ import json from datetime import datetime, timezone import aiosqlite -from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket from fastapi.responses import HTMLResponse, StreamingResponse from sse_starlette.sse import EventSourceResponse @@ -984,6 +984,13 @@ async def test_ssh(): return {"ok": True} +@router.websocket("/ws/terminal") +async def terminal_ws(websocket: WebSocket): + """WebSocket endpoint bridging the browser xterm.js terminal to an SSH PTY.""" + from app import terminal as _term + await _term.handle(websocket) + + @router.get("/api/v1/updates/check") async def check_updates(): """Check for a newer release on Forgejo.""" diff --git a/app/static/app.css b/app/static/app.css index 8b663cb..94dafc4 100644 --- a/app/static/app.css +++ b/app/static/app.css @@ -2408,3 +2408,42 @@ tr.drawer-row-active { padding-bottom: 1px; font-variant-numeric: tabular-nums; } + +/* ----------------------------------------------------------------------- + Live Terminal drawer panel (xterm.js) +----------------------------------------------------------------------- */ +.drawer-panel-terminal { + padding: 0 !important; + overflow: hidden !important; + position: relative; + background: #0d1117; +} + +/* Let xterm fill the full panel height */ +.drawer-panel-terminal .xterm { + height: 100%; +} +.drawer-panel-terminal .xterm-viewport { + overflow-y: auto !important; +} + +/* Reconnect bar — floats over the terminal when disconnected */ +.term-reconnect-bar { + position: absolute; + bottom: 12px; + right: 12px; + z-index: 20; + display: flex; + align-items: center; + gap: 8px; + background: rgba(13,17,23,0.85); + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px 10px; + font-size: 12px; + color: var(--text-muted); +} +.term-reconnect-bar .btn-secondary { + padding: 3px 10px; + font-size: 11px; +} diff --git a/app/static/app.js b/app/static/app.js index 3d9c5b2..ca59f68 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -1117,6 +1117,14 @@ document.querySelectorAll('.drawer-panel').forEach(function (p) { p.classList.toggle('active', p.id === 'drawer-panel-' + _drawerTab); }); + // Terminal tab: init/fit on activation; hide autoscroll (N/A for terminal) + var asl = document.querySelector('.autoscroll-label'); + if (_drawerTab === 'terminal') { + if (asl) asl.style.visibility = 'hidden'; + openTerminalTab(); + } else { + if (asl) asl.style.visibility = ''; + } }); // Close button @@ -1141,4 +1149,155 @@ }).catch(function () { showToast('Network error', 'error'); }); }); + // ----------------------------------------------------------------------- + // Live Terminal (xterm.js + SSH WebSocket) + // ----------------------------------------------------------------------- + + var _xtermReady = false; // xterm.js + FitAddon libraries loaded + var _terminal = null; // xterm.js Terminal instance + var _termFit = null; // FitAddon instance + var _termWs = null; // active WebSocket (null = disconnected) + + function _loadXtermLibs(cb) { + var link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = 'https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css'; + document.head.appendChild(link); + + var s1 = document.createElement('script'); + s1.src = 'https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js'; + s1.onload = function () { + var s2 = document.createElement('script'); + s2.src = 'https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js'; + s2.onload = cb; + document.head.appendChild(s2); + }; + document.head.appendChild(s1); + } + + function openTerminalTab() { + var panel = document.getElementById('drawer-panel-terminal'); + if (!panel) return; + + if (!_xtermReady) { + panel.innerHTML = '
Loading terminal\u2026
'; + _loadXtermLibs(function () { + _xtermReady = true; + _termInit(panel); + }); + return; + } + + if (!_terminal) { + _termInit(panel); + return; + } + + // Already initialised — refit to current panel dimensions + setTimeout(function () { + if (_termFit) try { _termFit.fit(); } catch (_) {} + }, 30); + } + + function _termInit(panel) { + panel.innerHTML = ''; + + var term = new Terminal({ + cursorBlink: true, + fontSize: 13, + fontFamily: '"SF Mono","Fira Code",Consolas,"DejaVu Sans Mono",monospace', + theme: { + background: '#0d1117', + foreground: '#e6edf3', + cursor: '#58a6ff', + cursorAccent: '#0d1117', + selectionBackground: 'rgba(88,166,255,0.25)', + black: '#484f58', red: '#ff7b72', green: '#3fb950', yellow: '#d29922', + blue: '#58a6ff', magenta: '#bc8cff', cyan: '#39c5cf', white: '#b1bac4', + brightBlack: '#6e7681', brightRed: '#ffa198', brightGreen: '#56d364', + brightYellow: '#e3b341', brightBlue: '#79c0ff', brightMagenta: '#d2a8ff', + brightCyan: '#56d4dd', brightWhite: '#f0f6fc', + }, + scrollback: 2000, + allowProposedApi: true, + }); + + var fit = new FitAddon.FitAddon(); + term.loadAddon(fit); + term.open(panel); + + _terminal = term; + _termFit = fit; + + // Initial fit after the panel is visible + setTimeout(function () { + if (_termFit) try { _termFit.fit(); } catch (_) {} + }, 30); + + // Forward all keystrokes → SSH (onData registered once here) + term.onData(function (data) { + if (_termWs && _termWs.readyState === 1) { + _termWs.send(new TextEncoder().encode(data)); + } + }); + + // Refit + notify server on resize + new ResizeObserver(function () { + if (!_termFit) return; + try { _termFit.fit(); } catch (_) {} + if (_termWs && _termWs.readyState === 1 && _terminal) { + _termWs.send(JSON.stringify({ type: 'resize', cols: _terminal.cols, rows: _terminal.rows })); + } + }).observe(panel); + + _termConnect(); + } + + function _termConnect() { + if (_termWs && _termWs.readyState <= 1) return; // already open or connecting + + var proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + var ws = new WebSocket(proto + '//' + location.host + '/ws/terminal'); + ws.binaryType = 'arraybuffer'; + _termWs = ws; + + ws.onopen = function () { + _termHideReconnect(); + if (_terminal && ws.readyState === 1) { + ws.send(JSON.stringify({ type: 'resize', cols: _terminal.cols, rows: _terminal.rows })); + } + }; + + ws.onmessage = function (e) { + if (!_terminal) return; + _terminal.write(e.data instanceof ArrayBuffer ? new Uint8Array(e.data) : e.data); + }; + + ws.onclose = function () { + if (_terminal) _terminal.write('\r\n\x1b[33m\u2500\u2500 disconnected \u2500\u2500\x1b[0m\r\n'); + _termShowReconnect(); + }; + + ws.onerror = function () { /* onclose fires too */ }; + } + + function _termShowReconnect() { + var panel = document.getElementById('drawer-panel-terminal'); + if (!panel || panel.querySelector('.term-reconnect-bar')) return; + var bar = document.createElement('div'); + bar.className = 'term-reconnect-bar'; + bar.innerHTML = 'Connection closed' + + ''; + bar.querySelector('button').onclick = function () { + bar.remove(); + _termConnect(); + }; + panel.appendChild(bar); + } + + function _termHideReconnect() { + var bar = document.querySelector('.term-reconnect-bar'); + if (bar) bar.remove(); + } + }()); diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 1f08320..dd7ec38 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -83,6 +83,7 @@ +
{% endblock %} diff --git a/app/terminal.py b/app/terminal.py new file mode 100644 index 0000000..0d489e6 --- /dev/null +++ b/app/terminal.py @@ -0,0 +1,143 @@ +""" +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