enh: open terminal

This commit is contained in:
Timothy Jaeryang Baek
2026-03-02 14:49:02 -06:00
parent 75683e5197
commit 4f6cb771f1
5 changed files with 622 additions and 547 deletions
+150 -1
View File
@@ -8,13 +8,14 @@ Routes:
import logging import logging
import aiohttp import aiohttp
from fastapi import APIRouter, Depends, Request, Response from fastapi import APIRouter, Depends, Request, Response, WebSocket
from fastapi.responses import JSONResponse, StreamingResponse from fastapi.responses import JSONResponse, StreamingResponse
from starlette.background import BackgroundTask from starlette.background import BackgroundTask
from open_webui.utils.auth import get_verified_user from open_webui.utils.auth import get_verified_user
from open_webui.utils.access_control import has_connection_access from open_webui.utils.access_control import has_connection_access
from open_webui.models.groups import Groups from open_webui.models.groups import Groups
from open_webui.models.users import Users
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -149,3 +150,151 @@ async def proxy_terminal(
return JSONResponse( return JSONResponse(
{"error": f"Terminal proxy error: {error}"}, status_code=502 {"error": f"Terminal proxy error: {error}"}, status_code=502
) )
# ---------------------------------------------------------------------------
# WebSocket proxy for interactive terminal sessions
# ---------------------------------------------------------------------------
async def _resolve_authenticated_connection(ws: WebSocket, server_id: str):
"""Authenticate a WebSocket via first-message auth and resolve the terminal server.
The client must send ``{"type": "auth", "token": "<jwt>"}`` as its first
message after connecting.
Returns ``(user, connection)`` on success, or ``None`` after closing *ws*
with an appropriate error code.
"""
import asyncio
import json
from open_webui.utils.auth import decode_token
# First-message authentication
try:
raw = await asyncio.wait_for(ws.receive_text(), timeout=10.0)
payload = json.loads(raw)
if payload.get("type") != "auth":
await ws.close(code=4001, reason="Expected auth message")
return None
token = payload.get("token", "")
data = decode_token(token)
if data is None or "id" not in data:
await ws.close(code=4001, reason="Invalid token")
return None
user = Users.get_user_by_id(data["id"])
if user is None:
await ws.close(code=4001, reason="User not found")
return None
except (asyncio.TimeoutError, json.JSONDecodeError):
await ws.close(code=4001, reason="Auth timeout or invalid payload")
return None
except Exception:
await ws.close(code=4001, reason="Invalid token")
return None
# Resolve terminal server
connections = ws.app.state.config.TERMINAL_SERVER_CONNECTIONS or []
connection = next((c for c in connections if c.get("id") == server_id), None)
if connection is None:
await ws.close(code=4004, reason="Terminal server not found")
return None
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)}
if not has_connection_access(user, connection, user_group_ids):
await ws.close(code=4003, reason="Access denied")
return None
return user, connection
@router.websocket("/{server_id}/api/terminals/{session_id}")
async def ws_terminal(
ws: WebSocket,
server_id: str,
session_id: str,
):
"""Proxy an interactive WebSocket terminal session to a terminal server.
Uses first-message auth: the client sends ``{"type": "auth", "token": "<jwt>"}``
as its first message. The proxy validates the JWT, then connects to the
upstream terminal server and authenticates with the server's API key.
"""
await ws.accept()
result = await _resolve_authenticated_connection(ws, server_id)
if result is None:
return
user, connection = result
base_url = (connection.get("url") or "").rstrip("/")
if not base_url:
await ws.close(code=4003, reason="Terminal server URL not configured")
return
# Build upstream WebSocket URL (no token in URL)
ws_base = base_url.replace("https://", "wss://").replace("http://", "ws://")
auth_type = connection.get("auth_type", "bearer")
upstream_params = {}
# For orchestrator-backed servers, pass user_id
upstream_params["user_id"] = user.id
import urllib.parse
upstream_url = f"{ws_base}/api/terminals/{session_id}"
if upstream_params:
upstream_url += f"?{urllib.parse.urlencode(upstream_params)}"
session = aiohttp.ClientSession()
try:
async with session.ws_connect(upstream_url) as upstream:
import asyncio
import json as _json
# First-message auth to upstream terminal server
auth_type = connection.get("auth_type", "bearer")
if auth_type == "bearer":
key = connection.get("key", "")
await upstream.send_str(_json.dumps({"type": "auth", "token": key}))
async def _client_to_upstream():
"""Forward client → upstream."""
try:
while True:
msg = await ws.receive()
if msg["type"] == "websocket.disconnect":
break
elif "bytes" in msg and msg["bytes"]:
await upstream.send_bytes(msg["bytes"])
elif "text" in msg and msg["text"]:
await upstream.send_str(msg["text"])
except Exception:
pass
async def _upstream_to_client():
"""Forward upstream → client."""
try:
async for msg in upstream:
if msg.type == aiohttp.WSMsgType.BINARY:
await ws.send_bytes(msg.data)
elif msg.type == aiohttp.WSMsgType.TEXT:
await ws.send_text(msg.data)
elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.ERROR):
break
except Exception:
pass
await asyncio.gather(
_client_to_upstream(),
_upstream_to_client(),
return_exceptions=True,
)
except Exception as e:
log.exception("Terminal WebSocket proxy error: %s", e)
finally:
await session.close()
try:
await ws.close()
except Exception:
pass
+123 -546
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -82,6 +82,8 @@
"@tiptap/pm": "^3.0.7", "@tiptap/pm": "^3.0.7",
"@tiptap/starter-kit": "^3.0.7", "@tiptap/starter-kit": "^3.0.7",
"@tiptap/suggestion": "^3.4.2", "@tiptap/suggestion": "^3.4.2",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"@xyflow/svelte": "^0.1.19", "@xyflow/svelte": "^0.1.19",
"alpinejs": "^3.15.0", "alpinejs": "^3.15.0",
+86
View File
@@ -36,12 +36,47 @@
import FileNavToolbar from './FileNav/FileNavToolbar.svelte'; import FileNavToolbar from './FileNav/FileNavToolbar.svelte';
import FilePreview from './FileNav/FilePreview.svelte'; import FilePreview from './FileNav/FilePreview.svelte';
import FileEntryRow from './FileNav/FileEntryRow.svelte'; import FileEntryRow from './FileNav/FileEntryRow.svelte';
import XTerminal from './XTerminal.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
export let onAttach: ((blob: Blob, name: string, contentType: string) => void) | null = null; export let onAttach: ((blob: Blob, name: string, contentType: string) => void) | null = null;
export let overlay = false; export let overlay = false;
// ── Terminal panel state ────────────────────────────────────────────
let terminalExpanded = false;
let terminalHeight = 200; // px, default when expanded
let isDraggingHandle = false;
let containerEl: HTMLElement;
let terminalConnected = false;
let terminalConnecting = false;
const toggleTerminal = () => {
terminalExpanded = !terminalExpanded;
};
const onHandleMouseDown = (e: MouseEvent) => {
e.preventDefault();
isDraggingHandle = true;
const startY = e.clientY;
const startHeight = terminalHeight;
const onMouseMove = (ev: MouseEvent) => {
const delta = startY - ev.clientY;
const maxH = containerEl ? containerEl.clientHeight - 100 : 500;
terminalHeight = Math.max(80, Math.min(maxH, startHeight + delta));
};
const onMouseUp = () => {
isDraggingHandle = false;
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
};
// ── Directory state ────────────────────────────────────────────────── // ── Directory state ──────────────────────────────────────────────────
let currentPath = savedPath; let currentPath = savedPath;
let entries: FileEntry[] = []; let entries: FileEntry[] = [];
@@ -454,6 +489,7 @@
</div> </div>
{:else} {:else}
<div <div
bind:this={containerEl}
class="flex flex-col h-full min-h-0 relative" class="flex flex-col h-full min-h-0 relative"
on:dragover={handleDragOver} on:dragover={handleDragOver}
on:dragleave={() => (isDragOver = false)} on:dragleave={() => (isDragOver = false)}
@@ -754,5 +790,55 @@
{/if} {/if}
{/if} {/if}
</div> </div>
<!-- Terminal bottom panel -->
<div class="shrink-0 border-t border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-850">
{#if terminalExpanded}
<!-- Drag handle (at top of panel) -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="h-1 cursor-row-resize hover:bg-blue-400/30 transition group relative"
on:mousedown={onHandleMouseDown}
>
<div class="absolute inset-x-0 -top-1 -bottom-1" />
</div>
{/if}
<!-- Toggle header (full-width button) -->
<button
class="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition"
on:click={toggleTerminal}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5">
<path fill-rule="evenodd" d="M3.25 3A2.25 2.25 0 0 0 1 5.25v9.5A2.25 2.25 0 0 0 3.25 17h13.5A2.25 2.25 0 0 0 19 14.75v-9.5A2.25 2.25 0 0 0 16.75 3H3.25Zm.943 8.752a.75.75 0 0 1 .055-1.06L6.128 9l-1.88-1.693a.75.75 0 1 1 1.004-1.114l2.5 2.25a.75.75 0 0 1 0 1.114l-2.5 2.25a.75.75 0 0 1-1.06-.055ZM9.75 10.25a.75.75 0 0 0 0 1.5h2.5a.75.75 0 0 0 0-1.5h-2.5Z" clip-rule="evenodd" />
</svg>
<span class="font-medium">{$i18n.t('Terminal')}</span>
{#if terminalExpanded}
<div
class="w-1.5 h-1.5 rounded-full transition-colors {terminalConnected
? 'bg-emerald-500'
: terminalConnecting
? 'bg-yellow-500 animate-pulse'
: 'bg-gray-400'}"
/>
{/if}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3 ml-auto transition-transform {terminalExpanded ? 'rotate-180' : ''}"
>
<path fill-rule="evenodd" d="M9.47 6.47a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 1 1-1.06 1.06L10 8.06l-3.72 3.72a.75.75 0 0 1-1.06-1.06l4.25-4.25Z" clip-rule="evenodd" />
</svg>
</button>
{#if terminalExpanded}
<div style="height: {terminalHeight}px" class="min-h-0">
<XTerminal {overlay} bind:connected={terminalConnected} bind:connecting={terminalConnecting} />
</div>
{/if}
</div>
</div> </div>
{/if} {/if}
+261
View File
@@ -0,0 +1,261 @@
<script lang="ts">
import { onMount, onDestroy, getContext } from 'svelte';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links';
import '@xterm/xterm/css/xterm.css';
import {
terminalServers,
settings,
selectedTerminalId,
user
} from '$lib/stores';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import Tooltip from '$lib/components/common/Tooltip.svelte';
const i18n = getContext('i18n');
export let overlay = false;
let terminalEl: HTMLDivElement;
let term: Terminal | null = null;
let fitAddon: FitAddon | null = null;
let ws: WebSocket | null = null;
export let connected = false;
export let connecting = false;
let resizeObserver: ResizeObserver | null = null;
// Resolve the active terminal server's info for the WebSocket URL
const getTerminalInfo = (): { serverId: string; baseUrl: string } | null => {
// System terminal (admin-configured, has an `id`)
const systemTerminals = ($terminalServers ?? []).filter((t: any) => t.id);
const systemMatch = systemTerminals.find((t: any) => t.id === $selectedTerminalId);
if (systemMatch) {
// For system terminals, WS goes through the Open WebUI backend proxy
return { serverId: systemMatch.id, baseUrl: WEBUI_API_BASE_URL };
}
// Direct terminal (user-configured, matched by URL)
const directTerminals = ($settings?.terminalServers ?? []).filter((s: any) => s.url);
const directMatch = directTerminals.find((s: any) => s.url === $selectedTerminalId);
if (directMatch) {
// For direct terminals, construct WS URL from the server URL directly
return { serverId: '__direct__', baseUrl: directMatch.url };
}
return null;
};
const connect = async () => {
if (ws) disconnect();
const info = getTerminalInfo();
if (!info) return;
connecting = true;
const token = localStorage.getItem('token') ?? '';
try {
let sessionId: string;
let wsUrl: string;
let authToken: string;
if (info.serverId === '__direct__') {
// Direct connection to open-terminal
const base = info.baseUrl.replace(/\/$/, '');
const directTerminals = ($settings?.terminalServers ?? []).filter((s: any) => s.url);
const directMatch = directTerminals.find(
(s: any) => s.url === $selectedTerminalId
);
const apiKey = directMatch?.key ?? '';
authToken = apiKey;
// Create session
const res = await fetch(`${base}/api/terminals`, {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}` }
});
if (!res.ok) throw new Error(`Failed to create session: ${res.status}`);
const session = await res.json();
sessionId = session.id;
const wsBase = base.replace(/^https:/, 'wss:').replace(/^http:/, 'ws:');
wsUrl = `${wsBase}/api/terminals/${sessionId}`;
} else {
// System terminal — proxy through Open WebUI backend
const base = info.baseUrl.replace(/\/$/, '');
authToken = token;
// Create session via proxy
const res = await fetch(`${base}/terminals/${info.serverId}/api/terminals`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` }
});
if (!res.ok) throw new Error(`Failed to create session: ${res.status}`);
const session = await res.json();
sessionId = session.id;
const wsBase = base.replace(/^https:/, 'wss:').replace(/^http:/, 'ws:');
wsUrl = `${wsBase}/terminals/${info.serverId}/api/terminals/${sessionId}`;
}
ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
// First-message auth (no token in URL)
if (ws) {
ws.send(JSON.stringify({ type: 'auth', token: authToken }));
}
connected = true;
connecting = false;
// Send initial resize
if (term && ws) {
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
}
};
ws.onmessage = (event) => {
if (term) {
if (event.data instanceof ArrayBuffer) {
term.write(new Uint8Array(event.data));
} else {
term.write(event.data);
}
}
};
ws.onclose = () => {
connected = false;
connecting = false;
if (term) {
term.write('\r\n\x1b[90m[Connection closed]\x1b[0m\r\n');
}
};
ws.onerror = () => {
connected = false;
connecting = false;
};
} catch (err) {
connecting = false;
if (term) {
term.write(`\r\n\x1b[31m[Error: ${err}]\x1b[0m\r\n`);
}
}
};
const disconnect = () => {
if (ws) {
ws.close();
ws = null;
}
connected = false;
connecting = false;
};
const initTerminal = () => {
if (!terminalEl || term) return;
term = new Terminal({
cursorBlink: true,
fontSize: 13,
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, 'Courier New', monospace",
theme: {
background: '#1a1b26',
foreground: '#c0caf5',
cursor: '#c0caf5',
cursorAccent: '#1a1b26',
selectionBackground: '#33467c',
selectionForeground: '#c0caf5',
black: '#15161e',
red: '#f7768e',
green: '#9ece6a',
yellow: '#e0af68',
blue: '#7aa2f7',
magenta: '#bb9af7',
cyan: '#7dcfff',
white: '#a9b1d6',
brightBlack: '#414868',
brightRed: '#f7768e',
brightGreen: '#9ece6a',
brightYellow: '#e0af68',
brightBlue: '#7aa2f7',
brightMagenta: '#bb9af7',
brightCyan: '#7dcfff',
brightWhite: '#c0caf5'
},
allowProposedApi: true,
scrollback: 5000
});
fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.loadAddon(new WebLinksAddon());
term.open(terminalEl);
// Fit after a frame so the container has dimensions
requestAnimationFrame(() => {
fitAddon?.fit();
});
// Forward keystrokes to WebSocket
term.onData((data) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(new TextEncoder().encode(data));
}
});
// Forward binary data (e.g. paste with special chars)
term.onBinary((data) => {
if (ws && ws.readyState === WebSocket.OPEN) {
const buffer = new Uint8Array(data.length);
for (let i = 0; i < data.length; i++) {
buffer[i] = data.charCodeAt(i) & 0xff;
}
ws.send(buffer);
}
});
// Handle resize
term.onResize(({ cols, rows }) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'resize', cols, rows }));
}
});
// Watch container size changes
resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
fitAddon?.fit();
});
});
resizeObserver.observe(terminalEl);
// Auto-connect
connect();
};
onMount(() => {
initTerminal();
});
onDestroy(() => {
disconnect();
resizeObserver?.disconnect();
term?.dispose();
term = null;
fitAddon = null;
});
</script>
<div class="h-full min-h-0 relative">
<div
bind:this={terminalEl}
class="absolute inset-0 p-1"
class:pointer-events-none={overlay}
/>
</div>