terminaide 0.0.1__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.
- terminaide/__init__.py +148 -0
- terminaide/core/manager.py +272 -0
- terminaide/core/proxy.py +281 -0
- terminaide/core/settings.py +159 -0
- terminaide/exceptions.py +130 -0
- terminaide/installer.py +231 -0
- terminaide/serve.py +259 -0
- terminaide/static/favicon.ico +0 -0
- terminaide/templates/terminal.html +132 -0
- terminaide-0.0.1.dist-info/METADATA +53 -0
- terminaide-0.0.1.dist-info/RECORD +12 -0
- terminaide-0.0.1.dist-info/WHEEL +4 -0
terminaide/__init__.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# terminaide/__init__.py
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
terminaide: Serve Python CLI applications in the browser using ttyd.
|
|
5
|
+
|
|
6
|
+
This package provides tools to easily serve Python CLI applications through
|
|
7
|
+
a browser-based terminal using ttyd. It handles binary installation and
|
|
8
|
+
management automatically across supported platforms.
|
|
9
|
+
|
|
10
|
+
Supported Platforms:
|
|
11
|
+
- Linux x86_64 (Docker containers)
|
|
12
|
+
- macOS ARM64 (Apple Silicon)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from fastapi import FastAPI
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Optional, Dict, Any, Union
|
|
19
|
+
|
|
20
|
+
# Configure package-level logging
|
|
21
|
+
logging.getLogger("terminaide").addHandler(logging.NullHandler())
|
|
22
|
+
|
|
23
|
+
# Core functionality
|
|
24
|
+
from .core.settings import TTYDConfig
|
|
25
|
+
from .serve import serve_tty, _configure_app
|
|
26
|
+
|
|
27
|
+
# Installation management
|
|
28
|
+
from .installer import setup_ttyd, get_platform_info
|
|
29
|
+
|
|
30
|
+
# Expose all exceptions
|
|
31
|
+
from .exceptions import (
|
|
32
|
+
terminaideError,
|
|
33
|
+
BinaryError,
|
|
34
|
+
InstallationError,
|
|
35
|
+
PlatformNotSupportedError,
|
|
36
|
+
DependencyError,
|
|
37
|
+
DownloadError,
|
|
38
|
+
TTYDStartupError,
|
|
39
|
+
TTYDProcessError,
|
|
40
|
+
ClientScriptError,
|
|
41
|
+
TemplateError,
|
|
42
|
+
ProxyError,
|
|
43
|
+
ConfigurationError
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
__version__ = "0.2.0" # Updated version number
|
|
47
|
+
__all__ = [
|
|
48
|
+
# Main functionality
|
|
49
|
+
"serve_tty",
|
|
50
|
+
"TTYDConfig",
|
|
51
|
+
|
|
52
|
+
# Binary management
|
|
53
|
+
"setup_ttyd",
|
|
54
|
+
"get_platform_info",
|
|
55
|
+
|
|
56
|
+
# Exceptions
|
|
57
|
+
"terminaideError",
|
|
58
|
+
"BinaryError",
|
|
59
|
+
"InstallationError",
|
|
60
|
+
"PlatformNotSupportedError",
|
|
61
|
+
"DependencyError",
|
|
62
|
+
"DownloadError",
|
|
63
|
+
"TTYDStartupError",
|
|
64
|
+
"TTYDProcessError",
|
|
65
|
+
"ClientScriptError",
|
|
66
|
+
"TemplateError",
|
|
67
|
+
"ProxyError",
|
|
68
|
+
"ConfigurationError"
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
# Type aliases for better documentation
|
|
72
|
+
ThemeConfig = Dict[str, str]
|
|
73
|
+
TTYDOptions = Dict[str, Any]
|
|
74
|
+
|
|
75
|
+
def serve_tty(
|
|
76
|
+
app: FastAPI,
|
|
77
|
+
client_script: Union[str, Path],
|
|
78
|
+
*,
|
|
79
|
+
mount_path: str = "/tty",
|
|
80
|
+
port: int = 7681,
|
|
81
|
+
theme: Optional[ThemeConfig] = None,
|
|
82
|
+
ttyd_options: Optional[TTYDOptions] = None,
|
|
83
|
+
template_override: Optional[Union[str, Path]] = None,
|
|
84
|
+
debug: bool = False
|
|
85
|
+
) -> None:
|
|
86
|
+
"""
|
|
87
|
+
Configure FastAPI application to serve a Python script through a browser-based terminal.
|
|
88
|
+
|
|
89
|
+
This function automatically handles ttyd binary installation and setup for the
|
|
90
|
+
current platform. Supported platforms are Linux x86_64 (for Docker) and
|
|
91
|
+
macOS ARM64 (Apple Silicon).
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
app: FastAPI application instance
|
|
95
|
+
client_script: Path to Python script to run in terminal
|
|
96
|
+
mount_path: URL path to mount terminal (default: "/tty")
|
|
97
|
+
port: Port for ttyd process (default: 7681)
|
|
98
|
+
theme: Terminal theme configuration (default: {"background": "black"})
|
|
99
|
+
ttyd_options: Additional ttyd process options
|
|
100
|
+
template_override: Custom HTML template path
|
|
101
|
+
debug: Enable development mode with auto-reload (default: False)
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
InstallationError: If ttyd binary installation fails
|
|
105
|
+
PlatformNotSupportedError: If running on an unsupported platform
|
|
106
|
+
DependencyError: If required system libraries are missing
|
|
107
|
+
TTYDStartupError: If ttyd fails to start
|
|
108
|
+
ClientScriptError: If client script cannot be found or executed
|
|
109
|
+
ConfigurationError: If provided configuration values are invalid
|
|
110
|
+
|
|
111
|
+
Example:
|
|
112
|
+
```python
|
|
113
|
+
from fastapi import FastAPI
|
|
114
|
+
from terminaide import serve_tty
|
|
115
|
+
|
|
116
|
+
app = FastAPI()
|
|
117
|
+
|
|
118
|
+
# Basic usage
|
|
119
|
+
serve_tty(app, "client.py")
|
|
120
|
+
|
|
121
|
+
# Custom configuration
|
|
122
|
+
serve_tty(
|
|
123
|
+
app,
|
|
124
|
+
"client.py",
|
|
125
|
+
mount_path="/terminal",
|
|
126
|
+
theme={"background": "#1a1a1a"},
|
|
127
|
+
debug=True
|
|
128
|
+
)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Notes:
|
|
132
|
+
- For Docker deployments, the package automatically handles ttyd installation
|
|
133
|
+
- Binary installation happens on first use and is cached for subsequent runs
|
|
134
|
+
- In Docker, required system libraries (libwebsockets, json-c) must be present
|
|
135
|
+
"""
|
|
136
|
+
# Create configuration object
|
|
137
|
+
config = TTYDConfig(
|
|
138
|
+
client_script=client_script,
|
|
139
|
+
mount_path=mount_path,
|
|
140
|
+
port=port,
|
|
141
|
+
theme=theme or {"background": "black"},
|
|
142
|
+
ttyd_options=ttyd_options or {},
|
|
143
|
+
template_override=template_override,
|
|
144
|
+
debug=debug
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Configure the application with our ttyd setup
|
|
148
|
+
_configure_app(app, config)
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# terminaide/core/manager.py
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
TTYd process management and lifecycle control.
|
|
5
|
+
|
|
6
|
+
This module is responsible for starting, monitoring, and stopping the ttyd process.
|
|
7
|
+
It ensures proper process cleanup and provides health monitoring capabilities.
|
|
8
|
+
The manager adapts its behavior based on whether we're using root or non-root
|
|
9
|
+
mounting configurations.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import signal
|
|
14
|
+
import logging
|
|
15
|
+
import subprocess
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from typing import Optional, List, Dict, Any
|
|
18
|
+
from contextlib import asynccontextmanager
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from fastapi import FastAPI
|
|
22
|
+
|
|
23
|
+
from ..exceptions import TTYDStartupError, TTYDProcessError
|
|
24
|
+
from ..installer import setup_ttyd
|
|
25
|
+
from .settings import TTYDConfig
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("terminaide")
|
|
28
|
+
|
|
29
|
+
class TTYDManager:
|
|
30
|
+
"""
|
|
31
|
+
Manages the lifecycle of the ttyd process.
|
|
32
|
+
|
|
33
|
+
This class handles all aspects of the ttyd process, including:
|
|
34
|
+
- Process startup and shutdown
|
|
35
|
+
- Health monitoring
|
|
36
|
+
- Resource cleanup
|
|
37
|
+
- Signal handling
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, config: TTYDConfig):
|
|
41
|
+
"""
|
|
42
|
+
Initialize the TTYDManager.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
config: TTYDConfig instance with process configuration
|
|
46
|
+
"""
|
|
47
|
+
self.config = config
|
|
48
|
+
self.process: Optional[subprocess.Popen] = None
|
|
49
|
+
self._start_time: Optional[datetime] = None
|
|
50
|
+
self._ttyd_path: Optional[Path] = None
|
|
51
|
+
self._setup_ttyd()
|
|
52
|
+
|
|
53
|
+
def _setup_ttyd(self) -> None:
|
|
54
|
+
"""
|
|
55
|
+
Set up ttyd binary and verify it's ready to use.
|
|
56
|
+
|
|
57
|
+
This method handles the installation and verification of the ttyd binary,
|
|
58
|
+
using our installer module to manage platform-specific binaries.
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
self._ttyd_path = setup_ttyd()
|
|
62
|
+
logger.info(f"Using ttyd binary at: {self._ttyd_path}")
|
|
63
|
+
except Exception as e:
|
|
64
|
+
logger.error(f"Failed to set up ttyd: {e}")
|
|
65
|
+
raise TTYDStartupError(f"Failed to set up ttyd: {e}")
|
|
66
|
+
|
|
67
|
+
def _build_command(self) -> List[str]:
|
|
68
|
+
"""
|
|
69
|
+
Build the ttyd command with all necessary arguments.
|
|
70
|
+
|
|
71
|
+
This method constructs the command line arguments for ttyd based on
|
|
72
|
+
the current configuration, taking into account both root and non-root
|
|
73
|
+
mounting scenarios.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
List of command arguments for ttyd process
|
|
77
|
+
"""
|
|
78
|
+
if not self._ttyd_path:
|
|
79
|
+
raise TTYDStartupError("ttyd binary path not set")
|
|
80
|
+
|
|
81
|
+
cmd = [str(self._ttyd_path)]
|
|
82
|
+
|
|
83
|
+
# Basic configuration
|
|
84
|
+
cmd.extend(['-p', str(self.config.port)])
|
|
85
|
+
cmd.extend(['-i', self.config.ttyd_options.interface])
|
|
86
|
+
|
|
87
|
+
# Security settings
|
|
88
|
+
if not self.config.ttyd_options.check_origin:
|
|
89
|
+
cmd.append('--no-check-origin')
|
|
90
|
+
|
|
91
|
+
if self.config.ttyd_options.credential_required:
|
|
92
|
+
if not (self.config.ttyd_options.username and self.config.ttyd_options.password):
|
|
93
|
+
raise TTYDStartupError("Credentials required but not provided")
|
|
94
|
+
cmd.extend([
|
|
95
|
+
'-c',
|
|
96
|
+
f"{self.config.ttyd_options.username}:{self.config.ttyd_options.password}"
|
|
97
|
+
])
|
|
98
|
+
|
|
99
|
+
# Debug mode settings
|
|
100
|
+
if self.config.debug:
|
|
101
|
+
cmd.extend(['-d', '3']) # Maximum debug output
|
|
102
|
+
|
|
103
|
+
# Terminal customization
|
|
104
|
+
theme_json = self.config.theme.model_dump_json()
|
|
105
|
+
cmd.extend(['-t', f'theme={theme_json}'])
|
|
106
|
+
|
|
107
|
+
# Explicitly set writable or read-only mode
|
|
108
|
+
if self.config.ttyd_options.writable:
|
|
109
|
+
cmd.append('--writable')
|
|
110
|
+
else:
|
|
111
|
+
cmd.append('-R')
|
|
112
|
+
|
|
113
|
+
# Add client script to run in the terminal
|
|
114
|
+
cmd.extend([
|
|
115
|
+
'python',
|
|
116
|
+
str(self.config.client_script)
|
|
117
|
+
])
|
|
118
|
+
|
|
119
|
+
return cmd
|
|
120
|
+
|
|
121
|
+
def start(self) -> None:
|
|
122
|
+
"""
|
|
123
|
+
Start the ttyd process with the current configuration.
|
|
124
|
+
|
|
125
|
+
This method launches ttyd with appropriate settings and monitors
|
|
126
|
+
its startup to ensure it's running correctly.
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
TTYDStartupError: If process fails to start
|
|
130
|
+
TTYDProcessError: If process is already running
|
|
131
|
+
"""
|
|
132
|
+
if self.is_running:
|
|
133
|
+
raise TTYDProcessError("TTYd process is already running")
|
|
134
|
+
|
|
135
|
+
cmd = self._build_command()
|
|
136
|
+
cmd_str = ' '.join(cmd)
|
|
137
|
+
logger.info(f"Starting ttyd with command: {cmd_str}")
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
# Start the process in a new session to isolate signals
|
|
141
|
+
self.process = subprocess.Popen(
|
|
142
|
+
cmd,
|
|
143
|
+
stdout=subprocess.PIPE,
|
|
144
|
+
stderr=subprocess.PIPE,
|
|
145
|
+
start_new_session=True
|
|
146
|
+
)
|
|
147
|
+
self._start_time = datetime.now()
|
|
148
|
+
|
|
149
|
+
# Monitor startup with longer timeout in debug mode
|
|
150
|
+
timeout = 4 if self.config.debug else 2
|
|
151
|
+
check_interval = 0.1
|
|
152
|
+
checks = int(timeout / check_interval)
|
|
153
|
+
|
|
154
|
+
for _ in range(checks):
|
|
155
|
+
if self.process.poll() is not None:
|
|
156
|
+
stderr = self.process.stderr.read().decode('utf-8')
|
|
157
|
+
logger.error(f"ttyd failed to start. Error: {stderr}")
|
|
158
|
+
raise TTYDStartupError(stderr=stderr)
|
|
159
|
+
|
|
160
|
+
if self.is_running:
|
|
161
|
+
mount_type = "root" if self.config.is_root_mounted else "non-root"
|
|
162
|
+
logger.info(
|
|
163
|
+
f"ttyd process started successfully with PID {self.process.pid} "
|
|
164
|
+
f"({mount_type} mounting)"
|
|
165
|
+
)
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
import time
|
|
169
|
+
time.sleep(check_interval)
|
|
170
|
+
|
|
171
|
+
logger.error("ttyd process did not start within timeout")
|
|
172
|
+
raise TTYDStartupError("ttyd process did not start within timeout")
|
|
173
|
+
|
|
174
|
+
except subprocess.SubprocessError as e:
|
|
175
|
+
logger.error(f"Failed to start ttyd: {e}")
|
|
176
|
+
raise TTYDStartupError(str(e))
|
|
177
|
+
|
|
178
|
+
def stop(self) -> None:
|
|
179
|
+
"""
|
|
180
|
+
Stop the ttyd process if it's running.
|
|
181
|
+
|
|
182
|
+
This method ensures clean process termination using SIGTERM first,
|
|
183
|
+
followed by SIGKILL if necessary. It handles cases where the process
|
|
184
|
+
might have already been terminated.
|
|
185
|
+
"""
|
|
186
|
+
if self.process:
|
|
187
|
+
logger.info("Stopping ttyd process...")
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
# Try graceful shutdown first
|
|
191
|
+
if os.name == 'nt': # Windows
|
|
192
|
+
self.process.terminate()
|
|
193
|
+
else: # Unix-like
|
|
194
|
+
try:
|
|
195
|
+
pgid = os.getpgid(self.process.pid)
|
|
196
|
+
os.killpg(pgid, signal.SIGTERM)
|
|
197
|
+
except ProcessLookupError:
|
|
198
|
+
# Process is already gone, which is fine
|
|
199
|
+
pass
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
self.process.wait(timeout=5) # Wait up to 5 seconds
|
|
203
|
+
except subprocess.TimeoutExpired:
|
|
204
|
+
# Force kill if graceful shutdown fails
|
|
205
|
+
if os.name == 'nt':
|
|
206
|
+
self.process.kill()
|
|
207
|
+
else:
|
|
208
|
+
try:
|
|
209
|
+
pgid = os.getpgid(self.process.pid)
|
|
210
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
211
|
+
except ProcessLookupError:
|
|
212
|
+
# Process is already gone, which is fine
|
|
213
|
+
pass
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
self.process.wait(timeout=1)
|
|
217
|
+
except subprocess.TimeoutExpired:
|
|
218
|
+
# If we still can't wait, the process is probably zombie or gone
|
|
219
|
+
pass
|
|
220
|
+
|
|
221
|
+
except Exception as e:
|
|
222
|
+
logger.warning(f"Error during process cleanup: {e}")
|
|
223
|
+
|
|
224
|
+
self.process = None
|
|
225
|
+
self._start_time = None
|
|
226
|
+
logger.info("ttyd process stopped successfully")
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def is_running(self) -> bool:
|
|
230
|
+
"""Check if ttyd process is currently running."""
|
|
231
|
+
return bool(self.process and self.process.poll() is None)
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def uptime(self) -> Optional[float]:
|
|
235
|
+
"""Get process uptime in seconds, if running."""
|
|
236
|
+
if self._start_time and self.is_running:
|
|
237
|
+
return (datetime.now() - self._start_time).total_seconds()
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
def check_health(self) -> Dict[str, Any]:
|
|
241
|
+
"""
|
|
242
|
+
Get comprehensive health check information about the process.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
Dictionary containing process status, uptime, and configuration details
|
|
246
|
+
"""
|
|
247
|
+
return {
|
|
248
|
+
"status": "running" if self.is_running else "stopped",
|
|
249
|
+
"uptime": self.uptime,
|
|
250
|
+
"pid": self.process.pid if self.process else None,
|
|
251
|
+
"mounting": "root" if self.config.is_root_mounted else "non-root",
|
|
252
|
+
"terminal_path": self.config.terminal_path,
|
|
253
|
+
"ttyd_path": str(self._ttyd_path) if self._ttyd_path else None,
|
|
254
|
+
**self.config.get_health_check_info()
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
@asynccontextmanager
|
|
258
|
+
async def lifespan(self, app: FastAPI):
|
|
259
|
+
"""
|
|
260
|
+
Manage ttyd process lifecycle within FastAPI application.
|
|
261
|
+
|
|
262
|
+
This context manager ensures proper startup and cleanup of the ttyd
|
|
263
|
+
process during the application lifecycle.
|
|
264
|
+
|
|
265
|
+
Usage:
|
|
266
|
+
app = FastAPI(lifespan=manager.lifespan)
|
|
267
|
+
"""
|
|
268
|
+
try:
|
|
269
|
+
self.start()
|
|
270
|
+
yield
|
|
271
|
+
finally:
|
|
272
|
+
self.stop()
|
terminaide/core/proxy.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# terminaide/core/proxy.py
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Proxy management for ttyd HTTP and WebSocket connections.
|
|
5
|
+
|
|
6
|
+
This module handles the proxying of both HTTP and WebSocket connections to the ttyd
|
|
7
|
+
process, with special handling for path management to support both root and non-root
|
|
8
|
+
mounting configurations.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import asyncio
|
|
14
|
+
from typing import Optional, Dict, Any
|
|
15
|
+
from urllib.parse import urljoin
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
import websockets
|
|
19
|
+
import websockets.exceptions
|
|
20
|
+
from fastapi import Request, WebSocket, HTTPException
|
|
21
|
+
from fastapi.responses import Response, StreamingResponse
|
|
22
|
+
|
|
23
|
+
from ..exceptions import ProxyError
|
|
24
|
+
from .settings import TTYDConfig
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("terminaide")
|
|
27
|
+
|
|
28
|
+
class ProxyManager:
|
|
29
|
+
"""
|
|
30
|
+
Manages HTTP and WebSocket proxying for ttyd while maintaining same-origin security.
|
|
31
|
+
|
|
32
|
+
This class handles the complexities of proxying requests to the ttyd process,
|
|
33
|
+
including path rewriting and WebSocket connection management. It supports both
|
|
34
|
+
root ("/") and non-root ("/path") mounting configurations.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, config: TTYDConfig):
|
|
38
|
+
"""
|
|
39
|
+
Initialize the proxy manager.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
config: TTYDConfig instance with proxy configuration
|
|
43
|
+
"""
|
|
44
|
+
self.config = config
|
|
45
|
+
self._client: Optional[httpx.AsyncClient] = None
|
|
46
|
+
|
|
47
|
+
# Build base URLs for the ttyd process
|
|
48
|
+
host = f"{self.config.ttyd_options.interface}:{self.config.port}"
|
|
49
|
+
self.target_url = f"http://{host}"
|
|
50
|
+
self.ws_url = f"ws://{host}/ws"
|
|
51
|
+
|
|
52
|
+
logger.info(
|
|
53
|
+
f"Proxy configured for ttyd at {self.target_url} "
|
|
54
|
+
f"(terminal path: {self.config.terminal_path})"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def http_client(self) -> httpx.AsyncClient:
|
|
59
|
+
"""Lazy initialization of HTTP client."""
|
|
60
|
+
if self._client is None:
|
|
61
|
+
self._client = httpx.AsyncClient(
|
|
62
|
+
timeout=httpx.Timeout(30.0),
|
|
63
|
+
follow_redirects=True
|
|
64
|
+
)
|
|
65
|
+
return self._client
|
|
66
|
+
|
|
67
|
+
async def cleanup(self) -> None:
|
|
68
|
+
"""Clean up resources."""
|
|
69
|
+
if self._client:
|
|
70
|
+
await self._client.aclose()
|
|
71
|
+
self._client = None
|
|
72
|
+
|
|
73
|
+
def _strip_path_prefix(self, path: str) -> str:
|
|
74
|
+
"""
|
|
75
|
+
Strip the mount path prefix from the request path.
|
|
76
|
+
|
|
77
|
+
This method handles both root and non-root mounting scenarios to ensure
|
|
78
|
+
requests are properly forwarded to ttyd.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
path: Original request path
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Path with prefix stripped for ttyd
|
|
85
|
+
"""
|
|
86
|
+
# For root mounting, we only need to strip the /terminal prefix
|
|
87
|
+
if self.config.is_root_mounted:
|
|
88
|
+
if path.startswith("/terminal/"):
|
|
89
|
+
return path.replace("/terminal", "", 1)
|
|
90
|
+
return "/"
|
|
91
|
+
|
|
92
|
+
# For non-root mounting, strip both the mount path and /terminal
|
|
93
|
+
prefix = self.config.terminal_path
|
|
94
|
+
if path.startswith(prefix):
|
|
95
|
+
return path.replace(prefix, "", 1) or "/"
|
|
96
|
+
return "/"
|
|
97
|
+
|
|
98
|
+
async def _handle_sourcemap(self, path: str) -> Response:
|
|
99
|
+
"""Handle sourcemap requests with minimal response."""
|
|
100
|
+
return Response(
|
|
101
|
+
content=json.dumps({
|
|
102
|
+
"version": 3,
|
|
103
|
+
"file": path.split('/')[-1].replace('.map', ''),
|
|
104
|
+
"sourceRoot": "",
|
|
105
|
+
"sources": ["source.js"],
|
|
106
|
+
"sourcesContent": ["// Source code unavailable"],
|
|
107
|
+
"names": [],
|
|
108
|
+
"mappings": ";;;;;;;",
|
|
109
|
+
}),
|
|
110
|
+
media_type='application/json',
|
|
111
|
+
headers={'Access-Control-Allow-Origin': '*'}
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
async def proxy_http(self, request: Request) -> Response:
|
|
115
|
+
"""
|
|
116
|
+
Proxy HTTP requests to ttyd.
|
|
117
|
+
|
|
118
|
+
This method handles path rewriting and forwards the request to the ttyd
|
|
119
|
+
process, supporting both root and non-root mounting configurations.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
request: Incoming FastAPI request
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Proxied response from ttyd
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
ProxyError: If proxying fails
|
|
129
|
+
"""
|
|
130
|
+
path = request.url.path
|
|
131
|
+
|
|
132
|
+
# Handle sourcemap requests
|
|
133
|
+
if path.endswith('.map'):
|
|
134
|
+
return await self._handle_sourcemap(path)
|
|
135
|
+
|
|
136
|
+
# Strip the appropriate prefix based on mounting configuration
|
|
137
|
+
target_path = self._strip_path_prefix(path)
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
# Forward the request to ttyd
|
|
141
|
+
headers = dict(request.headers)
|
|
142
|
+
headers.pop("host", None) # Remove host header
|
|
143
|
+
|
|
144
|
+
response = await self.http_client.request(
|
|
145
|
+
method=request.method,
|
|
146
|
+
url=urljoin(self.target_url, target_path),
|
|
147
|
+
headers=headers,
|
|
148
|
+
content=await request.body()
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Clean response headers that might cause issues
|
|
152
|
+
response_headers = {
|
|
153
|
+
k: v for k, v in response.headers.items()
|
|
154
|
+
if k.lower() not in {
|
|
155
|
+
'content-encoding',
|
|
156
|
+
'content-length',
|
|
157
|
+
'transfer-encoding'
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return StreamingResponse(
|
|
162
|
+
response.aiter_bytes(),
|
|
163
|
+
status_code=response.status_code,
|
|
164
|
+
headers=response_headers,
|
|
165
|
+
media_type=response.headers.get('content-type')
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
except httpx.RequestError as e:
|
|
169
|
+
logger.error(f"HTTP proxy error: {e}")
|
|
170
|
+
raise ProxyError(f"Failed to proxy request: {e}")
|
|
171
|
+
|
|
172
|
+
async def proxy_websocket(self, websocket: WebSocket) -> None:
|
|
173
|
+
"""
|
|
174
|
+
Proxy WebSocket connections to ttyd.
|
|
175
|
+
|
|
176
|
+
This method handles the WebSocket connection to the ttyd process,
|
|
177
|
+
including proper error handling and cleanup.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
websocket: Incoming WebSocket connection
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
ProxyError: If WebSocket proxying fails
|
|
184
|
+
"""
|
|
185
|
+
try:
|
|
186
|
+
# Accept the incoming connection with ttyd subprotocol
|
|
187
|
+
await websocket.accept(subprotocol='tty')
|
|
188
|
+
|
|
189
|
+
logger.info(f"Opening WebSocket connection to {self.ws_url}")
|
|
190
|
+
|
|
191
|
+
async with websockets.connect(
|
|
192
|
+
self.ws_url,
|
|
193
|
+
subprotocols=['tty'],
|
|
194
|
+
ping_interval=None,
|
|
195
|
+
close_timeout=5
|
|
196
|
+
) as target_ws:
|
|
197
|
+
logger.info("WebSocket connection established")
|
|
198
|
+
|
|
199
|
+
# Set up bidirectional forwarding
|
|
200
|
+
async def forward(source: Any, dest: Any, is_client: bool = True) -> None:
|
|
201
|
+
"""Forward data between WebSocket connections."""
|
|
202
|
+
try:
|
|
203
|
+
while True:
|
|
204
|
+
try:
|
|
205
|
+
# Handle different WebSocket implementations
|
|
206
|
+
if is_client:
|
|
207
|
+
data = await source.receive_bytes()
|
|
208
|
+
await dest.send(data)
|
|
209
|
+
else:
|
|
210
|
+
data = await source.recv()
|
|
211
|
+
if isinstance(data, bytes):
|
|
212
|
+
await dest.send_bytes(data)
|
|
213
|
+
else:
|
|
214
|
+
await dest.send_text(data)
|
|
215
|
+
except websockets.exceptions.ConnectionClosed:
|
|
216
|
+
logger.info(
|
|
217
|
+
f"{'Client' if is_client else 'Target'} "
|
|
218
|
+
"connection closed normally"
|
|
219
|
+
)
|
|
220
|
+
break
|
|
221
|
+
except Exception as e:
|
|
222
|
+
if not isinstance(e, asyncio.CancelledError):
|
|
223
|
+
logger.error(
|
|
224
|
+
f"{'Client' if is_client else 'Target'} "
|
|
225
|
+
f"connection error: {e}"
|
|
226
|
+
)
|
|
227
|
+
break
|
|
228
|
+
|
|
229
|
+
except asyncio.CancelledError:
|
|
230
|
+
logger.info(
|
|
231
|
+
f"{'Client' if is_client else 'Target'} "
|
|
232
|
+
"forwarding cancelled"
|
|
233
|
+
)
|
|
234
|
+
raise
|
|
235
|
+
except Exception as e:
|
|
236
|
+
if not isinstance(e, websockets.exceptions.ConnectionClosed):
|
|
237
|
+
logger.error(f"WebSocket forward error: {e}")
|
|
238
|
+
|
|
239
|
+
# Create forwarding tasks
|
|
240
|
+
tasks = [
|
|
241
|
+
asyncio.create_task(forward(websocket, target_ws)),
|
|
242
|
+
asyncio.create_task(forward(target_ws, websocket, False))
|
|
243
|
+
]
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
# Wait for either direction to complete
|
|
247
|
+
await asyncio.wait(
|
|
248
|
+
tasks,
|
|
249
|
+
return_when=asyncio.FIRST_COMPLETED
|
|
250
|
+
)
|
|
251
|
+
finally:
|
|
252
|
+
# Clean up tasks
|
|
253
|
+
for task in tasks:
|
|
254
|
+
if not task.done():
|
|
255
|
+
task.cancel()
|
|
256
|
+
try:
|
|
257
|
+
await task
|
|
258
|
+
except asyncio.CancelledError:
|
|
259
|
+
pass
|
|
260
|
+
|
|
261
|
+
except Exception as e:
|
|
262
|
+
logger.error(f"WebSocket proxy error: {e}")
|
|
263
|
+
if not isinstance(e, websockets.exceptions.ConnectionClosed):
|
|
264
|
+
raise ProxyError(f"WebSocket proxy error: {e}")
|
|
265
|
+
|
|
266
|
+
finally:
|
|
267
|
+
# Ensure WebSocket is closed
|
|
268
|
+
try:
|
|
269
|
+
await websocket.close()
|
|
270
|
+
except Exception:
|
|
271
|
+
pass # Connection already closed
|
|
272
|
+
|
|
273
|
+
def get_routes_info(self) -> Dict[str, Any]:
|
|
274
|
+
"""Get information about proxy routes for monitoring."""
|
|
275
|
+
return {
|
|
276
|
+
"http_endpoint": self.target_url,
|
|
277
|
+
"ws_endpoint": self.ws_url,
|
|
278
|
+
"mount_path": self.config.mount_path,
|
|
279
|
+
"terminal_path": self.config.terminal_path,
|
|
280
|
+
"is_root_mounted": self.config.is_root_mounted
|
|
281
|
+
}
|