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.
Files changed (73) hide show
  1. porterminal/__init__.py +288 -0
  2. porterminal/__main__.py +8 -0
  3. porterminal/app.py +381 -0
  4. porterminal/application/__init__.py +1 -0
  5. porterminal/application/ports/__init__.py +7 -0
  6. porterminal/application/ports/connection_port.py +34 -0
  7. porterminal/application/services/__init__.py +13 -0
  8. porterminal/application/services/management_service.py +279 -0
  9. porterminal/application/services/session_service.py +249 -0
  10. porterminal/application/services/tab_service.py +286 -0
  11. porterminal/application/services/terminal_service.py +426 -0
  12. porterminal/asgi.py +38 -0
  13. porterminal/cli/__init__.py +19 -0
  14. porterminal/cli/args.py +91 -0
  15. porterminal/cli/display.py +157 -0
  16. porterminal/composition.py +208 -0
  17. porterminal/config.py +195 -0
  18. porterminal/container.py +65 -0
  19. porterminal/domain/__init__.py +91 -0
  20. porterminal/domain/entities/__init__.py +16 -0
  21. porterminal/domain/entities/output_buffer.py +73 -0
  22. porterminal/domain/entities/session.py +86 -0
  23. porterminal/domain/entities/tab.py +71 -0
  24. porterminal/domain/ports/__init__.py +12 -0
  25. porterminal/domain/ports/pty_port.py +80 -0
  26. porterminal/domain/ports/session_repository.py +58 -0
  27. porterminal/domain/ports/tab_repository.py +75 -0
  28. porterminal/domain/services/__init__.py +18 -0
  29. porterminal/domain/services/environment_sanitizer.py +61 -0
  30. porterminal/domain/services/rate_limiter.py +63 -0
  31. porterminal/domain/services/session_limits.py +104 -0
  32. porterminal/domain/services/tab_limits.py +54 -0
  33. porterminal/domain/values/__init__.py +25 -0
  34. porterminal/domain/values/environment_rules.py +156 -0
  35. porterminal/domain/values/rate_limit_config.py +21 -0
  36. porterminal/domain/values/session_id.py +20 -0
  37. porterminal/domain/values/shell_command.py +37 -0
  38. porterminal/domain/values/tab_id.py +24 -0
  39. porterminal/domain/values/terminal_dimensions.py +45 -0
  40. porterminal/domain/values/user_id.py +25 -0
  41. porterminal/infrastructure/__init__.py +20 -0
  42. porterminal/infrastructure/cloudflared.py +295 -0
  43. porterminal/infrastructure/config/__init__.py +9 -0
  44. porterminal/infrastructure/config/shell_detector.py +84 -0
  45. porterminal/infrastructure/config/yaml_loader.py +34 -0
  46. porterminal/infrastructure/network.py +43 -0
  47. porterminal/infrastructure/registry/__init__.py +5 -0
  48. porterminal/infrastructure/registry/user_connection_registry.py +104 -0
  49. porterminal/infrastructure/repositories/__init__.py +9 -0
  50. porterminal/infrastructure/repositories/in_memory_session.py +70 -0
  51. porterminal/infrastructure/repositories/in_memory_tab.py +124 -0
  52. porterminal/infrastructure/server.py +161 -0
  53. porterminal/infrastructure/web/__init__.py +7 -0
  54. porterminal/infrastructure/web/websocket_adapter.py +78 -0
  55. porterminal/logging_setup.py +48 -0
  56. porterminal/pty/__init__.py +46 -0
  57. porterminal/pty/env.py +97 -0
  58. porterminal/pty/manager.py +163 -0
  59. porterminal/pty/protocol.py +84 -0
  60. porterminal/pty/unix.py +162 -0
  61. porterminal/pty/windows.py +131 -0
  62. porterminal/static/assets/app-BQiuUo6Q.css +32 -0
  63. porterminal/static/assets/app-YNN_jEhv.js +71 -0
  64. porterminal/static/icon.svg +34 -0
  65. porterminal/static/index.html +139 -0
  66. porterminal/static/manifest.json +31 -0
  67. porterminal/static/sw.js +66 -0
  68. porterminal/updater.py +257 -0
  69. ptn-0.1.4.dist-info/METADATA +191 -0
  70. ptn-0.1.4.dist-info/RECORD +73 -0
  71. ptn-0.1.4.dist-info/WHEEL +4 -0
  72. ptn-0.1.4.dist-info/entry_points.txt +2 -0
  73. ptn-0.1.4.dist-info/licenses/LICENSE +661 -0
@@ -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())
@@ -0,0 +1,8 @@
1
+ """Allow running as python -m porterminal."""
2
+
3
+ import sys
4
+
5
+ from porterminal import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
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."""
@@ -0,0 +1,7 @@
1
+ """Application layer ports - interfaces for presentation layer."""
2
+
3
+ from .connection_port import ConnectionPort
4
+
5
+ __all__ = [
6
+ "ConnectionPort",
7
+ ]