This commit is contained in:
Timothy Jaeryang Baek
2026-09-21 10:30:53 -04:00
parent 754c4b5762
commit e8bd0661d3
+42 -23
View File
@@ -5,6 +5,7 @@ Routes:
* /{server_id}/{path:path} — proxy request to terminal server * /{server_id}/{path:path} — proxy request to terminal server
""" """
import asyncio
import logging import logging
import posixpath import posixpath
from urllib.parse import unquote from urllib.parse import unquote
@@ -18,17 +19,17 @@ from open_webui.events import EVENTS, publish_event
from open_webui.models.config import Config from open_webui.models.config import Config
from open_webui.models.groups import Groups from open_webui.models.groups import Groups
from open_webui.utils.access_control import has_connection_access from open_webui.utils.access_control import has_connection_access
from open_webui.utils.auth import get_verified_user from open_webui.utils.auth import get_verified_user, get_verified_user_by_token
from open_webui.utils.headers import bearer_auth_header, normalize_bearer_token from open_webui.utils.headers import bearer_auth_header, normalize_bearer_token
from open_webui.utils.json_codec import JSONCodec from open_webui.utils.json_codec import JSONCodec
from open_webui.utils.terminals import ( from open_webui.utils.terminals import (
TERMINAL_CONTEXT_HEADER, TERMINAL_CONTEXT_HEADER,
get_terminal_server_url, get_terminal_server_url,
is_terminal_orchestrator, is_terminal_orchestrator,
terminal_chat_uploads,
terminal_context_available, terminal_context_available,
terminal_context_config, terminal_context_config,
terminal_context_id, terminal_context_id,
terminal_chat_uploads,
terminal_contexts, terminal_contexts,
) )
from starlette.background import BackgroundTask from starlette.background import BackgroundTask
@@ -262,10 +263,6 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str):
Returns ``(user, connection, chat_id, token)`` on success, or ``None`` after Returns ``(user, connection, chat_id, token)`` on success, or ``None`` after
closing *ws* with an appropriate error code. closing *ws* with an appropriate error code.
""" """
import asyncio
from open_webui.utils.auth import get_verified_user_by_token
# First-message authentication # First-message authentication
try: try:
raw = await asyncio.wait_for(ws.receive_text(), timeout=10.0) raw = await asyncio.wait_for(ws.receive_text(), timeout=10.0)
@@ -274,13 +271,28 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str):
await ws.close(code=4001, reason='Expected auth message') await ws.close(code=4001, reason='Expected auth message')
return None return None
token = payload.get('token', '') token = payload.get('token', '')
except (TimeoutError, JSONCodec.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
result = await _resolve_terminal_access(ws, server_id, token)
if result is None:
return None
user, connection = result
chat_id = payload.get('chat_id', '')
return user, connection, chat_id if isinstance(chat_id, str) else '', token
async def _resolve_terminal_access(ws: WebSocket, server_id: str, token: str):
"""Resolve current access for both the handshake and an open terminal session."""
try:
user = await get_verified_user_by_token(token, getattr(ws.app.state, 'redis', None)) user = await get_verified_user_by_token(token, getattr(ws.app.state, 'redis', None))
if user is None: if user is None:
await ws.close(code=4001, reason='Invalid token') await ws.close(code=4001, reason='Invalid token')
return None return None
except (asyncio.TimeoutError, JSONCodec.JSONDecodeError):
await ws.close(code=4001, reason='Auth timeout or invalid payload')
return None
except Exception: except Exception:
await ws.close(code=4001, reason='Invalid token') await ws.close(code=4001, reason='Invalid token')
return None return None
@@ -297,16 +309,14 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str):
await ws.close(code=4003, reason='Terminal server disabled') await ws.close(code=4003, reason='Terminal server disabled')
return None return None
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)} if not await has_connection_access(user, connection):
if not await has_connection_access(user, connection, user_group_ids):
await ws.close(code=4003, reason='Access denied') await ws.close(code=4003, reason='Access denied')
return None return None
chat_id = payload.get('chat_id', '')
if not terminal_context_available(connection, 'chat'): if not terminal_context_available(connection, 'chat'):
await ws.close(code=4003, reason='Terminal server is not available in chats') await ws.close(code=4003, reason='Terminal server is not available in chats')
return None return None
return user, connection, chat_id if isinstance(chat_id, str) else '', token return user, connection
@router.websocket('/{server_id}/api/terminals/{session_id}') @router.websocket('/{server_id}/api/terminals/{session_id}')
@@ -366,7 +376,6 @@ async def ws_terminal(
headers=upstream_headers, headers=upstream_headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL, ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as upstream: ) as upstream:
import asyncio
import json as _json import json as _json
# First-message auth to upstream terminal server # First-message auth to upstream terminal server
@@ -419,20 +428,30 @@ async def ws_terminal(
except Exception: except Exception:
pass pass
# End the proxy as soon as either direction finishes (e.g. a async def _watch_access():
# graceful upstream CLOSE) and cancel the sibling, which would try:
while True:
# Poll current state so revocation also works across workers.
await asyncio.sleep(10)
if await _resolve_terminal_access(ws, server_id, token) is None:
return
except Exception:
log.exception('Terminal access recheck failed')
# End the proxy as soon as any task finishes (e.g. a
# graceful upstream CLOSE) and cancel the rest, which would
# otherwise hang on a blocked ws.receive() until the browser leaves. # otherwise hang on a blocked ws.receive() until the browser leaves.
tasks = [ tasks = [
asyncio.create_task(_client_to_upstream()), asyncio.create_task(_client_to_upstream()),
asyncio.create_task(_upstream_to_client()), asyncio.create_task(_upstream_to_client()),
asyncio.create_task(_watch_access()),
] ]
_done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) try:
for task in pending: await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
task.cancel() finally:
try: for task in tasks:
await task task.cancel()
except asyncio.CancelledError: await asyncio.gather(*tasks, return_exceptions=True)
pass
except Exception as e: except Exception as e:
log.exception('Terminal WebSocket proxy error: %s', e) log.exception('Terminal WebSocket proxy error: %s', e)
finally: finally: