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
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""User connection registry for broadcasting tab sync messages."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from porterminal.application.ports import ConnectionPort
|
|
8
|
+
from porterminal.domain import UserId
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class UserConnectionRegistry:
|
|
14
|
+
"""Track all WebSocket connections per user for broadcasting.
|
|
15
|
+
|
|
16
|
+
Enables real-time tab sync across multiple browser windows/devices.
|
|
17
|
+
Thread-safe for async usage.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self._connections: dict[str, set[ConnectionPort]] = {}
|
|
22
|
+
self._lock = asyncio.Lock()
|
|
23
|
+
|
|
24
|
+
async def register(self, user_id: UserId, connection: ConnectionPort) -> None:
|
|
25
|
+
"""Register a new connection for a user."""
|
|
26
|
+
user_str = str(user_id)
|
|
27
|
+
async with self._lock:
|
|
28
|
+
if user_str not in self._connections:
|
|
29
|
+
self._connections[user_str] = set()
|
|
30
|
+
self._connections[user_str].add(connection)
|
|
31
|
+
logger.debug(
|
|
32
|
+
"Connection registered user_id=%s total=%d",
|
|
33
|
+
user_str,
|
|
34
|
+
len(self._connections[user_str]),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
async def unregister(self, user_id: UserId, connection: ConnectionPort) -> None:
|
|
38
|
+
"""Unregister a connection."""
|
|
39
|
+
user_str = str(user_id)
|
|
40
|
+
async with self._lock:
|
|
41
|
+
if user_str in self._connections:
|
|
42
|
+
self._connections[user_str].discard(connection)
|
|
43
|
+
if not self._connections[user_str]:
|
|
44
|
+
del self._connections[user_str]
|
|
45
|
+
logger.debug("Connection unregistered user_id=%s", user_str)
|
|
46
|
+
|
|
47
|
+
async def broadcast(
|
|
48
|
+
self,
|
|
49
|
+
user_id: UserId,
|
|
50
|
+
message: dict[str, Any],
|
|
51
|
+
exclude: ConnectionPort | None = None,
|
|
52
|
+
) -> int:
|
|
53
|
+
"""Send message to all connections for a user.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
user_id: User to broadcast to.
|
|
57
|
+
message: Message dict to send.
|
|
58
|
+
exclude: Optional connection to exclude (e.g., the sender).
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Number of connections sent to.
|
|
62
|
+
"""
|
|
63
|
+
user_str = str(user_id)
|
|
64
|
+
|
|
65
|
+
async with self._lock:
|
|
66
|
+
connections = self._connections.get(user_str, set()).copy()
|
|
67
|
+
|
|
68
|
+
if exclude:
|
|
69
|
+
connections.discard(exclude)
|
|
70
|
+
|
|
71
|
+
if not connections:
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
# Send in parallel
|
|
75
|
+
async def send_one(conn: ConnectionPort) -> bool:
|
|
76
|
+
try:
|
|
77
|
+
await conn.send_message(message)
|
|
78
|
+
return True
|
|
79
|
+
except Exception as e:
|
|
80
|
+
logger.warning("Failed to broadcast to connection: %s", e)
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
results = await asyncio.gather(
|
|
84
|
+
*[send_one(conn) for conn in connections],
|
|
85
|
+
return_exceptions=True,
|
|
86
|
+
)
|
|
87
|
+
count = sum(1 for r in results if r is True)
|
|
88
|
+
|
|
89
|
+
logger.debug(
|
|
90
|
+
"Broadcast to user_id=%s sent=%d/%d type=%s",
|
|
91
|
+
user_str,
|
|
92
|
+
count,
|
|
93
|
+
len(connections),
|
|
94
|
+
message.get("type"),
|
|
95
|
+
)
|
|
96
|
+
return count
|
|
97
|
+
|
|
98
|
+
def connection_count(self, user_id: UserId) -> int:
|
|
99
|
+
"""Get number of connections for a user."""
|
|
100
|
+
return len(self._connections.get(str(user_id), set()))
|
|
101
|
+
|
|
102
|
+
def total_connections(self) -> int:
|
|
103
|
+
"""Get total number of connections across all users."""
|
|
104
|
+
return sum(len(conns) for conns in self._connections.values())
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""In-memory session repository implementation."""
|
|
2
|
+
|
|
3
|
+
from typing import TypeVar
|
|
4
|
+
|
|
5
|
+
from porterminal.domain import Session, SessionId, UserId
|
|
6
|
+
from porterminal.domain.ports import SessionRepository
|
|
7
|
+
|
|
8
|
+
PTYHandle = TypeVar("PTYHandle")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class InMemorySessionRepository(SessionRepository[PTYHandle]):
|
|
12
|
+
"""In-memory session storage implementing SessionRepository.
|
|
13
|
+
|
|
14
|
+
Thread-safe for async usage (dict operations are atomic in CPython).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self._sessions: dict[str, Session[PTYHandle]] = {}
|
|
19
|
+
self._user_sessions: dict[str, set[str]] = {}
|
|
20
|
+
|
|
21
|
+
def get(self, session_id: SessionId) -> Session[PTYHandle] | None:
|
|
22
|
+
"""Get session by ID."""
|
|
23
|
+
return self._sessions.get(str(session_id))
|
|
24
|
+
|
|
25
|
+
def get_by_id_str(self, session_id: str) -> Session[PTYHandle] | None:
|
|
26
|
+
"""Get session by ID string."""
|
|
27
|
+
return self._sessions.get(session_id)
|
|
28
|
+
|
|
29
|
+
def get_by_user(self, user_id: UserId) -> list[Session[PTYHandle]]:
|
|
30
|
+
"""Get all sessions for a user."""
|
|
31
|
+
user_str = str(user_id)
|
|
32
|
+
session_ids = self._user_sessions.get(user_str, set())
|
|
33
|
+
return [self._sessions[sid] for sid in session_ids if sid in self._sessions]
|
|
34
|
+
|
|
35
|
+
def add(self, session: Session[PTYHandle]) -> None:
|
|
36
|
+
"""Add a new session."""
|
|
37
|
+
session_id = str(session.id)
|
|
38
|
+
user_id = str(session.user_id)
|
|
39
|
+
|
|
40
|
+
self._sessions[session_id] = session
|
|
41
|
+
|
|
42
|
+
if user_id not in self._user_sessions:
|
|
43
|
+
self._user_sessions[user_id] = set()
|
|
44
|
+
self._user_sessions[user_id].add(session_id)
|
|
45
|
+
|
|
46
|
+
def remove(self, session_id: SessionId) -> Session[PTYHandle] | None:
|
|
47
|
+
"""Remove and return a session."""
|
|
48
|
+
session_id_str = str(session_id)
|
|
49
|
+
session = self._sessions.pop(session_id_str, None)
|
|
50
|
+
|
|
51
|
+
if session:
|
|
52
|
+
user_id = str(session.user_id)
|
|
53
|
+
if user_id in self._user_sessions:
|
|
54
|
+
self._user_sessions[user_id].discard(session_id_str)
|
|
55
|
+
if not self._user_sessions[user_id]:
|
|
56
|
+
del self._user_sessions[user_id]
|
|
57
|
+
|
|
58
|
+
return session
|
|
59
|
+
|
|
60
|
+
def count(self) -> int:
|
|
61
|
+
"""Get total session count."""
|
|
62
|
+
return len(self._sessions)
|
|
63
|
+
|
|
64
|
+
def count_for_user(self, user_id: UserId) -> int:
|
|
65
|
+
"""Get session count for a user."""
|
|
66
|
+
return len(self._user_sessions.get(str(user_id), set()))
|
|
67
|
+
|
|
68
|
+
def all_sessions(self) -> list[Session[PTYHandle]]:
|
|
69
|
+
"""Get all sessions."""
|
|
70
|
+
return list(self._sessions.values())
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""In-memory tab repository implementation."""
|
|
2
|
+
|
|
3
|
+
from porterminal.domain import SessionId, Tab, TabId, UserId
|
|
4
|
+
from porterminal.domain.ports import TabRepository
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class InMemoryTabRepository(TabRepository):
|
|
8
|
+
"""In-memory tab storage implementing TabRepository.
|
|
9
|
+
|
|
10
|
+
Thread-safe for async usage (dict operations are atomic in CPython).
|
|
11
|
+
Uses dual-indexing for efficient lookups by user and session.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self) -> None:
|
|
15
|
+
self._tabs: dict[str, Tab] = {}
|
|
16
|
+
self._user_tabs: dict[str, set[str]] = {} # user_id -> {tab_ids}
|
|
17
|
+
self._session_tabs: dict[str, set[str]] = {} # session_id -> {tab_ids}
|
|
18
|
+
|
|
19
|
+
def get(self, tab_id: TabId) -> Tab | None:
|
|
20
|
+
"""Get tab by ID."""
|
|
21
|
+
return self._tabs.get(str(tab_id))
|
|
22
|
+
|
|
23
|
+
def get_by_id_str(self, tab_id: str) -> Tab | None:
|
|
24
|
+
"""Get tab by ID string."""
|
|
25
|
+
return self._tabs.get(tab_id)
|
|
26
|
+
|
|
27
|
+
def get_by_user(self, user_id: UserId) -> list[Tab]:
|
|
28
|
+
"""Get all tabs for a user (ordered by created_at ASC)."""
|
|
29
|
+
user_str = str(user_id)
|
|
30
|
+
tab_ids = self._user_tabs.get(user_str, set())
|
|
31
|
+
tabs = [self._tabs[tid] for tid in tab_ids if tid in self._tabs]
|
|
32
|
+
return sorted(tabs, key=lambda t: t.created_at)
|
|
33
|
+
|
|
34
|
+
def get_by_session(self, session_id: SessionId) -> list[Tab]:
|
|
35
|
+
"""Get all tabs referencing a specific session."""
|
|
36
|
+
session_str = str(session_id)
|
|
37
|
+
tab_ids = self._session_tabs.get(session_str, set())
|
|
38
|
+
return [self._tabs[tid] for tid in tab_ids if tid in self._tabs]
|
|
39
|
+
|
|
40
|
+
def add(self, tab: Tab) -> None:
|
|
41
|
+
"""Add a new tab."""
|
|
42
|
+
tab_id = str(tab.id)
|
|
43
|
+
user_id = str(tab.user_id)
|
|
44
|
+
session_id = str(tab.session_id)
|
|
45
|
+
|
|
46
|
+
self._tabs[tab_id] = tab
|
|
47
|
+
|
|
48
|
+
# Index by user
|
|
49
|
+
if user_id not in self._user_tabs:
|
|
50
|
+
self._user_tabs[user_id] = set()
|
|
51
|
+
self._user_tabs[user_id].add(tab_id)
|
|
52
|
+
|
|
53
|
+
# Index by session
|
|
54
|
+
if session_id not in self._session_tabs:
|
|
55
|
+
self._session_tabs[session_id] = set()
|
|
56
|
+
self._session_tabs[session_id].add(tab_id)
|
|
57
|
+
|
|
58
|
+
def update(self, tab: Tab) -> None:
|
|
59
|
+
"""Update an existing tab (name, last_accessed)."""
|
|
60
|
+
tab_id = str(tab.id)
|
|
61
|
+
if tab_id in self._tabs:
|
|
62
|
+
self._tabs[tab_id] = tab
|
|
63
|
+
|
|
64
|
+
def remove(self, tab_id: TabId) -> Tab | None:
|
|
65
|
+
"""Remove and return a tab."""
|
|
66
|
+
tab_id_str = str(tab_id)
|
|
67
|
+
tab = self._tabs.pop(tab_id_str, None)
|
|
68
|
+
|
|
69
|
+
if tab:
|
|
70
|
+
user_id = str(tab.user_id)
|
|
71
|
+
session_id = str(tab.session_id)
|
|
72
|
+
|
|
73
|
+
# Clean up user index
|
|
74
|
+
if user_id in self._user_tabs:
|
|
75
|
+
self._user_tabs[user_id].discard(tab_id_str)
|
|
76
|
+
if not self._user_tabs[user_id]:
|
|
77
|
+
del self._user_tabs[user_id]
|
|
78
|
+
|
|
79
|
+
# Clean up session index
|
|
80
|
+
if session_id in self._session_tabs:
|
|
81
|
+
self._session_tabs[session_id].discard(tab_id_str)
|
|
82
|
+
if not self._session_tabs[session_id]:
|
|
83
|
+
del self._session_tabs[session_id]
|
|
84
|
+
|
|
85
|
+
return tab
|
|
86
|
+
|
|
87
|
+
def remove_by_session(self, session_id: SessionId) -> list[Tab]:
|
|
88
|
+
"""Remove all tabs referencing a session (cascade).
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
List of removed tabs.
|
|
92
|
+
"""
|
|
93
|
+
session_str = str(session_id)
|
|
94
|
+
tab_ids = list(self._session_tabs.get(session_str, set()))
|
|
95
|
+
|
|
96
|
+
removed = []
|
|
97
|
+
for tab_id_str in tab_ids:
|
|
98
|
+
tab = self._tabs.pop(tab_id_str, None)
|
|
99
|
+
if tab:
|
|
100
|
+
removed.append(tab)
|
|
101
|
+
# Clean up user index
|
|
102
|
+
user_id = str(tab.user_id)
|
|
103
|
+
if user_id in self._user_tabs:
|
|
104
|
+
self._user_tabs[user_id].discard(tab_id_str)
|
|
105
|
+
if not self._user_tabs[user_id]:
|
|
106
|
+
del self._user_tabs[user_id]
|
|
107
|
+
|
|
108
|
+
# Clean up session index
|
|
109
|
+
if session_str in self._session_tabs:
|
|
110
|
+
del self._session_tabs[session_str]
|
|
111
|
+
|
|
112
|
+
return removed
|
|
113
|
+
|
|
114
|
+
def count(self) -> int:
|
|
115
|
+
"""Get total tab count."""
|
|
116
|
+
return len(self._tabs)
|
|
117
|
+
|
|
118
|
+
def count_for_user(self, user_id: UserId) -> int:
|
|
119
|
+
"""Get tab count for a user."""
|
|
120
|
+
return len(self._user_tabs.get(str(user_id), set()))
|
|
121
|
+
|
|
122
|
+
def all_tabs(self) -> list[Tab]:
|
|
123
|
+
"""Get all tabs."""
|
|
124
|
+
return list(self._tabs.values())
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Server and tunnel management utilities."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
import urllib.request
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def wait_for_server(host: str, port: int, timeout: int = 30) -> bool:
|
|
17
|
+
"""Wait for the server to be ready and verify it's Porterminal.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
host: Server host address.
|
|
21
|
+
port: Server port number.
|
|
22
|
+
timeout: Maximum seconds to wait.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
True if server is ready, False otherwise.
|
|
26
|
+
"""
|
|
27
|
+
start_time = time.time()
|
|
28
|
+
url = f"http://{host}:{port}/health"
|
|
29
|
+
|
|
30
|
+
while time.time() - start_time < timeout:
|
|
31
|
+
try:
|
|
32
|
+
with urllib.request.urlopen(url, timeout=2) as response:
|
|
33
|
+
if response.status == 200:
|
|
34
|
+
# Verify it's actually Porterminal by checking response structure
|
|
35
|
+
try:
|
|
36
|
+
data = json.loads(response.read().decode("utf-8"))
|
|
37
|
+
if data.get("status") == "healthy" and "sessions" in data:
|
|
38
|
+
return True
|
|
39
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
40
|
+
pass
|
|
41
|
+
except (urllib.error.URLError, OSError, TimeoutError):
|
|
42
|
+
pass
|
|
43
|
+
time.sleep(0.5)
|
|
44
|
+
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def start_server(host: str, port: int, *, verbose: bool = False) -> subprocess.Popen:
|
|
49
|
+
"""Start the uvicorn server.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
host: Host address to bind.
|
|
53
|
+
port: Port number to bind.
|
|
54
|
+
verbose: If True, show server logs directly.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Popen process object for the server.
|
|
58
|
+
"""
|
|
59
|
+
cmd = [
|
|
60
|
+
sys.executable,
|
|
61
|
+
"-m",
|
|
62
|
+
"uvicorn",
|
|
63
|
+
"porterminal.app:app",
|
|
64
|
+
"--host",
|
|
65
|
+
host,
|
|
66
|
+
"--port",
|
|
67
|
+
str(port),
|
|
68
|
+
"--log-level",
|
|
69
|
+
"debug" if verbose else "warning",
|
|
70
|
+
"--no-access-log", # Disable access logging
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
if verbose:
|
|
74
|
+
# Let output go directly to console
|
|
75
|
+
process = subprocess.Popen(cmd)
|
|
76
|
+
else:
|
|
77
|
+
process = subprocess.Popen(
|
|
78
|
+
cmd,
|
|
79
|
+
stdout=subprocess.PIPE,
|
|
80
|
+
stderr=subprocess.STDOUT,
|
|
81
|
+
text=True,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return process
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def start_cloudflared(port: int) -> tuple[subprocess.Popen, str | None]:
|
|
88
|
+
"""Start cloudflared tunnel and return the URL.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
port: Local port to tunnel.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Tuple of (process, url) where url may be None if tunnel failed.
|
|
95
|
+
"""
|
|
96
|
+
cmd = [
|
|
97
|
+
"cloudflared",
|
|
98
|
+
"tunnel",
|
|
99
|
+
"--no-autoupdate",
|
|
100
|
+
"--protocol",
|
|
101
|
+
"http2", # Use HTTP/2 instead of QUIC (more reliable on Windows)
|
|
102
|
+
"--config",
|
|
103
|
+
os.devnull, # Ignore any config files (cross-platform)
|
|
104
|
+
"--origincert",
|
|
105
|
+
os.devnull, # Skip origin certificate (cross-platform)
|
|
106
|
+
"--url",
|
|
107
|
+
f"http://127.0.0.1:{port}", # Use 127.0.0.1 explicitly
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
# Clear cloudflared config to ensure clean quick tunnel
|
|
111
|
+
env = os.environ.copy()
|
|
112
|
+
env["TUNNEL_ORIGIN_CERT"] = ""
|
|
113
|
+
env["NO_AUTOUPDATE"] = "true"
|
|
114
|
+
# Point to a non-existent config to force quick tunnel mode
|
|
115
|
+
env["TUNNEL_CONFIG"] = os.devnull
|
|
116
|
+
|
|
117
|
+
process = subprocess.Popen(
|
|
118
|
+
cmd,
|
|
119
|
+
stdout=subprocess.PIPE,
|
|
120
|
+
stderr=subprocess.STDOUT,
|
|
121
|
+
text=True,
|
|
122
|
+
bufsize=1,
|
|
123
|
+
env=env,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Parse URL from cloudflared output (flexible pattern for different Cloudflare domains)
|
|
127
|
+
url_pattern = re.compile(r"https://[a-z0-9-]+\.(trycloudflare\.com|cloudflare-tunnel\.com)")
|
|
128
|
+
url = None
|
|
129
|
+
|
|
130
|
+
# Read output until we find the URL
|
|
131
|
+
for line in iter(process.stdout.readline, ""):
|
|
132
|
+
if process.poll() is not None:
|
|
133
|
+
break
|
|
134
|
+
|
|
135
|
+
match = url_pattern.search(line)
|
|
136
|
+
if match:
|
|
137
|
+
url = match.group(0)
|
|
138
|
+
break
|
|
139
|
+
|
|
140
|
+
# Also check for errors
|
|
141
|
+
if "error" in line.lower():
|
|
142
|
+
console.print(f"[red]Cloudflared error:[/red] {line.strip()}")
|
|
143
|
+
|
|
144
|
+
return process, url
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def drain_process_output(process: subprocess.Popen) -> None:
|
|
148
|
+
"""Drain process output silently (only print errors).
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
process: Subprocess to drain output from.
|
|
152
|
+
"""
|
|
153
|
+
try:
|
|
154
|
+
for line in iter(process.stdout.readline, ""):
|
|
155
|
+
if not line:
|
|
156
|
+
break
|
|
157
|
+
line = line.strip()
|
|
158
|
+
if line and "error" in line.lower():
|
|
159
|
+
console.print(f"[red]{line}[/red]")
|
|
160
|
+
except (OSError, ValueError):
|
|
161
|
+
pass
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""FastAPI WebSocket adapter implementing ConnectionPort."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from fastapi import WebSocket
|
|
7
|
+
from starlette.websockets import WebSocketDisconnect
|
|
8
|
+
|
|
9
|
+
from porterminal.application.ports import ConnectionPort
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FastAPIWebSocketAdapter(ConnectionPort):
|
|
13
|
+
"""Adapts FastAPI WebSocket to ConnectionPort protocol."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, websocket: WebSocket) -> None:
|
|
16
|
+
self._websocket = websocket
|
|
17
|
+
self._closed = False
|
|
18
|
+
|
|
19
|
+
async def send_output(self, data: bytes) -> None:
|
|
20
|
+
"""Send terminal output to client."""
|
|
21
|
+
if not self._closed:
|
|
22
|
+
try:
|
|
23
|
+
await self._websocket.send_bytes(data)
|
|
24
|
+
except Exception:
|
|
25
|
+
self._closed = True
|
|
26
|
+
|
|
27
|
+
async def send_message(self, message: dict[str, Any]) -> None:
|
|
28
|
+
"""Send JSON control message to client."""
|
|
29
|
+
if not self._closed:
|
|
30
|
+
try:
|
|
31
|
+
await self._websocket.send_json(message)
|
|
32
|
+
except Exception:
|
|
33
|
+
self._closed = True
|
|
34
|
+
|
|
35
|
+
async def receive(self) -> dict[str, Any] | bytes:
|
|
36
|
+
"""Receive message from client (binary or JSON).
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
bytes for terminal input, dict for control messages.
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
WebSocketDisconnect: If connection is closed.
|
|
43
|
+
"""
|
|
44
|
+
try:
|
|
45
|
+
message = await self._websocket.receive()
|
|
46
|
+
except WebSocketDisconnect:
|
|
47
|
+
self._closed = True
|
|
48
|
+
raise
|
|
49
|
+
|
|
50
|
+
if message.get("bytes"):
|
|
51
|
+
return message["bytes"]
|
|
52
|
+
elif message.get("text"):
|
|
53
|
+
return json.loads(message["text"])
|
|
54
|
+
|
|
55
|
+
# Handle disconnect message
|
|
56
|
+
if message.get("type") == "websocket.disconnect":
|
|
57
|
+
self._closed = True
|
|
58
|
+
raise WebSocketDisconnect()
|
|
59
|
+
|
|
60
|
+
raise ValueError(f"Unknown message type: {message.get('type')}")
|
|
61
|
+
|
|
62
|
+
async def close(self, code: int = 1000, reason: str = "") -> None:
|
|
63
|
+
"""Close the connection."""
|
|
64
|
+
if not self._closed:
|
|
65
|
+
self._closed = True
|
|
66
|
+
try:
|
|
67
|
+
await self._websocket.close(code=code, reason=reason)
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
def is_connected(self) -> bool:
|
|
72
|
+
"""Check if connection is still open."""
|
|
73
|
+
return not self._closed
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def websocket(self) -> WebSocket:
|
|
77
|
+
"""Get underlying WebSocket (for compatibility)."""
|
|
78
|
+
return self._websocket
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Logging setup for the Porterminal server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CleanFormatter(logging.Formatter):
|
|
10
|
+
"""Clean log formatter with minimal output."""
|
|
11
|
+
|
|
12
|
+
FORMATS = {
|
|
13
|
+
logging.DEBUG: "\033[90m[DEBUG]\033[0m %(message)s",
|
|
14
|
+
logging.INFO: "\033[36m[INFO]\033[0m %(message)s",
|
|
15
|
+
logging.WARNING: "\033[33m[WARN]\033[0m %(message)s",
|
|
16
|
+
logging.ERROR: "\033[31m[ERROR]\033[0m %(message)s",
|
|
17
|
+
logging.CRITICAL: "\033[31;1m[CRIT]\033[0m %(message)s",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
21
|
+
fmt = self.FORMATS.get(record.levelno, "[%(levelname)s] %(message)s")
|
|
22
|
+
formatter = logging.Formatter(fmt)
|
|
23
|
+
return formatter.format(record)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def setup_logging_from_env() -> None:
|
|
27
|
+
"""Configure root logging with clean format."""
|
|
28
|
+
level_name = (os.environ.get("PORTERMINAL_LOG_LEVEL") or "info").upper()
|
|
29
|
+
level = getattr(logging, level_name, logging.INFO)
|
|
30
|
+
|
|
31
|
+
root = logging.getLogger()
|
|
32
|
+
|
|
33
|
+
# Remove existing handlers
|
|
34
|
+
for handler in root.handlers[:]:
|
|
35
|
+
root.removeHandler(handler)
|
|
36
|
+
|
|
37
|
+
# Add clean handler
|
|
38
|
+
handler = logging.StreamHandler()
|
|
39
|
+
handler.setFormatter(CleanFormatter())
|
|
40
|
+
root.addHandler(handler)
|
|
41
|
+
root.setLevel(level)
|
|
42
|
+
|
|
43
|
+
# Set level for app loggers
|
|
44
|
+
logging.getLogger("src").setLevel(level)
|
|
45
|
+
|
|
46
|
+
# Suppress noisy loggers
|
|
47
|
+
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
|
48
|
+
logging.getLogger("uvicorn.error").setLevel(logging.WARNING)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PTY management with cross-platform support.
|
|
3
|
+
|
|
4
|
+
This package provides:
|
|
5
|
+
- PTYBackend Protocol for platform-specific implementations
|
|
6
|
+
- SecurePTYManager for secure PTY management with env sanitization
|
|
7
|
+
- Platform-specific backends (Windows, Unix)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from .env import BLOCKED_ENV_VARS, SAFE_ENV_VARS, build_safe_environment
|
|
13
|
+
from .manager import SecurePTYManager
|
|
14
|
+
from .protocol import PTYBackend
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
# Protocol
|
|
18
|
+
"PTYBackend",
|
|
19
|
+
# Manager
|
|
20
|
+
"SecurePTYManager",
|
|
21
|
+
# Backends
|
|
22
|
+
"create_backend",
|
|
23
|
+
# Environment
|
|
24
|
+
"SAFE_ENV_VARS",
|
|
25
|
+
"BLOCKED_ENV_VARS",
|
|
26
|
+
"build_safe_environment",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def create_backend() -> PTYBackend:
|
|
31
|
+
"""Create platform-appropriate PTY backend.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
PTYBackend instance for the current platform.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
RuntimeError: If no suitable backend is available.
|
|
38
|
+
"""
|
|
39
|
+
if sys.platform == "win32":
|
|
40
|
+
from .windows import WindowsPTYBackend
|
|
41
|
+
|
|
42
|
+
return WindowsPTYBackend()
|
|
43
|
+
else:
|
|
44
|
+
from .unix import UnixPTYBackend
|
|
45
|
+
|
|
46
|
+
return UnixPTYBackend()
|