ptn 0.1.4__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.
- porterminal/__init__.py +288 -0
- porterminal/__main__.py +8 -0
- porterminal/app.py +381 -0
- porterminal/application/__init__.py +1 -0
- porterminal/application/ports/__init__.py +7 -0
- porterminal/application/ports/connection_port.py +34 -0
- porterminal/application/services/__init__.py +13 -0
- porterminal/application/services/management_service.py +279 -0
- porterminal/application/services/session_service.py +249 -0
- porterminal/application/services/tab_service.py +286 -0
- porterminal/application/services/terminal_service.py +426 -0
- porterminal/asgi.py +38 -0
- porterminal/cli/__init__.py +19 -0
- porterminal/cli/args.py +91 -0
- porterminal/cli/display.py +157 -0
- porterminal/composition.py +208 -0
- porterminal/config.py +195 -0
- porterminal/container.py +65 -0
- porterminal/domain/__init__.py +91 -0
- porterminal/domain/entities/__init__.py +16 -0
- porterminal/domain/entities/output_buffer.py +73 -0
- porterminal/domain/entities/session.py +86 -0
- porterminal/domain/entities/tab.py +71 -0
- porterminal/domain/ports/__init__.py +12 -0
- porterminal/domain/ports/pty_port.py +80 -0
- porterminal/domain/ports/session_repository.py +58 -0
- porterminal/domain/ports/tab_repository.py +75 -0
- porterminal/domain/services/__init__.py +18 -0
- porterminal/domain/services/environment_sanitizer.py +61 -0
- porterminal/domain/services/rate_limiter.py +63 -0
- porterminal/domain/services/session_limits.py +104 -0
- porterminal/domain/services/tab_limits.py +54 -0
- porterminal/domain/values/__init__.py +25 -0
- porterminal/domain/values/environment_rules.py +156 -0
- porterminal/domain/values/rate_limit_config.py +21 -0
- porterminal/domain/values/session_id.py +20 -0
- porterminal/domain/values/shell_command.py +37 -0
- porterminal/domain/values/tab_id.py +24 -0
- porterminal/domain/values/terminal_dimensions.py +45 -0
- porterminal/domain/values/user_id.py +25 -0
- porterminal/infrastructure/__init__.py +20 -0
- porterminal/infrastructure/cloudflared.py +295 -0
- porterminal/infrastructure/config/__init__.py +9 -0
- porterminal/infrastructure/config/shell_detector.py +84 -0
- porterminal/infrastructure/config/yaml_loader.py +34 -0
- porterminal/infrastructure/network.py +43 -0
- porterminal/infrastructure/registry/__init__.py +5 -0
- porterminal/infrastructure/registry/user_connection_registry.py +104 -0
- porterminal/infrastructure/repositories/__init__.py +9 -0
- porterminal/infrastructure/repositories/in_memory_session.py +70 -0
- porterminal/infrastructure/repositories/in_memory_tab.py +124 -0
- porterminal/infrastructure/server.py +161 -0
- porterminal/infrastructure/web/__init__.py +7 -0
- porterminal/infrastructure/web/websocket_adapter.py +78 -0
- porterminal/logging_setup.py +48 -0
- porterminal/pty/__init__.py +46 -0
- porterminal/pty/env.py +97 -0
- porterminal/pty/manager.py +163 -0
- porterminal/pty/protocol.py +84 -0
- porterminal/pty/unix.py +162 -0
- porterminal/pty/windows.py +131 -0
- porterminal/static/assets/app-BQiuUo6Q.css +32 -0
- porterminal/static/assets/app-YNN_jEhv.js +71 -0
- porterminal/static/icon.svg +34 -0
- porterminal/static/index.html +139 -0
- porterminal/static/manifest.json +31 -0
- porterminal/static/sw.js +66 -0
- porterminal/updater.py +257 -0
- ptn-0.1.4.dist-info/METADATA +191 -0
- ptn-0.1.4.dist-info/RECORD +73 -0
- ptn-0.1.4.dist-info/WHEEL +4 -0
- ptn-0.1.4.dist-info/entry_points.txt +2 -0
- ptn-0.1.4.dist-info/licenses/LICENSE +661 -0
porterminal/__init__.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Porterminal - Web-based terminal accessible via Cloudflare Tunnel.
|
|
3
|
+
|
|
4
|
+
This package provides:
|
|
5
|
+
- FastAPI server with WebSocket terminal endpoint
|
|
6
|
+
- PTY management with cross-platform support (Windows/Unix)
|
|
7
|
+
- Session management with reconnection support
|
|
8
|
+
- Configuration system with shell auto-detection
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.4"
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from threading import Thread
|
|
19
|
+
|
|
20
|
+
from rich.console import Console
|
|
21
|
+
|
|
22
|
+
from porterminal.cli import display_startup_screen, parse_args
|
|
23
|
+
from porterminal.infrastructure import (
|
|
24
|
+
CloudflaredInstaller,
|
|
25
|
+
drain_process_output,
|
|
26
|
+
find_available_port,
|
|
27
|
+
is_port_available,
|
|
28
|
+
start_cloudflared,
|
|
29
|
+
start_server,
|
|
30
|
+
wait_for_server,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
console = Console()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _run_in_background(args) -> int:
|
|
37
|
+
"""Spawn the server in background and return immediately."""
|
|
38
|
+
import tempfile
|
|
39
|
+
|
|
40
|
+
# Create temp file for URL communication
|
|
41
|
+
url_file = Path(tempfile.gettempdir()) / f"porterminal-{os.getpid()}.url"
|
|
42
|
+
|
|
43
|
+
# Build command without --background flag, with URL file
|
|
44
|
+
cmd = [sys.executable, "-m", "porterminal", f"--_url-file={url_file}"]
|
|
45
|
+
if args.path:
|
|
46
|
+
cmd.append(args.path)
|
|
47
|
+
if args.no_tunnel:
|
|
48
|
+
cmd.append("--no-tunnel")
|
|
49
|
+
if args.verbose:
|
|
50
|
+
cmd.append("--verbose")
|
|
51
|
+
|
|
52
|
+
# Start subprocess
|
|
53
|
+
popen_kwargs = {
|
|
54
|
+
"stdout": subprocess.DEVNULL,
|
|
55
|
+
"stderr": subprocess.DEVNULL,
|
|
56
|
+
"stdin": subprocess.DEVNULL,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if sys.platform == "win32":
|
|
60
|
+
# Windows: CREATE_NO_WINDOW hides console window
|
|
61
|
+
CREATE_NO_WINDOW = 0x08000000
|
|
62
|
+
popen_kwargs["creationflags"] = CREATE_NO_WINDOW
|
|
63
|
+
else:
|
|
64
|
+
# Unix: start_new_session to detach from terminal
|
|
65
|
+
popen_kwargs["start_new_session"] = True
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
proc = subprocess.Popen(cmd, **popen_kwargs)
|
|
69
|
+
except Exception as e:
|
|
70
|
+
console.print(f"[red]Error starting process:[/red] {e}")
|
|
71
|
+
return 1
|
|
72
|
+
|
|
73
|
+
# Wait for URL file to be created (with timeout)
|
|
74
|
+
timeout = 30
|
|
75
|
+
start_time = time.time()
|
|
76
|
+
|
|
77
|
+
with console.status("[cyan]Starting in background...[/cyan]", spinner="dots") as status:
|
|
78
|
+
while time.time() - start_time < timeout:
|
|
79
|
+
if url_file.exists():
|
|
80
|
+
try:
|
|
81
|
+
content = url_file.read_text().strip()
|
|
82
|
+
if content:
|
|
83
|
+
status.stop()
|
|
84
|
+
url = content
|
|
85
|
+
is_tunnel = url.startswith("https://")
|
|
86
|
+
cwd = args.path or os.getcwd()
|
|
87
|
+
|
|
88
|
+
# Display full startup screen with QR code
|
|
89
|
+
display_startup_screen(url, is_tunnel=is_tunnel, cwd=cwd)
|
|
90
|
+
|
|
91
|
+
# Add background mode info
|
|
92
|
+
console.print(
|
|
93
|
+
f"[green]Running in background[/green] [dim](PID: {proc.pid})[/dim]"
|
|
94
|
+
)
|
|
95
|
+
stop_cmd = (
|
|
96
|
+
f"taskkill /T /PID {proc.pid} /F"
|
|
97
|
+
if sys.platform == "win32"
|
|
98
|
+
else f"kill {proc.pid}"
|
|
99
|
+
)
|
|
100
|
+
console.print(f"[dim]Stop with: {stop_cmd}[/dim]\n")
|
|
101
|
+
|
|
102
|
+
# Cleanup temp file
|
|
103
|
+
try:
|
|
104
|
+
url_file.unlink()
|
|
105
|
+
except OSError:
|
|
106
|
+
pass
|
|
107
|
+
return 0
|
|
108
|
+
except OSError:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
if proc.poll() is not None:
|
|
112
|
+
status.stop()
|
|
113
|
+
console.print(
|
|
114
|
+
f"[red]Error:[/red] Process exited unexpectedly (code: {proc.returncode})"
|
|
115
|
+
)
|
|
116
|
+
# Cleanup temp file
|
|
117
|
+
try:
|
|
118
|
+
url_file.unlink()
|
|
119
|
+
except OSError:
|
|
120
|
+
pass
|
|
121
|
+
return 1
|
|
122
|
+
|
|
123
|
+
time.sleep(0.2)
|
|
124
|
+
|
|
125
|
+
console.print("[red]Error:[/red] Timeout waiting for server to start")
|
|
126
|
+
# Kill the process tree (includes child shells)
|
|
127
|
+
if sys.platform == "win32":
|
|
128
|
+
subprocess.run(["taskkill", "/T", "/PID", str(proc.pid), "/F"], capture_output=True)
|
|
129
|
+
else:
|
|
130
|
+
proc.terminate()
|
|
131
|
+
try:
|
|
132
|
+
proc.wait(timeout=3)
|
|
133
|
+
except subprocess.TimeoutExpired:
|
|
134
|
+
proc.kill()
|
|
135
|
+
proc.wait()
|
|
136
|
+
return 1
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def main() -> int:
|
|
140
|
+
"""Main entry point."""
|
|
141
|
+
args = parse_args()
|
|
142
|
+
verbose = args.verbose
|
|
143
|
+
|
|
144
|
+
# Handle background mode
|
|
145
|
+
if args.background:
|
|
146
|
+
return _run_in_background(args)
|
|
147
|
+
|
|
148
|
+
# Set log level based on verbose flag
|
|
149
|
+
if verbose:
|
|
150
|
+
os.environ["PORTERMINAL_LOG_LEVEL"] = "DEBUG"
|
|
151
|
+
|
|
152
|
+
# Validate and set working directory
|
|
153
|
+
cwd_str = None
|
|
154
|
+
if args.path:
|
|
155
|
+
cwd = Path(args.path).resolve()
|
|
156
|
+
if not cwd.exists():
|
|
157
|
+
console.print(f"[red]Error:[/red] Path does not exist: {cwd}")
|
|
158
|
+
return 1
|
|
159
|
+
if not cwd.is_dir():
|
|
160
|
+
console.print(f"[red]Error:[/red] Path is not a directory: {cwd}")
|
|
161
|
+
return 1
|
|
162
|
+
cwd_str = str(cwd)
|
|
163
|
+
os.environ["PORTERMINAL_CWD"] = cwd_str
|
|
164
|
+
|
|
165
|
+
from porterminal.config import get_config
|
|
166
|
+
|
|
167
|
+
config = get_config()
|
|
168
|
+
bind_host = config.server.host
|
|
169
|
+
preferred_port = config.server.port
|
|
170
|
+
port = preferred_port
|
|
171
|
+
# Use 127.0.0.1 for health checks (can't connect to 0.0.0.0)
|
|
172
|
+
check_host = "127.0.0.1" if bind_host == "0.0.0.0" else bind_host
|
|
173
|
+
|
|
174
|
+
# Check cloudflared (skip if --no-tunnel)
|
|
175
|
+
if not args.no_tunnel and not CloudflaredInstaller.is_installed():
|
|
176
|
+
console.print("[yellow]cloudflared not found[/yellow]")
|
|
177
|
+
if not CloudflaredInstaller.install():
|
|
178
|
+
console.print()
|
|
179
|
+
console.print("Install manually: [cyan]winget install cloudflare.cloudflared[/cyan]")
|
|
180
|
+
return 1
|
|
181
|
+
# Verify installation - if still not found, ask to restart shell
|
|
182
|
+
if not CloudflaredInstaller.is_installed():
|
|
183
|
+
console.print()
|
|
184
|
+
console.print("[yellow]Please restart your terminal and run again.[/yellow]")
|
|
185
|
+
return 0 # Exit gracefully, not an error
|
|
186
|
+
|
|
187
|
+
# Show startup status
|
|
188
|
+
with console.status("[cyan]Starting...[/cyan]", spinner="dots") as status:
|
|
189
|
+
# Start or reuse server
|
|
190
|
+
if wait_for_server(check_host, port, timeout=1):
|
|
191
|
+
if verbose:
|
|
192
|
+
console.print(f"[dim]Reusing server on {bind_host}:{port}[/dim]")
|
|
193
|
+
server_process = None
|
|
194
|
+
else:
|
|
195
|
+
if not is_port_available(bind_host, port):
|
|
196
|
+
port = find_available_port(bind_host, preferred_port)
|
|
197
|
+
if verbose:
|
|
198
|
+
console.print(f"[dim]Using port {port}[/dim]")
|
|
199
|
+
|
|
200
|
+
status.update("[cyan]Starting server...[/cyan]")
|
|
201
|
+
server_process = start_server(bind_host, port, verbose=verbose)
|
|
202
|
+
|
|
203
|
+
if not wait_for_server(check_host, port, timeout=30):
|
|
204
|
+
console.print("[red]Error:[/red] Server failed to start")
|
|
205
|
+
if server_process and server_process.poll() is None:
|
|
206
|
+
server_process.terminate()
|
|
207
|
+
try:
|
|
208
|
+
server_process.wait(timeout=3)
|
|
209
|
+
except subprocess.TimeoutExpired:
|
|
210
|
+
server_process.kill()
|
|
211
|
+
server_process.wait()
|
|
212
|
+
return 1
|
|
213
|
+
|
|
214
|
+
tunnel_process = None
|
|
215
|
+
tunnel_url = None
|
|
216
|
+
|
|
217
|
+
if not args.no_tunnel:
|
|
218
|
+
status.update("[cyan]Establishing tunnel...[/cyan]")
|
|
219
|
+
tunnel_process, tunnel_url = start_cloudflared(port)
|
|
220
|
+
|
|
221
|
+
if not tunnel_url:
|
|
222
|
+
console.print("[red]Error:[/red] Failed to establish tunnel")
|
|
223
|
+
for proc in [server_process, tunnel_process]:
|
|
224
|
+
if proc and proc.poll() is None:
|
|
225
|
+
proc.terminate()
|
|
226
|
+
try:
|
|
227
|
+
proc.wait(timeout=3)
|
|
228
|
+
except subprocess.TimeoutExpired:
|
|
229
|
+
proc.kill()
|
|
230
|
+
proc.wait()
|
|
231
|
+
return 1
|
|
232
|
+
|
|
233
|
+
# Determine final URL
|
|
234
|
+
display_cwd = cwd_str or os.getcwd()
|
|
235
|
+
if args.no_tunnel:
|
|
236
|
+
display_url = f"http://{check_host}:{port}"
|
|
237
|
+
else:
|
|
238
|
+
display_url = tunnel_url
|
|
239
|
+
|
|
240
|
+
# If running as background child, write URL to file and skip display
|
|
241
|
+
if args.url_file:
|
|
242
|
+
try:
|
|
243
|
+
Path(args.url_file).write_text(display_url)
|
|
244
|
+
except OSError as e:
|
|
245
|
+
console.print(f"[red]Error writing URL file:[/red] {e}")
|
|
246
|
+
else:
|
|
247
|
+
# Display final screen (only in foreground mode)
|
|
248
|
+
display_startup_screen(display_url, is_tunnel=not args.no_tunnel, cwd=display_cwd)
|
|
249
|
+
|
|
250
|
+
# Drain process output silently in background (only when not verbose)
|
|
251
|
+
if server_process is not None and not verbose:
|
|
252
|
+
Thread(target=drain_process_output, args=(server_process,), daemon=True).start()
|
|
253
|
+
if tunnel_process is not None:
|
|
254
|
+
Thread(target=drain_process_output, args=(tunnel_process,), daemon=True).start()
|
|
255
|
+
|
|
256
|
+
# Wait for Ctrl+C or process exit
|
|
257
|
+
try:
|
|
258
|
+
while True:
|
|
259
|
+
if server_process is not None and server_process.poll() is not None:
|
|
260
|
+
console.print("\n[red]Server stopped unexpectedly[/red]")
|
|
261
|
+
break
|
|
262
|
+
if tunnel_process is not None and tunnel_process.poll() is not None:
|
|
263
|
+
console.print("\n[red]Tunnel stopped unexpectedly[/red]")
|
|
264
|
+
break
|
|
265
|
+
time.sleep(1)
|
|
266
|
+
|
|
267
|
+
except KeyboardInterrupt:
|
|
268
|
+
console.print("\n[dim]Shutting down...[/dim]")
|
|
269
|
+
|
|
270
|
+
# Cleanup - terminate gracefully, then kill if needed
|
|
271
|
+
def cleanup_process(proc: subprocess.Popen | None, name: str) -> None:
|
|
272
|
+
if proc is None or proc.poll() is not None:
|
|
273
|
+
return
|
|
274
|
+
try:
|
|
275
|
+
proc.terminate()
|
|
276
|
+
proc.wait(timeout=5)
|
|
277
|
+
except subprocess.TimeoutExpired:
|
|
278
|
+
proc.kill()
|
|
279
|
+
proc.wait() # Reap the killed process
|
|
280
|
+
|
|
281
|
+
cleanup_process(server_process, "server")
|
|
282
|
+
cleanup_process(tunnel_process, "tunnel")
|
|
283
|
+
|
|
284
|
+
return 0
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
if __name__ == "__main__":
|
|
288
|
+
sys.exit(main())
|
porterminal/__main__.py
ADDED
porterminal/app.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""FastAPI application with security checks and WebSocket endpoint."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import ctypes
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import signal
|
|
8
|
+
from contextlib import asynccontextmanager, suppress
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from fastapi import FastAPI, Query, Request, Response, WebSocket, WebSocketDisconnect
|
|
12
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
13
|
+
from fastapi.staticfiles import StaticFiles
|
|
14
|
+
from starlette.middleware.base import RequestResponseEndpoint
|
|
15
|
+
|
|
16
|
+
from .composition import create_container
|
|
17
|
+
from .container import Container
|
|
18
|
+
from .domain import UserId
|
|
19
|
+
from .infrastructure.web import FastAPIWebSocketAdapter
|
|
20
|
+
from .logging_setup import setup_logging_from_env
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
# Path to static files (inside package)
|
|
25
|
+
STATIC_DIR = Path(__file__).parent / "static"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_admin() -> bool:
|
|
29
|
+
"""Check if running as administrator (Windows)."""
|
|
30
|
+
try:
|
|
31
|
+
return ctypes.windll.shell32.IsUserAnAdmin() != 0
|
|
32
|
+
except Exception:
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def security_preflight_checks() -> None:
|
|
37
|
+
"""Run security checks before starting the application."""
|
|
38
|
+
# Check not running as admin
|
|
39
|
+
if is_admin():
|
|
40
|
+
logger.warning(
|
|
41
|
+
"SECURITY WARNING: Running as Administrator is not recommended. "
|
|
42
|
+
"This exposes excessive privileges to remote users."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@asynccontextmanager
|
|
47
|
+
async def lifespan(app: FastAPI):
|
|
48
|
+
"""Application lifespan manager."""
|
|
49
|
+
# Startup
|
|
50
|
+
setup_logging_from_env()
|
|
51
|
+
security_preflight_checks()
|
|
52
|
+
|
|
53
|
+
# Create DI container with all wired dependencies
|
|
54
|
+
config_path = os.environ.get("PORTERMINAL_CONFIG_PATH", "config.yaml")
|
|
55
|
+
cwd = os.environ.get("PORTERMINAL_CWD")
|
|
56
|
+
|
|
57
|
+
container = create_container(config_path=config_path, cwd=cwd)
|
|
58
|
+
app.state.container = container
|
|
59
|
+
|
|
60
|
+
# Wire up cascade: when session is destroyed, close associated tabs and broadcast
|
|
61
|
+
async def on_session_destroyed(session_id, user_id):
|
|
62
|
+
closed_tabs = container.tab_service.close_tabs_for_session(session_id)
|
|
63
|
+
for tab in closed_tabs:
|
|
64
|
+
message = container.tab_service.build_tab_closed_message(tab.tab_id, "session_ended")
|
|
65
|
+
await container.connection_registry.broadcast(user_id, message)
|
|
66
|
+
|
|
67
|
+
container.session_service.set_on_session_destroyed(on_session_destroyed)
|
|
68
|
+
|
|
69
|
+
await container.session_service.start()
|
|
70
|
+
|
|
71
|
+
logger.info("Porterminal server started")
|
|
72
|
+
|
|
73
|
+
yield
|
|
74
|
+
|
|
75
|
+
# Shutdown
|
|
76
|
+
await container.session_service.stop()
|
|
77
|
+
logger.info("Porterminal server stopped")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def create_app() -> FastAPI:
|
|
81
|
+
"""Create and configure the FastAPI application."""
|
|
82
|
+
app = FastAPI(
|
|
83
|
+
title="Porterminal",
|
|
84
|
+
description="Web-based terminal accessible from phone via Cloudflare Tunnel",
|
|
85
|
+
version="0.1.2",
|
|
86
|
+
lifespan=lifespan,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
@app.middleware("http")
|
|
90
|
+
async def no_cache_static_assets(
|
|
91
|
+
request: Request, call_next: RequestResponseEndpoint
|
|
92
|
+
) -> Response:
|
|
93
|
+
"""Disable caching for static assets to ensure live updates during development."""
|
|
94
|
+
response = await call_next(request)
|
|
95
|
+
if request.url.path.startswith("/static/"):
|
|
96
|
+
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
|
97
|
+
response.headers["Pragma"] = "no-cache"
|
|
98
|
+
response.headers["Expires"] = "0"
|
|
99
|
+
return response
|
|
100
|
+
|
|
101
|
+
# Mount static files
|
|
102
|
+
if STATIC_DIR.exists():
|
|
103
|
+
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
|
104
|
+
|
|
105
|
+
@app.get("/", response_class=HTMLResponse)
|
|
106
|
+
async def index():
|
|
107
|
+
"""Serve the main page."""
|
|
108
|
+
index_path = STATIC_DIR / "index.html"
|
|
109
|
+
if index_path.exists():
|
|
110
|
+
content = index_path.read_text(encoding="utf-8")
|
|
111
|
+
return HTMLResponse(
|
|
112
|
+
content=content,
|
|
113
|
+
headers={
|
|
114
|
+
"Cache-Control": "no-cache, no-store, must-revalidate",
|
|
115
|
+
"Pragma": "no-cache",
|
|
116
|
+
"Expires": "0",
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
return JSONResponse(
|
|
120
|
+
{"error": "index.html not found"},
|
|
121
|
+
status_code=404,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
@app.get("/health")
|
|
125
|
+
async def health():
|
|
126
|
+
"""Health check endpoint."""
|
|
127
|
+
container: Container = app.state.container
|
|
128
|
+
return {
|
|
129
|
+
"status": "healthy",
|
|
130
|
+
"sessions": container.session_service.session_count(),
|
|
131
|
+
"tabs": container.tab_service.tab_count(),
|
|
132
|
+
"connections": container.connection_registry.total_connections(),
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
@app.get("/api/tabs")
|
|
136
|
+
async def list_tabs(request: Request):
|
|
137
|
+
"""List all tabs for the current user."""
|
|
138
|
+
container: Container = app.state.container
|
|
139
|
+
user_id = UserId(request.headers.get("cf-access-authenticated-user-email", "local-user"))
|
|
140
|
+
tabs = container.tab_service.get_user_tabs(user_id)
|
|
141
|
+
return {
|
|
142
|
+
"tabs": [tab.to_dict() for tab in tabs],
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
@app.get("/api/config")
|
|
146
|
+
async def get_client_config():
|
|
147
|
+
"""Get client configuration (shells and buttons)."""
|
|
148
|
+
container: Container = app.state.container
|
|
149
|
+
return {
|
|
150
|
+
"shells": [{"id": s.id, "name": s.name} for s in container.available_shells],
|
|
151
|
+
"buttons": container.buttons,
|
|
152
|
+
"default_shell": container.default_shell_id,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
@app.post("/api/config/reload")
|
|
156
|
+
async def reload_configuration():
|
|
157
|
+
"""Reload configuration from file.
|
|
158
|
+
|
|
159
|
+
Note: With the new DI architecture, hot-reload requires server restart.
|
|
160
|
+
"""
|
|
161
|
+
return JSONResponse(
|
|
162
|
+
{"status": "info", "message": "Config reload requires server restart"},
|
|
163
|
+
status_code=501,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
@app.post("/api/shutdown")
|
|
167
|
+
async def shutdown_server(request: Request):
|
|
168
|
+
"""Shutdown the server and tunnel.
|
|
169
|
+
|
|
170
|
+
Allowed from:
|
|
171
|
+
- Localhost (direct access)
|
|
172
|
+
- Cloudflare Tunnel (cf-ray header present)
|
|
173
|
+
- Cloudflare Access authenticated users
|
|
174
|
+
"""
|
|
175
|
+
# Check if request is from localhost
|
|
176
|
+
client_host = request.client.host if request.client else None
|
|
177
|
+
is_localhost = client_host in ("127.0.0.1", "::1", "localhost")
|
|
178
|
+
|
|
179
|
+
# Check for Cloudflare Tunnel (has cf-ray header)
|
|
180
|
+
is_cloudflare_tunnel = request.headers.get("cf-ray") is not None
|
|
181
|
+
|
|
182
|
+
# Check for Cloudflare Access authentication
|
|
183
|
+
cf_user = request.headers.get("cf-access-authenticated-user-email")
|
|
184
|
+
|
|
185
|
+
if not is_localhost and not is_cloudflare_tunnel and not cf_user:
|
|
186
|
+
logger.warning(
|
|
187
|
+
"Unauthorized shutdown attempt from %s",
|
|
188
|
+
client_host,
|
|
189
|
+
)
|
|
190
|
+
return JSONResponse(
|
|
191
|
+
{"error": "Unauthorized - must be localhost or via Cloudflare Tunnel"},
|
|
192
|
+
status_code=403,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
source = cf_user or ("tunnel" if is_cloudflare_tunnel else client_host)
|
|
196
|
+
logger.info("Shutdown requested via API by %s", source)
|
|
197
|
+
|
|
198
|
+
# Send response before shutting down
|
|
199
|
+
asyncio.get_running_loop().call_later(0.5, lambda: os.kill(os.getpid(), signal.SIGTERM))
|
|
200
|
+
|
|
201
|
+
return {"status": "ok", "message": "Server shutting down..."}
|
|
202
|
+
|
|
203
|
+
@app.websocket("/ws/management")
|
|
204
|
+
async def websocket_management(websocket: WebSocket):
|
|
205
|
+
"""Management WebSocket for tab operations and state sync.
|
|
206
|
+
|
|
207
|
+
This is the control plane for tab management. Clients send requests
|
|
208
|
+
(create_tab, close_tab, rename_tab) and receive responses + state updates.
|
|
209
|
+
"""
|
|
210
|
+
await websocket.accept()
|
|
211
|
+
|
|
212
|
+
container: Container = websocket.app.state.container
|
|
213
|
+
management_service = container.management_service
|
|
214
|
+
connection_registry = container.connection_registry
|
|
215
|
+
|
|
216
|
+
# Get user ID from headers (Cloudflare Access)
|
|
217
|
+
user_id = UserId(websocket.headers.get("cf-access-authenticated-user-email", "local-user"))
|
|
218
|
+
connection = FastAPIWebSocketAdapter(websocket)
|
|
219
|
+
|
|
220
|
+
logger.info(
|
|
221
|
+
"Management WebSocket connected client=%s user_id=%s",
|
|
222
|
+
getattr(websocket.client, "host", None),
|
|
223
|
+
user_id,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
# Register for broadcasts
|
|
228
|
+
await connection_registry.register(user_id, connection)
|
|
229
|
+
|
|
230
|
+
# Send initial state sync
|
|
231
|
+
state_sync = management_service.build_state_sync(user_id)
|
|
232
|
+
await connection.send_message(state_sync)
|
|
233
|
+
|
|
234
|
+
# Handle messages
|
|
235
|
+
while connection.is_connected():
|
|
236
|
+
try:
|
|
237
|
+
message = await connection.receive()
|
|
238
|
+
if isinstance(message, dict):
|
|
239
|
+
await management_service.handle_message(user_id, connection, message)
|
|
240
|
+
except WebSocketDisconnect:
|
|
241
|
+
break
|
|
242
|
+
except Exception as e:
|
|
243
|
+
logger.warning("Management message error: %s", e)
|
|
244
|
+
break
|
|
245
|
+
|
|
246
|
+
except WebSocketDisconnect:
|
|
247
|
+
pass
|
|
248
|
+
except Exception:
|
|
249
|
+
logger.exception("Management WebSocket error user_id=%s", user_id)
|
|
250
|
+
finally:
|
|
251
|
+
await connection_registry.unregister(user_id, connection)
|
|
252
|
+
logger.info("Management WebSocket disconnected user_id=%s", user_id)
|
|
253
|
+
|
|
254
|
+
@app.websocket("/ws")
|
|
255
|
+
async def websocket_terminal(
|
|
256
|
+
websocket: WebSocket,
|
|
257
|
+
skip_buffer: str | None = Query(None),
|
|
258
|
+
tab_id: str | None = Query(None),
|
|
259
|
+
):
|
|
260
|
+
"""WebSocket endpoint for terminal I/O only.
|
|
261
|
+
|
|
262
|
+
This endpoint REQUIRES a valid tab_id. Tabs must be created via
|
|
263
|
+
/ws/management first. This endpoint only handles terminal I/O
|
|
264
|
+
for existing tabs.
|
|
265
|
+
"""
|
|
266
|
+
logger.info(
|
|
267
|
+
"WebSocket connect attempt client=%s tab_id=%s",
|
|
268
|
+
getattr(websocket.client, "host", None),
|
|
269
|
+
tab_id,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# Get dependencies from container
|
|
273
|
+
container: Container = websocket.app.state.container
|
|
274
|
+
session_service = container.session_service
|
|
275
|
+
tab_service = container.tab_service
|
|
276
|
+
terminal_service = container.terminal_service
|
|
277
|
+
connection_registry = container.connection_registry
|
|
278
|
+
|
|
279
|
+
# Get user ID from headers (Cloudflare Access)
|
|
280
|
+
user_id = UserId(websocket.headers.get("cf-access-authenticated-user-email", "local-user"))
|
|
281
|
+
|
|
282
|
+
# Validate tab_id is provided
|
|
283
|
+
if not tab_id:
|
|
284
|
+
logger.warning("WebSocket rejected - no tab_id provided user_id=%s", user_id)
|
|
285
|
+
await websocket.close(code=4000, reason="tab_id required")
|
|
286
|
+
return
|
|
287
|
+
|
|
288
|
+
# Validate tab exists and belongs to user
|
|
289
|
+
tab = tab_service.get_tab(tab_id)
|
|
290
|
+
if not tab or str(tab.user_id) != str(user_id):
|
|
291
|
+
logger.warning(
|
|
292
|
+
"WebSocket rejected - tab not found or unauthorized user_id=%s tab_id=%s",
|
|
293
|
+
user_id,
|
|
294
|
+
tab_id,
|
|
295
|
+
)
|
|
296
|
+
await websocket.close(code=4004, reason="Tab not found")
|
|
297
|
+
return
|
|
298
|
+
|
|
299
|
+
# Get the session for this tab
|
|
300
|
+
session = await session_service.reconnect_session(tab.session_id, user_id)
|
|
301
|
+
if not session:
|
|
302
|
+
# Session died - close tab and reject connection
|
|
303
|
+
logger.warning(
|
|
304
|
+
"WebSocket rejected - session ended user_id=%s tab_id=%s session_id=%s",
|
|
305
|
+
user_id,
|
|
306
|
+
tab_id,
|
|
307
|
+
tab.session_id,
|
|
308
|
+
)
|
|
309
|
+
closed_tab = tab_service.close_tab(tab_id, user_id)
|
|
310
|
+
if closed_tab:
|
|
311
|
+
# Accept briefly to send error, then close
|
|
312
|
+
await websocket.accept()
|
|
313
|
+
connection = FastAPIWebSocketAdapter(websocket)
|
|
314
|
+
await connection_registry.register(user_id, connection)
|
|
315
|
+
await connection_registry.broadcast(
|
|
316
|
+
user_id,
|
|
317
|
+
tab_service.build_tab_closed_message(tab_id, "session_ended"),
|
|
318
|
+
)
|
|
319
|
+
await connection_registry.unregister(user_id, connection)
|
|
320
|
+
await websocket.close(code=4005, reason="Session ended")
|
|
321
|
+
return
|
|
322
|
+
|
|
323
|
+
# Accept the connection
|
|
324
|
+
await websocket.accept()
|
|
325
|
+
connection = FastAPIWebSocketAdapter(websocket)
|
|
326
|
+
|
|
327
|
+
logger.info(
|
|
328
|
+
"WebSocket accepted client=%s user_id=%s tab_id=%s session_id=%s",
|
|
329
|
+
getattr(websocket.client, "host", None),
|
|
330
|
+
user_id,
|
|
331
|
+
tab_id,
|
|
332
|
+
session.session_id,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
# Update tab access time
|
|
336
|
+
tab_service.touch_tab(tab_id, user_id)
|
|
337
|
+
|
|
338
|
+
try:
|
|
339
|
+
# Register connection for broadcasts
|
|
340
|
+
await connection_registry.register(user_id, connection)
|
|
341
|
+
|
|
342
|
+
# Send session info
|
|
343
|
+
await connection.send_message(
|
|
344
|
+
{
|
|
345
|
+
"type": "session_info",
|
|
346
|
+
"session_id": session.session_id,
|
|
347
|
+
"shell": session.shell_id,
|
|
348
|
+
"tab_id": tab.tab_id,
|
|
349
|
+
}
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
# Handle terminal I/O
|
|
353
|
+
await terminal_service.handle_session(
|
|
354
|
+
session, connection, skip_buffer=bool(skip_buffer)
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
except WebSocketDisconnect:
|
|
358
|
+
logger.info("Client disconnected user_id=%s tab_id=%s", user_id, tab_id)
|
|
359
|
+
|
|
360
|
+
except Exception:
|
|
361
|
+
logger.exception("WebSocket error user_id=%s tab_id=%s", user_id, tab_id)
|
|
362
|
+
with suppress(Exception):
|
|
363
|
+
await connection.close(code=1011)
|
|
364
|
+
finally:
|
|
365
|
+
# Unregister connection
|
|
366
|
+
await connection_registry.unregister(user_id, connection)
|
|
367
|
+
|
|
368
|
+
if session:
|
|
369
|
+
session_service.disconnect_session(session.id)
|
|
370
|
+
logger.info(
|
|
371
|
+
"WebSocket handler finished user_id=%s tab_id=%s session_id=%s",
|
|
372
|
+
user_id,
|
|
373
|
+
tab_id,
|
|
374
|
+
session.session_id if session else None,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
return app
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# Create the app instance
|
|
381
|
+
app = create_app()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Application layer - use cases and orchestration."""
|