termbridge 0.1.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- termbridge/__init__.py +1 -0
- termbridge/api.py +588 -0
- termbridge/di.py +63 -0
- termbridge/exceptions.py +68 -0
- termbridge/logging.py +56 -0
- termbridge/main.py +39 -0
- termbridge/middleware.py +110 -0
- termbridge/models.py +293 -0
- termbridge/ports.py +31 -0
- termbridge/process.py +76 -0
- termbridge/repositories.py +188 -0
- termbridge/runtime.py +24 -0
- termbridge/services.py +1248 -0
- termbridge/settings.py +46 -0
- termbridge/static/assets/index-C8taCHmV.css +1 -0
- termbridge/static/assets/index-CzXzkYtH.js +14 -0
- termbridge/static/index.html +13 -0
- termbridge-0.1.5.dist-info/METADATA +146 -0
- termbridge-0.1.5.dist-info/RECORD +22 -0
- termbridge-0.1.5.dist-info/WHEEL +4 -0
- termbridge-0.1.5.dist-info/entry_points.txt +2 -0
- termbridge-0.1.5.dist-info/licenses/LICENSE +21 -0
termbridge/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""TermBridge fastapi."""
|
termbridge/api.py
ADDED
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import base64
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import websockets
|
|
9
|
+
from fastapi import APIRouter, FastAPI, HTTPException, Query, Request, Response, WebSocket, WebSocketDisconnect, status
|
|
10
|
+
from fastapi.exceptions import RequestValidationError
|
|
11
|
+
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
|
12
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
13
|
+
from websockets.typing import Origin, Subprotocol
|
|
14
|
+
|
|
15
|
+
from termbridge.di import SessionServiceDep, TerminalServiceDep, WorkspaceBrowserServiceDep
|
|
16
|
+
from termbridge.exceptions import (
|
|
17
|
+
InvalidTerminalCommandError,
|
|
18
|
+
InvalidTerminalConfigError,
|
|
19
|
+
NoAvailablePortError,
|
|
20
|
+
SessionNotFoundError,
|
|
21
|
+
SessionRepositoryError,
|
|
22
|
+
ShortcutNotFoundError,
|
|
23
|
+
ShortcutRepositoryError,
|
|
24
|
+
UnknownRuntimeError,
|
|
25
|
+
WorkspaceBrowserError,
|
|
26
|
+
WorkspaceNotFoundError,
|
|
27
|
+
WorkspacePathNotDirectoryError,
|
|
28
|
+
WorkspacePathNotFoundError,
|
|
29
|
+
)
|
|
30
|
+
from termbridge.logging import configure_logging
|
|
31
|
+
from termbridge.middleware import RequestLoggingMiddleware
|
|
32
|
+
from termbridge.models import (
|
|
33
|
+
CloseAllSessionsResponse,
|
|
34
|
+
CreateSessionRequest,
|
|
35
|
+
CreateShortcutRequest,
|
|
36
|
+
EnvironmentListResponse,
|
|
37
|
+
LinuxCheckResponse,
|
|
38
|
+
RuntimeCheckRequest,
|
|
39
|
+
RuntimeCheckResponse,
|
|
40
|
+
SessionResponse,
|
|
41
|
+
SessionTreeResponse,
|
|
42
|
+
Shortcut,
|
|
43
|
+
ShortcutListResponse,
|
|
44
|
+
TerminalSettings,
|
|
45
|
+
UpdateShortcutRequest,
|
|
46
|
+
UpdateTerminalSettingsRequest,
|
|
47
|
+
WindowsCygwinCheckRequest,
|
|
48
|
+
WindowsCygwinCheckResponse,
|
|
49
|
+
WindowsCygwinSettings,
|
|
50
|
+
WindowsWslCheckResponse,
|
|
51
|
+
WindowsWslSettings,
|
|
52
|
+
WorkspaceRootsResponse,
|
|
53
|
+
WorkspaceTreeResponse,
|
|
54
|
+
)
|
|
55
|
+
from termbridge.settings import load_settings
|
|
56
|
+
|
|
57
|
+
router = APIRouter()
|
|
58
|
+
logger = logging.getLogger(__name__)
|
|
59
|
+
|
|
60
|
+
STATUS_ERROR_CODES = {
|
|
61
|
+
status.HTTP_400_BAD_REQUEST: "bad_request",
|
|
62
|
+
status.HTTP_404_NOT_FOUND: "not_found",
|
|
63
|
+
status.HTTP_409_CONFLICT: "conflict",
|
|
64
|
+
status.HTTP_422_UNPROCESSABLE_CONTENT: "validation_error",
|
|
65
|
+
status.HTTP_500_INTERNAL_SERVER_ERROR: "internal_error",
|
|
66
|
+
status.HTTP_503_SERVICE_UNAVAILABLE: "service_unavailable",
|
|
67
|
+
}
|
|
68
|
+
HOP_BY_HOP_HEADERS = {
|
|
69
|
+
"connection",
|
|
70
|
+
"keep-alive",
|
|
71
|
+
"proxy-authenticate",
|
|
72
|
+
"proxy-authorization",
|
|
73
|
+
"te",
|
|
74
|
+
"trailer",
|
|
75
|
+
"transfer-encoding",
|
|
76
|
+
"upgrade",
|
|
77
|
+
"content-encoding",
|
|
78
|
+
"content-length",
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _error_code(status_code: int) -> str:
|
|
83
|
+
return STATUS_ERROR_CODES.get(status_code, "request_failed")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _error_message(detail: Any, fallback: str) -> str:
|
|
87
|
+
if isinstance(detail, str) and detail:
|
|
88
|
+
return detail
|
|
89
|
+
return fallback
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _error_response(status_code: int, code: str, error: str) -> JSONResponse:
|
|
93
|
+
return JSONResponse(status_code=status_code, content={"code": code, "error": error})
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _basic_auth_header(username: str, password: str) -> str:
|
|
97
|
+
token = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
|
|
98
|
+
return f"Basic {token}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _target_headers(credential: Any | None) -> dict[str, str]:
|
|
102
|
+
if credential is None:
|
|
103
|
+
return {}
|
|
104
|
+
return {"Authorization": _basic_auth_header(credential.username, credential.password)}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _proxy_headers(headers: httpx.Headers) -> dict[str, str]:
|
|
108
|
+
return {key: value for key, value in headers.items() if key.lower() not in HOP_BY_HOP_HEADERS}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def http_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
|
112
|
+
if not isinstance(exc, StarletteHTTPException):
|
|
113
|
+
return await unhandled_exception_handler(request, exc)
|
|
114
|
+
code = _error_code(exc.status_code)
|
|
115
|
+
error = _error_message(exc.detail, "Request failed")
|
|
116
|
+
return _error_response(exc.status_code, code, error)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def validation_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
|
120
|
+
if not isinstance(exc, RequestValidationError):
|
|
121
|
+
return await unhandled_exception_handler(request, exc)
|
|
122
|
+
return _error_response(status.HTTP_422_UNPROCESSABLE_CONTENT, "validation_error", "Validation error")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
|
126
|
+
logger.exception("Unhandled request error path=%s", request.url.path)
|
|
127
|
+
return _error_response(status.HTTP_500_INTERNAL_SERVER_ERROR, "internal_error", "Internal server error")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@router.get("/health")
|
|
131
|
+
def health() -> dict[str, str]:
|
|
132
|
+
return {"status": "ok"}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@router.get("/api/workspaces/roots", response_model=WorkspaceRootsResponse)
|
|
136
|
+
def list_workspace_roots(service: WorkspaceBrowserServiceDep) -> WorkspaceRootsResponse:
|
|
137
|
+
try:
|
|
138
|
+
return service.list_roots()
|
|
139
|
+
except WorkspaceBrowserError as exc:
|
|
140
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@router.get("/api/workspaces/tree", response_model=WorkspaceTreeResponse)
|
|
144
|
+
def list_workspace_tree(
|
|
145
|
+
service: WorkspaceBrowserServiceDep,
|
|
146
|
+
path: str = Query(min_length=1),
|
|
147
|
+
show_hidden: bool = False,
|
|
148
|
+
) -> WorkspaceTreeResponse:
|
|
149
|
+
try:
|
|
150
|
+
return service.list_children(Path(path), show_hidden=show_hidden)
|
|
151
|
+
except WorkspacePathNotFoundError as exc:
|
|
152
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
|
153
|
+
except WorkspacePathNotDirectoryError as exc:
|
|
154
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
|
155
|
+
except WorkspaceBrowserError as exc:
|
|
156
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@router.get("/api/shortcuts", response_model=ShortcutListResponse)
|
|
160
|
+
def list_shortcuts(service: TerminalServiceDep) -> ShortcutListResponse:
|
|
161
|
+
try:
|
|
162
|
+
return service.list_shortcuts()
|
|
163
|
+
except ShortcutRepositoryError as exc:
|
|
164
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@router.post("/api/shortcuts", response_model=Shortcut, status_code=status.HTTP_201_CREATED)
|
|
168
|
+
def create_shortcut(request: CreateShortcutRequest, service: TerminalServiceDep) -> Shortcut:
|
|
169
|
+
try:
|
|
170
|
+
return service.create_shortcut(request)
|
|
171
|
+
except InvalidTerminalConfigError as exc:
|
|
172
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
|
173
|
+
except ShortcutRepositoryError as exc:
|
|
174
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@router.put("/api/shortcuts/{shortcut_id}", response_model=Shortcut)
|
|
178
|
+
def update_shortcut(shortcut_id: str, request: UpdateShortcutRequest, service: TerminalServiceDep) -> Shortcut:
|
|
179
|
+
try:
|
|
180
|
+
return service.update_shortcut(shortcut_id, request)
|
|
181
|
+
except ShortcutNotFoundError as exc:
|
|
182
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Shortcut not found") from exc
|
|
183
|
+
except InvalidTerminalConfigError as exc:
|
|
184
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
|
185
|
+
except ShortcutRepositoryError as exc:
|
|
186
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@router.delete("/api/shortcuts/{shortcut_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
190
|
+
def delete_shortcut(shortcut_id: str, service: TerminalServiceDep, session_service: SessionServiceDep) -> Response:
|
|
191
|
+
try:
|
|
192
|
+
if any(session.shortcut_id == shortcut_id for session in session_service.list_sessions()):
|
|
193
|
+
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Shortcut is in use")
|
|
194
|
+
service.delete_shortcut(shortcut_id)
|
|
195
|
+
except HTTPException:
|
|
196
|
+
raise
|
|
197
|
+
except ShortcutNotFoundError as exc:
|
|
198
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Shortcut not found") from exc
|
|
199
|
+
except (SessionRepositoryError, ShortcutRepositoryError) as exc:
|
|
200
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
|
|
201
|
+
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@router.get("/api/terminal-settings", response_model=TerminalSettings)
|
|
205
|
+
def get_terminal_settings(service: TerminalServiceDep) -> TerminalSettings:
|
|
206
|
+
try:
|
|
207
|
+
return service.get_settings()
|
|
208
|
+
except ShortcutRepositoryError as exc:
|
|
209
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@router.put("/api/terminal-settings", response_model=TerminalSettings)
|
|
213
|
+
def update_terminal_settings(request: UpdateTerminalSettingsRequest, service: TerminalServiceDep) -> TerminalSettings:
|
|
214
|
+
try:
|
|
215
|
+
return service.update_settings(request)
|
|
216
|
+
except InvalidTerminalConfigError as exc:
|
|
217
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
|
218
|
+
except ShortcutRepositoryError as exc:
|
|
219
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@router.post("/api/environment/ttyd/check", response_model=RuntimeCheckResponse)
|
|
223
|
+
def check_ttyd(request: RuntimeCheckRequest, service: TerminalServiceDep) -> RuntimeCheckResponse:
|
|
224
|
+
return service.check_ttyd(request.path)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@router.get("/api/environments", response_model=EnvironmentListResponse)
|
|
228
|
+
def list_environments(service: TerminalServiceDep) -> EnvironmentListResponse:
|
|
229
|
+
try:
|
|
230
|
+
return service.list_environments()
|
|
231
|
+
except ShortcutRepositoryError as exc:
|
|
232
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@router.get("/api/environment/windows-cygwin/settings", response_model=WindowsCygwinSettings)
|
|
236
|
+
def get_windows_cygwin_settings(service: TerminalServiceDep) -> WindowsCygwinSettings:
|
|
237
|
+
try:
|
|
238
|
+
return service.get_windows_cygwin_settings()
|
|
239
|
+
except ShortcutRepositoryError as exc:
|
|
240
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@router.put("/api/environment/windows-cygwin/settings", response_model=WindowsCygwinSettings)
|
|
244
|
+
def update_windows_cygwin_settings(
|
|
245
|
+
request: WindowsCygwinSettings, service: TerminalServiceDep
|
|
246
|
+
) -> WindowsCygwinSettings:
|
|
247
|
+
try:
|
|
248
|
+
return service.update_windows_cygwin_settings(request)
|
|
249
|
+
except InvalidTerminalConfigError as exc:
|
|
250
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
|
251
|
+
except ShortcutRepositoryError as exc:
|
|
252
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@router.post("/api/environment/windows-cygwin/check", response_model=WindowsCygwinCheckResponse)
|
|
256
|
+
def check_windows_cygwin(
|
|
257
|
+
request: WindowsCygwinCheckRequest, service: TerminalServiceDep
|
|
258
|
+
) -> WindowsCygwinCheckResponse:
|
|
259
|
+
return service.check_windows_cygwin(request.bash_path)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@router.get("/api/environment/windows-wsl/settings", response_model=WindowsWslSettings)
|
|
263
|
+
def get_windows_wsl_settings(service: TerminalServiceDep) -> WindowsWslSettings:
|
|
264
|
+
try:
|
|
265
|
+
return service.get_windows_wsl_settings()
|
|
266
|
+
except ShortcutRepositoryError as exc:
|
|
267
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@router.put("/api/environment/windows-wsl/settings", response_model=WindowsWslSettings)
|
|
271
|
+
def update_windows_wsl_settings(request: WindowsWslSettings, service: TerminalServiceDep) -> WindowsWslSettings:
|
|
272
|
+
try:
|
|
273
|
+
return service.update_windows_wsl_settings(request)
|
|
274
|
+
except ShortcutRepositoryError as exc:
|
|
275
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@router.post("/api/environment/windows-wsl/check", response_model=WindowsWslCheckResponse)
|
|
279
|
+
def check_windows_wsl(service: TerminalServiceDep) -> WindowsWslCheckResponse:
|
|
280
|
+
return service.check_windows_wsl()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@router.post("/api/environment/linux/check", response_model=LinuxCheckResponse)
|
|
284
|
+
def check_linux(service: TerminalServiceDep) -> LinuxCheckResponse:
|
|
285
|
+
return service.check_linux()
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@router.post("/api/sessions", response_model=SessionResponse, status_code=status.HTTP_201_CREATED)
|
|
289
|
+
def create_session(request: CreateSessionRequest, service: SessionServiceDep) -> SessionResponse:
|
|
290
|
+
try:
|
|
291
|
+
return service.create(request)
|
|
292
|
+
except (
|
|
293
|
+
UnknownRuntimeError,
|
|
294
|
+
InvalidTerminalCommandError,
|
|
295
|
+
InvalidTerminalConfigError,
|
|
296
|
+
WorkspaceNotFoundError,
|
|
297
|
+
) as exc:
|
|
298
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
|
299
|
+
except ShortcutNotFoundError as exc:
|
|
300
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Shortcut not found") from exc
|
|
301
|
+
except NoAvailablePortError as exc:
|
|
302
|
+
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc
|
|
303
|
+
except SessionRepositoryError as exc:
|
|
304
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@router.get("/api/sessions", response_model=list[SessionResponse])
|
|
308
|
+
def list_sessions(service: SessionServiceDep) -> list[SessionResponse]:
|
|
309
|
+
try:
|
|
310
|
+
return service.list_sessions()
|
|
311
|
+
except SessionRepositoryError as exc:
|
|
312
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@router.get("/api/session-tree", response_model=SessionTreeResponse)
|
|
316
|
+
def list_session_tree(service: SessionServiceDep) -> SessionTreeResponse:
|
|
317
|
+
try:
|
|
318
|
+
return service.list_tree()
|
|
319
|
+
except SessionRepositoryError as exc:
|
|
320
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@router.post("/api/sessions/close-all", response_model=CloseAllSessionsResponse)
|
|
324
|
+
def close_all_sessions(service: SessionServiceDep) -> CloseAllSessionsResponse:
|
|
325
|
+
try:
|
|
326
|
+
return service.close_all()
|
|
327
|
+
except SessionRepositoryError as exc:
|
|
328
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@router.delete("/api/session-workspaces/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
332
|
+
def delete_session_workspace(workspace_id: str, service: SessionServiceDep) -> Response:
|
|
333
|
+
try:
|
|
334
|
+
service.delete_workspace(workspace_id)
|
|
335
|
+
except SessionNotFoundError as exc:
|
|
336
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session workspace not found") from exc
|
|
337
|
+
except SessionRepositoryError as exc:
|
|
338
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
|
|
339
|
+
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@router.get("/api/sessions/{session_id}", response_model=SessionResponse)
|
|
343
|
+
def get_session(session_id: str, service: SessionServiceDep) -> SessionResponse:
|
|
344
|
+
try:
|
|
345
|
+
return service.get(session_id)
|
|
346
|
+
except SessionNotFoundError as exc:
|
|
347
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found") from exc
|
|
348
|
+
except SessionRepositoryError as exc:
|
|
349
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@router.post("/api/sessions/{session_id}/start", response_model=SessionResponse)
|
|
353
|
+
def start_session(session_id: str, service: SessionServiceDep) -> SessionResponse:
|
|
354
|
+
try:
|
|
355
|
+
return service.start(session_id)
|
|
356
|
+
except SessionNotFoundError as exc:
|
|
357
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found") from exc
|
|
358
|
+
except NoAvailablePortError as exc:
|
|
359
|
+
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc
|
|
360
|
+
except InvalidTerminalConfigError as exc:
|
|
361
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
|
362
|
+
except SessionRepositoryError as exc:
|
|
363
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@router.post("/api/sessions/{session_id}/stop", response_model=SessionResponse)
|
|
367
|
+
def stop_session(session_id: str, service: SessionServiceDep) -> SessionResponse:
|
|
368
|
+
try:
|
|
369
|
+
return service.stop(session_id)
|
|
370
|
+
except SessionNotFoundError as exc:
|
|
371
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found") from exc
|
|
372
|
+
except SessionRepositoryError as exc:
|
|
373
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@router.delete("/api/sessions/{session_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
377
|
+
def delete_session(session_id: str, service: SessionServiceDep) -> Response:
|
|
378
|
+
try:
|
|
379
|
+
service.delete(session_id)
|
|
380
|
+
except SessionNotFoundError as exc:
|
|
381
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found") from exc
|
|
382
|
+
except SessionRepositoryError as exc:
|
|
383
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
|
|
384
|
+
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
@router.get("/terminal/{session_id}", include_in_schema=False)
|
|
388
|
+
def redirect_terminal_root(session_id: str) -> RedirectResponse:
|
|
389
|
+
return RedirectResponse(url=f"/terminal/{session_id}/", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@router.get("/terminal/{session_id}/{path:path}", include_in_schema=False)
|
|
393
|
+
async def proxy_terminal_http(session_id: str, path: str, request: Request, service: SessionServiceDep) -> Response:
|
|
394
|
+
try:
|
|
395
|
+
target = service.terminal_proxy_target(session_id)
|
|
396
|
+
except SessionNotFoundError as exc:
|
|
397
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found") from exc
|
|
398
|
+
except InvalidTerminalConfigError as exc:
|
|
399
|
+
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
|
400
|
+
target_path = path or ""
|
|
401
|
+
target_url = f"{target.base_url.rstrip('/')}/{target_path}"
|
|
402
|
+
if request.url.query:
|
|
403
|
+
target_url = f"{target_url}?{request.url.query}"
|
|
404
|
+
headers = _target_headers(target.credential)
|
|
405
|
+
async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client:
|
|
406
|
+
try:
|
|
407
|
+
upstream = await client.request(request.method, target_url, headers=headers, content=await request.body())
|
|
408
|
+
except httpx.HTTPError as exc:
|
|
409
|
+
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Terminal proxy request failed") from exc
|
|
410
|
+
return Response(content=upstream.content, status_code=upstream.status_code, headers=_proxy_headers(upstream.headers))
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
@router.websocket("/terminal/{session_id}/ws")
|
|
414
|
+
async def proxy_terminal_websocket(session_id: str, websocket: WebSocket, service: SessionServiceDep) -> None:
|
|
415
|
+
try:
|
|
416
|
+
target = service.terminal_proxy_target(session_id)
|
|
417
|
+
except (SessionNotFoundError, InvalidTerminalConfigError):
|
|
418
|
+
await websocket.close(code=1008)
|
|
419
|
+
return
|
|
420
|
+
target_url = target.base_url.replace("http://", "ws://", 1).replace("https://", "wss://", 1).rstrip("/")
|
|
421
|
+
query = websocket.url.query
|
|
422
|
+
if query:
|
|
423
|
+
target_url = f"{target_url}/ws?{query}"
|
|
424
|
+
else:
|
|
425
|
+
target_url = f"{target_url}/ws"
|
|
426
|
+
requested_subprotocols = [
|
|
427
|
+
item.strip()
|
|
428
|
+
for item in websocket.headers.get("sec-websocket-protocol", "").split(",")
|
|
429
|
+
if item.strip()
|
|
430
|
+
]
|
|
431
|
+
subprotocol = Subprotocol("tty") if "tty" in requested_subprotocols else None
|
|
432
|
+
accepted = False
|
|
433
|
+
try:
|
|
434
|
+
async with websockets.connect(
|
|
435
|
+
target_url,
|
|
436
|
+
additional_headers=_target_headers(target.credential),
|
|
437
|
+
origin=Origin(target.base_url),
|
|
438
|
+
subprotocols=[subprotocol] if subprotocol else None,
|
|
439
|
+
proxy=None,
|
|
440
|
+
) as upstream:
|
|
441
|
+
await websocket.accept(subprotocol=subprotocol)
|
|
442
|
+
accepted = True
|
|
443
|
+
logger.info(
|
|
444
|
+
"Terminal websocket proxy connected session_id=%s target=%s subprotocol=%s",
|
|
445
|
+
session_id,
|
|
446
|
+
target_url,
|
|
447
|
+
subprotocol,
|
|
448
|
+
)
|
|
449
|
+
client_message_count = 0
|
|
450
|
+
upstream_message_count = 0
|
|
451
|
+
|
|
452
|
+
async def client_to_upstream() -> None:
|
|
453
|
+
nonlocal client_message_count
|
|
454
|
+
while True:
|
|
455
|
+
message = await websocket.receive()
|
|
456
|
+
if message["type"] == "websocket.disconnect":
|
|
457
|
+
logger.info(
|
|
458
|
+
"Terminal websocket client disconnected session_id=%s code=%s client_messages=%s upstream_messages=%s",
|
|
459
|
+
session_id,
|
|
460
|
+
message.get("code"),
|
|
461
|
+
client_message_count,
|
|
462
|
+
upstream_message_count,
|
|
463
|
+
)
|
|
464
|
+
await upstream.close()
|
|
465
|
+
return
|
|
466
|
+
if message.get("bytes") is not None:
|
|
467
|
+
client_message_count += 1
|
|
468
|
+
logger.debug(
|
|
469
|
+
"Terminal websocket client bytes session_id=%s length=%s count=%s",
|
|
470
|
+
session_id,
|
|
471
|
+
len(message["bytes"]),
|
|
472
|
+
client_message_count,
|
|
473
|
+
)
|
|
474
|
+
await upstream.send(message["bytes"])
|
|
475
|
+
elif message.get("text") is not None:
|
|
476
|
+
client_message_count += 1
|
|
477
|
+
logger.debug(
|
|
478
|
+
"Terminal websocket client text session_id=%s length=%s count=%s",
|
|
479
|
+
session_id,
|
|
480
|
+
len(message["text"]),
|
|
481
|
+
client_message_count,
|
|
482
|
+
)
|
|
483
|
+
await upstream.send(message["text"])
|
|
484
|
+
|
|
485
|
+
async def upstream_to_client() -> None:
|
|
486
|
+
nonlocal upstream_message_count
|
|
487
|
+
try:
|
|
488
|
+
async for message in upstream:
|
|
489
|
+
upstream_message_count += 1
|
|
490
|
+
if isinstance(message, bytes):
|
|
491
|
+
logger.debug(
|
|
492
|
+
"Terminal websocket upstream bytes session_id=%s length=%s count=%s",
|
|
493
|
+
session_id,
|
|
494
|
+
len(message),
|
|
495
|
+
upstream_message_count,
|
|
496
|
+
)
|
|
497
|
+
await websocket.send_bytes(message)
|
|
498
|
+
else:
|
|
499
|
+
logger.debug(
|
|
500
|
+
"Terminal websocket upstream text session_id=%s length=%s count=%s",
|
|
501
|
+
session_id,
|
|
502
|
+
len(message),
|
|
503
|
+
upstream_message_count,
|
|
504
|
+
)
|
|
505
|
+
await websocket.send_text(message)
|
|
506
|
+
except websockets.ConnectionClosed as exc:
|
|
507
|
+
logger.info(
|
|
508
|
+
"Terminal websocket upstream disconnected session_id=%s code=%s reason=%s detail=%s client_messages=%s upstream_messages=%s",
|
|
509
|
+
session_id,
|
|
510
|
+
upstream.close_code,
|
|
511
|
+
upstream.close_reason,
|
|
512
|
+
exc,
|
|
513
|
+
client_message_count,
|
|
514
|
+
upstream_message_count,
|
|
515
|
+
)
|
|
516
|
+
await websocket.close(code=1000)
|
|
517
|
+
return
|
|
518
|
+
logger.info(
|
|
519
|
+
"Terminal websocket upstream closed session_id=%s code=%s reason=%s client_messages=%s upstream_messages=%s",
|
|
520
|
+
session_id,
|
|
521
|
+
upstream.close_code,
|
|
522
|
+
upstream.close_reason,
|
|
523
|
+
client_message_count,
|
|
524
|
+
upstream_message_count,
|
|
525
|
+
)
|
|
526
|
+
await websocket.close(code=1000)
|
|
527
|
+
|
|
528
|
+
done, pending = await asyncio.wait(
|
|
529
|
+
{asyncio.create_task(client_to_upstream()), asyncio.create_task(upstream_to_client())},
|
|
530
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
531
|
+
)
|
|
532
|
+
for task in pending:
|
|
533
|
+
task.cancel()
|
|
534
|
+
for task in done:
|
|
535
|
+
task.result()
|
|
536
|
+
logger.info(
|
|
537
|
+
"Terminal websocket proxy finished session_id=%s client_messages=%s upstream_messages=%s",
|
|
538
|
+
session_id,
|
|
539
|
+
client_message_count,
|
|
540
|
+
upstream_message_count,
|
|
541
|
+
)
|
|
542
|
+
except WebSocketDisconnect:
|
|
543
|
+
return
|
|
544
|
+
except Exception:
|
|
545
|
+
logger.exception("Terminal websocket proxy failed session_id=%s", session_id)
|
|
546
|
+
if accepted:
|
|
547
|
+
await websocket.close(code=1011)
|
|
548
|
+
else:
|
|
549
|
+
await websocket.close(code=1008)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _default_web_dir() -> Path:
|
|
553
|
+
return Path(__file__).parent / "static"
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _resolve_web_dir(web_dir: Path | None) -> Path | None:
|
|
557
|
+
static_dir = web_dir or _default_web_dir()
|
|
558
|
+
index_file = static_dir / "index.html"
|
|
559
|
+
if index_file.is_file():
|
|
560
|
+
return static_dir
|
|
561
|
+
return None
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def create_app(*, serve_web: bool = True, web_dir: Path | None = None) -> FastAPI:
|
|
565
|
+
settings = load_settings()
|
|
566
|
+
configure_logging(settings)
|
|
567
|
+
|
|
568
|
+
app = FastAPI(title="TermBridge")
|
|
569
|
+
app.add_exception_handler(StarletteHTTPException, http_exception_handler)
|
|
570
|
+
app.add_exception_handler(RequestValidationError, validation_exception_handler)
|
|
571
|
+
app.add_exception_handler(Exception, unhandled_exception_handler)
|
|
572
|
+
app.add_middleware(RequestLoggingMiddleware)
|
|
573
|
+
app.include_router(router)
|
|
574
|
+
|
|
575
|
+
static_dir = _resolve_web_dir(web_dir) if serve_web else None
|
|
576
|
+
if static_dir is not None:
|
|
577
|
+
|
|
578
|
+
@app.get("/{path:path}", include_in_schema=False)
|
|
579
|
+
def serve_web_app(path: str) -> FileResponse:
|
|
580
|
+
if path == "health" or path.startswith("api/"):
|
|
581
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
|
582
|
+
|
|
583
|
+
requested_file = (static_dir / path).resolve()
|
|
584
|
+
if requested_file.is_file() and requested_file.is_relative_to(static_dir.resolve()):
|
|
585
|
+
return FileResponse(requested_file)
|
|
586
|
+
return FileResponse(static_dir / "index.html")
|
|
587
|
+
|
|
588
|
+
return app
|
termbridge/di.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from functools import lru_cache
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
|
|
4
|
+
from fastapi import Depends
|
|
5
|
+
|
|
6
|
+
from termbridge.ports import PortAllocator
|
|
7
|
+
from termbridge.process import ProcessAdapter, TtydProcessAdapter
|
|
8
|
+
from termbridge.repositories import FileSessionRepository, FileTerminalRepository
|
|
9
|
+
from termbridge.runtime import RuntimeRegistry
|
|
10
|
+
from termbridge.services import SessionService, TerminalService, WorkspaceBrowserService
|
|
11
|
+
from termbridge.settings import Settings, load_settings
|
|
12
|
+
|
|
13
|
+
SettingsDep = Annotated[Settings, Depends(load_settings)]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@lru_cache
|
|
17
|
+
def get_process_adapter() -> ProcessAdapter:
|
|
18
|
+
return TtydProcessAdapter()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@lru_cache
|
|
22
|
+
def get_runtime_registry() -> RuntimeRegistry:
|
|
23
|
+
return RuntimeRegistry()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_session_repository(settings: SettingsDep) -> FileSessionRepository:
|
|
27
|
+
return FileSessionRepository(settings.sessions_file)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_terminal_repository(settings: SettingsDep) -> FileTerminalRepository:
|
|
31
|
+
return FileTerminalRepository(settings.terminals_file)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_terminal_service(
|
|
35
|
+
settings: SettingsDep,
|
|
36
|
+
repository: Annotated[FileTerminalRepository, Depends(get_terminal_repository)],
|
|
37
|
+
) -> TerminalService:
|
|
38
|
+
return TerminalService(repository, settings=settings)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_port_allocator(settings: SettingsDep) -> PortAllocator:
|
|
42
|
+
return PortAllocator(settings.host, settings.port_start, settings.port_end)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_session_service(
|
|
46
|
+
settings: SettingsDep,
|
|
47
|
+
repository: Annotated[FileSessionRepository, Depends(get_session_repository)],
|
|
48
|
+
runtime_registry: Annotated[RuntimeRegistry, Depends(get_runtime_registry)],
|
|
49
|
+
port_allocator: Annotated[PortAllocator, Depends(get_port_allocator)],
|
|
50
|
+
process_adapter: Annotated[ProcessAdapter, Depends(get_process_adapter)],
|
|
51
|
+
terminal_service: Annotated[TerminalService, Depends(get_terminal_service)],
|
|
52
|
+
) -> SessionService:
|
|
53
|
+
return SessionService(settings, repository, runtime_registry, port_allocator, process_adapter, terminal_service)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@lru_cache
|
|
57
|
+
def get_workspace_browser_service() -> WorkspaceBrowserService:
|
|
58
|
+
return WorkspaceBrowserService()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
SessionServiceDep = Annotated[SessionService, Depends(get_session_service)]
|
|
62
|
+
TerminalServiceDep = Annotated[TerminalService, Depends(get_terminal_service)]
|
|
63
|
+
WorkspaceBrowserServiceDep = Annotated[WorkspaceBrowserService, Depends(get_workspace_browser_service)]
|