Skip to content

Commit 4737e1f

Browse files
committed
feat: open terminal integration
1 parent 7ea6afd commit 4737e1f

15 files changed

Lines changed: 767 additions & 64 deletions

File tree

backend/open_webui/config.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1183,6 +1183,20 @@ def reachable(host: str, port: int) -> bool:
11831183
tool_server_connections,
11841184
)
11851185

1186+
####################################
1187+
# TERMINAL_SERVER
1188+
####################################
1189+
1190+
terminal_server_connections = json.loads(
1191+
os.environ.get("TERMINAL_SERVER_CONNECTIONS", "[]")
1192+
)
1193+
1194+
TERMINAL_SERVER_CONNECTIONS = PersistentConfig(
1195+
"TERMINAL_SERVER_CONNECTIONS",
1196+
"terminal_server.connections",
1197+
terminal_server_connections,
1198+
)
1199+
11861200
####################################
11871201
# WEBUI
11881202
####################################

backend/open_webui/main.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@
9696
users,
9797
utils,
9898
scim,
99+
terminals,
99100
)
100101

101102
from open_webui.routers.retrieval import (
@@ -132,6 +133,8 @@
132133
THREAD_POOL_SIZE,
133134
# Tool Server Configs
134135
TOOL_SERVER_CONNECTIONS,
136+
# Terminal Server
137+
TERMINAL_SERVER_CONNECTIONS,
135138
# Code Execution
136139
ENABLE_CODE_EXECUTION,
137140
CODE_EXECUTION_ENGINE,
@@ -524,7 +527,7 @@
524527
process_chat_payload,
525528
process_chat_response,
526529
)
527-
from open_webui.utils.tools import set_tool_servers
530+
from open_webui.utils.tools import set_tool_servers, set_terminal_servers
528531

529532
from open_webui.utils.auth import (
530533
get_license_data,
@@ -690,8 +693,11 @@ async def lifespan(app: FastAPI):
690693
)
691694
await set_tool_servers(mock_request)
692695
log.info(f"Initialized {len(app.state.TOOL_SERVERS)} tool server(s)")
696+
697+
await set_terminal_servers(mock_request)
698+
log.info(f"Initialized {len(app.state.TERMINAL_SERVERS)} terminal server(s)")
693699
except Exception as e:
694-
log.warning(f"Failed to initialize tool servers at startup: {e}")
700+
log.warning(f"Failed to initialize tool/terminal servers at startup: {e}")
695701

696702
yield
697703

@@ -775,6 +781,15 @@ async def lifespan(app: FastAPI):
775781
app.state.config.TOOL_SERVER_CONNECTIONS = TOOL_SERVER_CONNECTIONS
776782
app.state.TOOL_SERVERS = []
777783

784+
########################################
785+
#
786+
# TERMINAL SERVER
787+
#
788+
########################################
789+
790+
app.state.config.TERMINAL_SERVER_CONNECTIONS = TERMINAL_SERVER_CONNECTIONS
791+
app.state.TERMINAL_SERVERS = []
792+
778793
########################################
779794
#
780795
# DIRECT CONNECTIONS
@@ -1540,6 +1555,7 @@ async def inspect_websocket(request: Request, call_next):
15401555
if ENABLE_ADMIN_ANALYTICS:
15411556
app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"])
15421557
app.include_router(utils.router, prefix="/api/v1/utils", tags=["utils"])
1558+
app.include_router(terminals.router, prefix="/api/v1/terminals", tags=["terminals"])
15431559

15441560
# SCIM 2.0 API for identity management
15451561
if ENABLE_SCIM:
@@ -2204,6 +2220,7 @@ async def get_app_config(request: Request):
22042220
"pending_user_overlay_content": app.state.config.PENDING_USER_OVERLAY_CONTENT,
22052221
"response_watermark": app.state.config.RESPONSE_WATERMARK,
22062222
},
2223+
22072224
"license_metadata": app.state.LICENSE_METADATA,
22082225
**(
22092226
{

backend/open_webui/routers/configs.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
get_tool_server_data,
1616
get_tool_server_url,
1717
set_tool_servers,
18+
set_terminal_servers,
1819
)
1920
from open_webui.utils.mcp.client import MCPClient
2021
from open_webui.models.oauth_sessions import OAuthSessions
@@ -214,6 +215,45 @@ async def set_tool_servers_config(
214215
}
215216

216217

218+
class TerminalServerConnection(BaseModel):
219+
id: str
220+
url: str
221+
key: Optional[str] = ""
222+
name: Optional[str] = ""
223+
auth_type: Optional[str] = "bearer"
224+
config: Optional[dict] = None # holds access_grants, etc.
225+
226+
model_config = ConfigDict(extra="allow")
227+
228+
229+
class TerminalServersConfigForm(BaseModel):
230+
TERMINAL_SERVER_CONNECTIONS: list[TerminalServerConnection]
231+
232+
233+
@router.get("/terminal_servers")
234+
async def get_terminal_servers_config(request: Request, user=Depends(get_admin_user)):
235+
return {
236+
"TERMINAL_SERVER_CONNECTIONS": request.app.state.config.TERMINAL_SERVER_CONNECTIONS,
237+
}
238+
239+
240+
@router.post("/terminal_servers")
241+
async def set_terminal_servers_config(
242+
request: Request,
243+
form_data: TerminalServersConfigForm,
244+
user=Depends(get_admin_user),
245+
):
246+
request.app.state.config.TERMINAL_SERVER_CONNECTIONS = [
247+
connection.model_dump() for connection in form_data.TERMINAL_SERVER_CONNECTIONS
248+
]
249+
250+
await set_terminal_servers(request)
251+
252+
return {
253+
"TERMINAL_SERVER_CONNECTIONS": request.app.state.config.TERMINAL_SERVER_CONNECTIONS,
254+
}
255+
256+
217257
@router.post("/tool_servers/verify")
218258
async def verify_tool_servers_config(
219259
request: Request, form_data: ToolServerConnection, user=Depends(get_admin_user)
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""Reverse proxy for admin-configured terminal servers.
2+
3+
Routes:
4+
GET / — list terminals the user has access to
5+
* /{server_id}/{path:path} — proxy request to terminal server
6+
"""
7+
8+
import logging
9+
10+
import aiohttp
11+
from fastapi import APIRouter, Depends, Request, Response
12+
from fastapi.responses import JSONResponse, StreamingResponse
13+
from starlette.background import BackgroundTask
14+
15+
from open_webui.utils.auth import get_verified_user
16+
from open_webui.utils.access_control import has_connection_access
17+
from open_webui.models.groups import Groups
18+
19+
log = logging.getLogger(__name__)
20+
21+
router = APIRouter()
22+
23+
STREAMING_CONTENT_TYPES = ("application/octet-stream", "image/", "application/pdf")
24+
STRIPPED_RESPONSE_HEADERS = frozenset(
25+
("transfer-encoding", "connection", "content-encoding", "content-length")
26+
)
27+
28+
29+
@router.get("/")
30+
async def list_terminal_servers(request: Request, user=Depends(get_verified_user)):
31+
"""Return terminal servers the authenticated user has access to."""
32+
connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or []
33+
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)}
34+
35+
return [
36+
{"id": connection.get("id", ""), "url": connection.get("url", ""), "name": connection.get("name", "")}
37+
for connection in connections
38+
if has_connection_access(user, connection, user_group_ids)
39+
]
40+
41+
42+
PROXY_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]
43+
44+
45+
@router.api_route("/{server_id}/{path:path}", methods=PROXY_METHODS)
46+
async def proxy_terminal(
47+
server_id: str,
48+
path: str,
49+
request: Request,
50+
user=Depends(get_verified_user),
51+
):
52+
"""Proxy a request to the admin terminal server identified by *server_id*."""
53+
connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or []
54+
connection = next((c for c in connections if c.get("id") == server_id), None)
55+
56+
if connection is None:
57+
return JSONResponse({"error": f"Terminal server '{server_id}' not found"}, status_code=404)
58+
59+
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)}
60+
if not has_connection_access(user, connection, user_group_ids):
61+
return JSONResponse({"error": "Access denied"}, status_code=403)
62+
63+
base_url = (connection.get("url") or "").rstrip("/")
64+
if not base_url:
65+
return JSONResponse({"error": "Terminal server URL not configured"}, status_code=503)
66+
67+
target_url = f"{base_url}/{path}"
68+
if request.query_params:
69+
target_url += f"?{request.query_params}"
70+
71+
headers = {"X-User-Id": user.id}
72+
cookies = {}
73+
auth_type = connection.get("auth_type", "bearer")
74+
75+
if auth_type == "bearer":
76+
headers["Authorization"] = f"Bearer {connection.get('key', '')}"
77+
elif auth_type == "session":
78+
cookies = request.cookies
79+
headers["Authorization"] = f"Bearer {request.state.token.credentials}"
80+
elif auth_type == "system_oauth":
81+
cookies = request.cookies
82+
oauth_token = request.headers.get("x-oauth-access-token", "")
83+
if oauth_token:
84+
headers["Authorization"] = f"Bearer {oauth_token}"
85+
# auth_type == "none": no Authorization header
86+
87+
content_type = request.headers.get("content-type")
88+
if content_type:
89+
headers["Content-Type"] = content_type
90+
91+
body = await request.body()
92+
session = aiohttp.ClientSession(
93+
timeout=aiohttp.ClientTimeout(total=300, connect=10),
94+
trust_env=True,
95+
)
96+
97+
try:
98+
upstream_response = await session.request(
99+
method=request.method,
100+
url=target_url,
101+
headers=headers,
102+
cookies=cookies,
103+
data=body or None,
104+
)
105+
106+
upstream_content_type = upstream_response.headers.get("content-type", "")
107+
filtered_headers = {
108+
key: value
109+
for key, value in upstream_response.headers.items()
110+
if key.lower() not in STRIPPED_RESPONSE_HEADERS
111+
}
112+
113+
# Stream binary responses directly
114+
if any(t in upstream_content_type for t in STREAMING_CONTENT_TYPES):
115+
async def cleanup():
116+
await upstream_response.release()
117+
await session.close()
118+
119+
return StreamingResponse(
120+
content=upstream_response.content.iter_any(),
121+
status_code=upstream_response.status,
122+
headers=filtered_headers,
123+
background=BackgroundTask(cleanup),
124+
)
125+
126+
# Buffer text/JSON responses
127+
response_body = await upstream_response.read()
128+
status_code = upstream_response.status
129+
await upstream_response.release()
130+
await session.close()
131+
132+
return Response(content=response_body, status_code=status_code, headers=filtered_headers)
133+
134+
except Exception as error:
135+
await session.close()
136+
log.exception("Terminal proxy error: %s", error)
137+
return JSONResponse({"error": f"Terminal proxy error: {error}"}, status_code=502)

backend/open_webui/utils/access_control.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,34 @@ def has_access(
153153
return False
154154

155155

156+
def has_connection_access(
157+
user: UserModel,
158+
connection: dict,
159+
user_group_ids: Optional[Set[str]] = None,
160+
) -> bool:
161+
"""
162+
Check if a user can access a server connection (tool server, terminal, etc.)
163+
based on ``config.access_grants`` within the connection dict.
164+
165+
- Admin with BYPASS_ADMIN_ACCESS_CONTROL → always allowed
166+
- Empty / missing access_grants → allowed for all users
167+
- Otherwise → delegates to ``has_access``
168+
"""
169+
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
170+
171+
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
172+
return True
173+
174+
if user_group_ids is None:
175+
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)}
176+
177+
access_grants = (connection.get("config") or {}).get("access_grants", [])
178+
if not access_grants:
179+
return True
180+
181+
return has_access(user.id, "read", access_grants, user_group_ids)
182+
183+
156184
def migrate_access_control(
157185
data: dict, ac_key: str = "access_control", grants_key: str = "access_grants"
158186
) -> None:

backend/open_webui/utils/middleware.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,9 @@
9999
from open_webui.utils.tools import (
100100
get_tools,
101101
get_updated_tool_function,
102-
has_tool_server_access,
102+
get_terminal_tools,
103103
)
104+
from open_webui.utils.access_control import has_connection_access
104105
from open_webui.utils.plugin import load_function_module_by_id
105106
from open_webui.utils.filter import (
106107
get_sorted_filter_ids,
@@ -2225,6 +2226,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
22252226
)
22262227

22272228
tool_ids = form_data.pop("tool_ids", None)
2229+
terminal_id = form_data.pop("terminal_id", None)
22282230
files = form_data.pop("files", None)
22292231

22302232
# Caller-provided OpenAI-style tools take precedence over server-side
@@ -2298,6 +2300,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
22982300
metadata = {
22992301
**metadata,
23002302
"tool_ids": tool_ids,
2303+
"terminal_id": terminal_id,
23012304
"files": files,
23022305
}
23032306
form_data["metadata"] = metadata
@@ -2342,7 +2345,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
23422345
continue
23432346

23442347
# Check access control for MCP server
2345-
if not has_tool_server_access(user, mcp_server_connection):
2348+
if not has_connection_access(user, mcp_server_connection):
23462349
log.warning(
23472350
f"Access denied to MCP server {server_id} for user {user.id}"
23482351
)
@@ -2479,6 +2482,17 @@ async def tool_function(**kwargs):
24792482
if mcp_tools_dict:
24802483
tools_dict = {**tools_dict, **mcp_tools_dict}
24812484

2485+
# Resolve terminal tools if terminal_id is set
2486+
if terminal_id:
2487+
terminal_tools = await get_terminal_tools(
2488+
request,
2489+
terminal_id,
2490+
user,
2491+
extra_params,
2492+
)
2493+
if terminal_tools:
2494+
tools_dict = {**tools_dict, **terminal_tools}
2495+
24822496
if direct_tool_servers:
24832497
for tool_server in direct_tool_servers:
24842498
tool_specs = tool_server.pop("specs", [])

0 commit comments

Comments
 (0)