ptn 0.1.0__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 +315 -0
- porterminal/application/__init__.py +1 -0
- porterminal/application/ports/__init__.py +9 -0
- porterminal/application/ports/config_port.py +42 -0
- porterminal/application/ports/connection_port.py +34 -0
- porterminal/application/services/__init__.py +9 -0
- porterminal/application/services/session_service.py +240 -0
- porterminal/application/services/terminal_service.py +305 -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 +172 -0
- porterminal/config.py +202 -0
- porterminal/container.py +50 -0
- porterminal/domain/__init__.py +77 -0
- porterminal/domain/entities/__init__.py +13 -0
- porterminal/domain/entities/output_buffer.py +73 -0
- porterminal/domain/entities/session.py +86 -0
- porterminal/domain/ports/__init__.py +10 -0
- porterminal/domain/ports/pty_port.py +80 -0
- porterminal/domain/ports/session_repository.py +58 -0
- porterminal/domain/services/__init__.py +14 -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/values/__init__.py +23 -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/terminal_dimensions.py +45 -0
- porterminal/domain/values/user_id.py +25 -0
- porterminal/infrastructure/__init__.py +16 -0
- porterminal/infrastructure/cloudflared.py +222 -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 +58 -0
- porterminal/infrastructure/repositories/__init__.py +7 -0
- porterminal/infrastructure/repositories/in_memory_session.py +70 -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 +105 -0
- porterminal/pty/env.py +97 -0
- porterminal/pty/fake.py +117 -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-BS392zlj.css +32 -0
- porterminal/static/assets/app-BSV5CpnM.css +32 -0
- porterminal/static/assets/app-C6UZmnjW.js +70 -0
- porterminal/static/assets/app-CjDvandz.js +70 -0
- porterminal/static/assets/app-D8Zu184y.js +70 -0
- porterminal/static/assets/app-D8kJUFHo.js +70 -0
- porterminal/static/assets/app-sDllFolD.js +70 -0
- porterminal/static/icon.svg +34 -0
- porterminal/static/index.html +121 -0
- porterminal/static/manifest.json +31 -0
- porterminal/static/sw.js +72 -0
- porterminal/updater.py +257 -0
- ptn-0.1.0.dist-info/METADATA +139 -0
- ptn-0.1.0.dist-info/RECORD +71 -0
- ptn-0.1.0.dist-info/WHEEL +4 -0
- ptn-0.1.0.dist-info/entry_points.txt +2 -0
- ptn-0.1.0.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.0"
|
|
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("[red]Error:[/red] Failed to install cloudflared")
|
|
179
|
+
console.print()
|
|
180
|
+
console.print("Install manually: [cyan]winget install cloudflare.cloudflared[/cyan]")
|
|
181
|
+
return 1
|
|
182
|
+
# Verify installation
|
|
183
|
+
if not CloudflaredInstaller.is_installed():
|
|
184
|
+
console.print("[red]Error:[/red] cloudflared still not found after install")
|
|
185
|
+
return 1
|
|
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,315 @@
|
|
|
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 SessionId, TerminalDimensions, 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
|
+
await container.session_service.start()
|
|
61
|
+
|
|
62
|
+
logger.info("Porterminal server started")
|
|
63
|
+
|
|
64
|
+
yield
|
|
65
|
+
|
|
66
|
+
# Shutdown
|
|
67
|
+
await container.session_service.stop()
|
|
68
|
+
logger.info("Porterminal server stopped")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def create_app() -> FastAPI:
|
|
72
|
+
"""Create and configure the FastAPI application."""
|
|
73
|
+
app = FastAPI(
|
|
74
|
+
title="Porterminal",
|
|
75
|
+
description="Web-based terminal accessible from phone via Cloudflare Tunnel",
|
|
76
|
+
version="0.1.0",
|
|
77
|
+
lifespan=lifespan,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
@app.middleware("http")
|
|
81
|
+
async def no_cache_static_assets(
|
|
82
|
+
request: Request, call_next: RequestResponseEndpoint
|
|
83
|
+
) -> Response:
|
|
84
|
+
"""Disable caching for static assets to ensure live updates during development."""
|
|
85
|
+
response = await call_next(request)
|
|
86
|
+
if request.url.path.startswith("/static/"):
|
|
87
|
+
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
|
88
|
+
response.headers["Pragma"] = "no-cache"
|
|
89
|
+
response.headers["Expires"] = "0"
|
|
90
|
+
return response
|
|
91
|
+
|
|
92
|
+
# Mount static files
|
|
93
|
+
if STATIC_DIR.exists():
|
|
94
|
+
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
|
95
|
+
|
|
96
|
+
@app.get("/", response_class=HTMLResponse)
|
|
97
|
+
async def index():
|
|
98
|
+
"""Serve the main page."""
|
|
99
|
+
index_path = STATIC_DIR / "index.html"
|
|
100
|
+
if index_path.exists():
|
|
101
|
+
content = index_path.read_text(encoding="utf-8")
|
|
102
|
+
return HTMLResponse(
|
|
103
|
+
content=content,
|
|
104
|
+
headers={
|
|
105
|
+
"Cache-Control": "no-cache, no-store, must-revalidate",
|
|
106
|
+
"Pragma": "no-cache",
|
|
107
|
+
"Expires": "0",
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
return JSONResponse(
|
|
111
|
+
{"error": "index.html not found"},
|
|
112
|
+
status_code=404,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
@app.get("/health")
|
|
116
|
+
async def health():
|
|
117
|
+
"""Health check endpoint."""
|
|
118
|
+
container: Container = app.state.container
|
|
119
|
+
return {
|
|
120
|
+
"status": "healthy",
|
|
121
|
+
"sessions": container.session_service.session_count(),
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
@app.get("/api/config")
|
|
125
|
+
async def get_client_config():
|
|
126
|
+
"""Get client configuration (shells and buttons)."""
|
|
127
|
+
container: Container = app.state.container
|
|
128
|
+
return {
|
|
129
|
+
"shells": [{"id": s.id, "name": s.name} for s in container.available_shells],
|
|
130
|
+
"buttons": container.buttons,
|
|
131
|
+
"default_shell": container.default_shell_id,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
@app.post("/api/config/reload")
|
|
135
|
+
async def reload_configuration():
|
|
136
|
+
"""Reload configuration from file.
|
|
137
|
+
|
|
138
|
+
Note: With the new DI architecture, hot-reload requires server restart.
|
|
139
|
+
"""
|
|
140
|
+
return JSONResponse(
|
|
141
|
+
{"status": "info", "message": "Config reload requires server restart"},
|
|
142
|
+
status_code=501,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
@app.post("/api/shutdown")
|
|
146
|
+
async def shutdown_server(request: Request):
|
|
147
|
+
"""Shutdown the server and tunnel.
|
|
148
|
+
|
|
149
|
+
Only allowed from localhost or authenticated Cloudflare Access users.
|
|
150
|
+
"""
|
|
151
|
+
# Check if request is from localhost
|
|
152
|
+
client_host = request.client.host if request.client else None
|
|
153
|
+
is_localhost = client_host in ("127.0.0.1", "::1", "localhost")
|
|
154
|
+
|
|
155
|
+
# Check for Cloudflare Access authentication
|
|
156
|
+
cf_user = request.headers.get("cf-access-authenticated-user-email")
|
|
157
|
+
|
|
158
|
+
if not is_localhost and not cf_user:
|
|
159
|
+
logger.warning(
|
|
160
|
+
"Unauthorized shutdown attempt from %s",
|
|
161
|
+
client_host,
|
|
162
|
+
)
|
|
163
|
+
return JSONResponse(
|
|
164
|
+
{
|
|
165
|
+
"error": "Unauthorized - must be localhost or authenticated via Cloudflare Access"
|
|
166
|
+
},
|
|
167
|
+
status_code=403,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
logger.info("Shutdown requested via API by %s", cf_user or client_host)
|
|
171
|
+
|
|
172
|
+
# Send response before shutting down
|
|
173
|
+
asyncio.get_running_loop().call_later(0.5, lambda: os.kill(os.getpid(), signal.SIGTERM))
|
|
174
|
+
|
|
175
|
+
return {"status": "ok", "message": "Server shutting down..."}
|
|
176
|
+
|
|
177
|
+
@app.websocket("/ws")
|
|
178
|
+
async def websocket_terminal(
|
|
179
|
+
websocket: WebSocket,
|
|
180
|
+
session_id: str | None = Query(None),
|
|
181
|
+
shell: str | None = Query(None),
|
|
182
|
+
skip_buffer: str | None = Query(None),
|
|
183
|
+
):
|
|
184
|
+
"""WebSocket endpoint for terminal communication."""
|
|
185
|
+
logger.info(
|
|
186
|
+
"WebSocket connect attempt client=%s session_id=%s shell=%s skip_buffer=%s",
|
|
187
|
+
getattr(websocket.client, "host", None),
|
|
188
|
+
session_id,
|
|
189
|
+
shell,
|
|
190
|
+
skip_buffer,
|
|
191
|
+
)
|
|
192
|
+
# Accept the connection
|
|
193
|
+
await websocket.accept()
|
|
194
|
+
|
|
195
|
+
# Get dependencies from container
|
|
196
|
+
container: Container = websocket.app.state.container
|
|
197
|
+
session_service = container.session_service
|
|
198
|
+
terminal_service = container.terminal_service
|
|
199
|
+
|
|
200
|
+
# Get user ID from headers (Cloudflare Access)
|
|
201
|
+
# For local testing, use a default user
|
|
202
|
+
user_id = UserId(websocket.headers.get("cf-access-authenticated-user-email", "local-user"))
|
|
203
|
+
connection = FastAPIWebSocketAdapter(websocket)
|
|
204
|
+
session = None
|
|
205
|
+
|
|
206
|
+
logger.info(
|
|
207
|
+
"WebSocket accepted client=%s user_id=%s session_id=%s shell=%s",
|
|
208
|
+
getattr(websocket.client, "host", None),
|
|
209
|
+
user_id,
|
|
210
|
+
session_id,
|
|
211
|
+
shell,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
if session_id:
|
|
216
|
+
# Reconnect to existing session
|
|
217
|
+
logger.info(
|
|
218
|
+
"WebSocket reconnect requested user_id=%s session_id=%s", user_id, session_id
|
|
219
|
+
)
|
|
220
|
+
session = await session_service.reconnect_session(SessionId(session_id), user_id)
|
|
221
|
+
if not session:
|
|
222
|
+
await connection.send_message(
|
|
223
|
+
{
|
|
224
|
+
"type": "error",
|
|
225
|
+
"message": "Session not found or unauthorized",
|
|
226
|
+
}
|
|
227
|
+
)
|
|
228
|
+
await connection.close(code=4004)
|
|
229
|
+
logger.warning(
|
|
230
|
+
"WebSocket reconnect denied user_id=%s session_id=%s", user_id, session_id
|
|
231
|
+
)
|
|
232
|
+
return
|
|
233
|
+
# reconnect_session already calls add_client()
|
|
234
|
+
else:
|
|
235
|
+
# Try to auto-join existing session first (for device switching)
|
|
236
|
+
session = session_service.get_active_session(user_id)
|
|
237
|
+
if session:
|
|
238
|
+
# Join existing session
|
|
239
|
+
session.add_client()
|
|
240
|
+
logger.info(
|
|
241
|
+
"WebSocket auto-joined existing session user_id=%s session_id=%s client_count=%d",
|
|
242
|
+
user_id,
|
|
243
|
+
session.session_id,
|
|
244
|
+
session.connected_clients,
|
|
245
|
+
)
|
|
246
|
+
else:
|
|
247
|
+
# Create new session
|
|
248
|
+
shell_cmd = container.get_shell(shell)
|
|
249
|
+
if not shell_cmd:
|
|
250
|
+
await connection.send_message(
|
|
251
|
+
{"type": "error", "message": "No shell available"}
|
|
252
|
+
)
|
|
253
|
+
await connection.close(code=4006)
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
dims = TerminalDimensions(container.default_cols, container.default_rows)
|
|
257
|
+
|
|
258
|
+
logger.info(
|
|
259
|
+
"WebSocket creating new session user_id=%s shell=%s cols=%d rows=%d",
|
|
260
|
+
user_id,
|
|
261
|
+
shell_cmd.id,
|
|
262
|
+
dims.cols,
|
|
263
|
+
dims.rows,
|
|
264
|
+
)
|
|
265
|
+
session = await session_service.create_session(
|
|
266
|
+
user_id=user_id,
|
|
267
|
+
shell=shell_cmd,
|
|
268
|
+
dimensions=dims,
|
|
269
|
+
)
|
|
270
|
+
session.add_client()
|
|
271
|
+
logger.info(
|
|
272
|
+
"WebSocket created session user_id=%s session_id=%s shell_id=%s",
|
|
273
|
+
user_id,
|
|
274
|
+
session.session_id,
|
|
275
|
+
session.shell_id,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# Handle the session using TerminalService
|
|
279
|
+
await terminal_service.handle_session(
|
|
280
|
+
session, connection, skip_buffer=bool(skip_buffer)
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
except ValueError as e:
|
|
284
|
+
# Session limit exceeded
|
|
285
|
+
await connection.send_message(
|
|
286
|
+
{
|
|
287
|
+
"type": "error",
|
|
288
|
+
"message": str(e),
|
|
289
|
+
}
|
|
290
|
+
)
|
|
291
|
+
await connection.close(code=4005)
|
|
292
|
+
logger.warning("WebSocket session limit user_id=%s error=%s", user_id, e)
|
|
293
|
+
|
|
294
|
+
except WebSocketDisconnect:
|
|
295
|
+
logger.info("Client disconnected user_id=%s", user_id)
|
|
296
|
+
|
|
297
|
+
except Exception:
|
|
298
|
+
logger.exception("WebSocket error user_id=%s", user_id)
|
|
299
|
+
with suppress(Exception):
|
|
300
|
+
await connection.close(code=1011)
|
|
301
|
+
finally:
|
|
302
|
+
if session:
|
|
303
|
+
session_service.disconnect_session(session.id)
|
|
304
|
+
actual_session_id = session_id or (session.session_id if session else None)
|
|
305
|
+
logger.info(
|
|
306
|
+
"WebSocket handler finished user_id=%s session_id=%s",
|
|
307
|
+
user_id,
|
|
308
|
+
actual_session_id,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
return app
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# Create the app instance
|
|
315
|
+
app = create_app()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Application layer - use cases and orchestration."""
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Configuration port - interface for configuration access."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Protocol
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ConfigPort(Protocol):
|
|
7
|
+
"""Protocol for configuration access.
|
|
8
|
+
|
|
9
|
+
Infrastructure layer implements this with actual config loading.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def get_server_host(self) -> str:
|
|
13
|
+
"""Get server bind host."""
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
def get_server_port(self) -> int:
|
|
17
|
+
"""Get server port."""
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
def get_default_cols(self) -> int:
|
|
21
|
+
"""Get default terminal columns."""
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
def get_default_rows(self) -> int:
|
|
25
|
+
"""Get default terminal rows."""
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
def get_default_shell_id(self) -> str:
|
|
29
|
+
"""Get default shell ID."""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
def get_shell_by_id(self, shell_id: str) -> dict[str, Any] | None:
|
|
33
|
+
"""Get shell configuration by ID."""
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
def get_available_shells(self) -> list[dict[str, Any]]:
|
|
37
|
+
"""Get list of available shells."""
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
def get_buttons(self) -> list[dict[str, Any]]:
|
|
41
|
+
"""Get custom button configurations."""
|
|
42
|
+
...
|