ptn 0.2.5__py3-none-any.whl → 0.4.2__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 +63 -11
- porterminal/_version.py +2 -2
- porterminal/app.py +25 -1
- porterminal/application/ports/__init__.py +2 -0
- porterminal/application/ports/connection_registry_port.py +46 -0
- porterminal/application/services/management_service.py +30 -55
- porterminal/application/services/session_service.py +3 -11
- porterminal/application/services/terminal_service.py +97 -56
- porterminal/cli/args.py +91 -30
- porterminal/cli/display.py +18 -16
- porterminal/cli/script_discovery.py +112 -0
- porterminal/composition.py +8 -7
- porterminal/config.py +12 -2
- porterminal/container.py +4 -0
- porterminal/domain/__init__.py +0 -9
- porterminal/domain/entities/output_buffer.py +56 -1
- porterminal/domain/entities/tab.py +11 -10
- porterminal/domain/services/__init__.py +0 -2
- porterminal/domain/values/__init__.py +0 -4
- porterminal/domain/values/environment_rules.py +3 -0
- porterminal/infrastructure/auth.py +131 -0
- porterminal/infrastructure/cloudflared.py +18 -12
- porterminal/infrastructure/config/shell_detector.py +407 -1
- porterminal/infrastructure/repositories/in_memory_session.py +1 -4
- porterminal/infrastructure/repositories/in_memory_tab.py +2 -10
- porterminal/infrastructure/server.py +28 -3
- porterminal/pty/env.py +16 -78
- porterminal/pty/manager.py +6 -4
- porterminal/static/assets/app-DlWNJWFE.js +87 -0
- porterminal/static/assets/app-xPAM7YhQ.css +1 -0
- porterminal/static/index.html +14 -2
- porterminal/updater.py +13 -5
- {ptn-0.2.5.dist-info → ptn-0.4.2.dist-info}/METADATA +84 -23
- {ptn-0.2.5.dist-info → ptn-0.4.2.dist-info}/RECORD +37 -34
- porterminal/static/assets/app-By4EXMHC.js +0 -72
- porterminal/static/assets/app-DQePboVd.css +0 -32
- {ptn-0.2.5.dist-info → ptn-0.4.2.dist-info}/WHEEL +0 -0
- {ptn-0.2.5.dist-info → ptn-0.4.2.dist-info}/entry_points.txt +0 -0
- {ptn-0.2.5.dist-info → ptn-0.4.2.dist-info}/licenses/LICENSE +0 -0
porterminal/__init__.py
CHANGED
|
@@ -14,6 +14,7 @@ except ImportError:
|
|
|
14
14
|
__version__ = "0.0.0-dev" # Fallback before first build
|
|
15
15
|
|
|
16
16
|
import os
|
|
17
|
+
import signal
|
|
17
18
|
import subprocess
|
|
18
19
|
import sys
|
|
19
20
|
import time
|
|
@@ -149,6 +150,30 @@ def main() -> int:
|
|
|
149
150
|
check_and_notify()
|
|
150
151
|
verbose = args.verbose
|
|
151
152
|
|
|
153
|
+
# Load config to check require_password setting
|
|
154
|
+
from porterminal.config import get_config
|
|
155
|
+
|
|
156
|
+
config = get_config()
|
|
157
|
+
|
|
158
|
+
# Handle password mode (CLI flag or config setting)
|
|
159
|
+
if args.password or config.security.require_password:
|
|
160
|
+
import getpass
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
password = getpass.getpass("Enter password: ")
|
|
164
|
+
if not password:
|
|
165
|
+
console.print("[red]Error:[/red] Password cannot be empty")
|
|
166
|
+
return 1
|
|
167
|
+
|
|
168
|
+
import bcrypt
|
|
169
|
+
|
|
170
|
+
password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt())
|
|
171
|
+
os.environ["PORTERMINAL_PASSWORD_HASH"] = password_hash.decode()
|
|
172
|
+
console.print("[green]Password protection enabled[/green]")
|
|
173
|
+
except KeyboardInterrupt:
|
|
174
|
+
console.print("\n[dim]Cancelled[/dim]")
|
|
175
|
+
return 0
|
|
176
|
+
|
|
152
177
|
# Handle background mode
|
|
153
178
|
if args.background:
|
|
154
179
|
return _run_in_background(args)
|
|
@@ -170,9 +195,6 @@ def main() -> int:
|
|
|
170
195
|
cwd_str = str(cwd)
|
|
171
196
|
os.environ["PORTERMINAL_CWD"] = cwd_str
|
|
172
197
|
|
|
173
|
-
from porterminal.config import get_config
|
|
174
|
-
|
|
175
|
-
config = get_config()
|
|
176
198
|
bind_host = config.server.host
|
|
177
199
|
preferred_port = config.server.port
|
|
178
200
|
port = preferred_port
|
|
@@ -226,6 +248,10 @@ def main() -> int:
|
|
|
226
248
|
status.update("[cyan]Establishing tunnel...[/cyan]")
|
|
227
249
|
tunnel_process, tunnel_url = start_cloudflared(port)
|
|
228
250
|
|
|
251
|
+
if tunnel_url:
|
|
252
|
+
# Wait for tunnel to stabilize before showing URL
|
|
253
|
+
time.sleep(1)
|
|
254
|
+
|
|
229
255
|
if not tunnel_url:
|
|
230
256
|
console.print("[red]Error:[/red] Failed to establish tunnel")
|
|
231
257
|
for proc in [server_process, tunnel_process]:
|
|
@@ -287,15 +313,41 @@ def main() -> int:
|
|
|
287
313
|
def cleanup_process(proc: subprocess.Popen | None, name: str) -> None:
|
|
288
314
|
if proc is None or proc.poll() is not None:
|
|
289
315
|
return
|
|
290
|
-
try:
|
|
291
|
-
proc.terminate()
|
|
292
|
-
proc.wait(timeout=5)
|
|
293
|
-
except subprocess.TimeoutExpired:
|
|
294
|
-
proc.kill()
|
|
295
|
-
proc.wait() # Reap the killed process
|
|
296
316
|
|
|
297
|
-
|
|
298
|
-
|
|
317
|
+
if sys.platform == "win32":
|
|
318
|
+
# Windows: use taskkill /T to kill entire process tree
|
|
319
|
+
try:
|
|
320
|
+
subprocess.run(
|
|
321
|
+
["taskkill", "/T", "/F", "/PID", str(proc.pid)],
|
|
322
|
+
capture_output=True,
|
|
323
|
+
timeout=10,
|
|
324
|
+
)
|
|
325
|
+
# Wait for process to actually terminate
|
|
326
|
+
proc.wait(timeout=5)
|
|
327
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
328
|
+
# Last resort: try to kill just the main process
|
|
329
|
+
try:
|
|
330
|
+
proc.kill()
|
|
331
|
+
proc.wait(timeout=2)
|
|
332
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
333
|
+
pass
|
|
334
|
+
else:
|
|
335
|
+
# Unix: terminate gracefully, then kill
|
|
336
|
+
try:
|
|
337
|
+
proc.terminate()
|
|
338
|
+
proc.wait(timeout=5)
|
|
339
|
+
except subprocess.TimeoutExpired:
|
|
340
|
+
proc.kill()
|
|
341
|
+
proc.wait()
|
|
342
|
+
|
|
343
|
+
# Ignore Ctrl+C during cleanup to prevent orphaned processes
|
|
344
|
+
# Cleanup has timeouts so it won't hang forever
|
|
345
|
+
old_handler = signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
346
|
+
try:
|
|
347
|
+
cleanup_process(server_process, "server")
|
|
348
|
+
cleanup_process(tunnel_process, "tunnel")
|
|
349
|
+
finally:
|
|
350
|
+
signal.signal(signal.SIGINT, old_handler)
|
|
299
351
|
|
|
300
352
|
return 0
|
|
301
353
|
|
porterminal/_version.py
CHANGED
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '0.2
|
|
32
|
-
__version_tuple__ = version_tuple = (0,
|
|
31
|
+
__version__ = version = '0.4.2'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 4, 2)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
porterminal/app.py
CHANGED
|
@@ -17,6 +17,7 @@ from . import __version__
|
|
|
17
17
|
from .composition import create_container
|
|
18
18
|
from .container import Container
|
|
19
19
|
from .domain import UserId
|
|
20
|
+
from .infrastructure.auth import authenticate_connection, validate_auth_message
|
|
20
21
|
from .infrastructure.web import FastAPIWebSocketAdapter
|
|
21
22
|
from .logging_setup import setup_logging_from_env
|
|
22
23
|
|
|
@@ -55,7 +56,12 @@ async def lifespan(app: FastAPI):
|
|
|
55
56
|
# config_path=None uses find_config_file() to search standard locations
|
|
56
57
|
cwd = os.environ.get("PORTERMINAL_CWD")
|
|
57
58
|
|
|
58
|
-
|
|
59
|
+
# Get password hash from environment if set
|
|
60
|
+
password_hash = None
|
|
61
|
+
if hash_str := os.environ.get("PORTERMINAL_PASSWORD_HASH"):
|
|
62
|
+
password_hash = hash_str.encode()
|
|
63
|
+
|
|
64
|
+
container = create_container(config_path=None, cwd=cwd, password_hash=password_hash)
|
|
59
65
|
app.state.container = container
|
|
60
66
|
|
|
61
67
|
# Wire up cascade: when session is destroyed, close associated tabs and broadcast
|
|
@@ -225,6 +231,17 @@ def create_app() -> FastAPI:
|
|
|
225
231
|
)
|
|
226
232
|
|
|
227
233
|
try:
|
|
234
|
+
# Authentication phase if password is set
|
|
235
|
+
if container.password_hash is not None:
|
|
236
|
+
authenticated = await authenticate_connection(
|
|
237
|
+
connection,
|
|
238
|
+
container.password_hash,
|
|
239
|
+
max_attempts=container.max_auth_attempts,
|
|
240
|
+
)
|
|
241
|
+
if not authenticated:
|
|
242
|
+
await websocket.close(code=4001, reason="Auth failed")
|
|
243
|
+
return
|
|
244
|
+
|
|
228
245
|
# Register for broadcasts
|
|
229
246
|
await connection_registry.register(user_id, connection)
|
|
230
247
|
|
|
@@ -333,6 +350,13 @@ def create_app() -> FastAPI:
|
|
|
333
350
|
session.session_id,
|
|
334
351
|
)
|
|
335
352
|
|
|
353
|
+
# Authentication check if password is set
|
|
354
|
+
if container.password_hash is not None:
|
|
355
|
+
if not await validate_auth_message(connection, container.password_hash):
|
|
356
|
+
logger.warning("Terminal WebSocket auth failed user_id=%s", user_id)
|
|
357
|
+
await websocket.close(code=4001, reason="Auth failed")
|
|
358
|
+
return
|
|
359
|
+
|
|
336
360
|
# Update tab access time
|
|
337
361
|
tab_service.touch_tab(tab_id, user_id)
|
|
338
362
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Connection registry port - interface for broadcasting to user connections."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from porterminal.domain import UserId
|
|
7
|
+
|
|
8
|
+
from .connection_port import ConnectionPort
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConnectionRegistryPort(Protocol):
|
|
12
|
+
"""Protocol for managing and broadcasting to user connections.
|
|
13
|
+
|
|
14
|
+
Infrastructure layer (e.g., UserConnectionRegistry) implements this.
|
|
15
|
+
Application layer uses this interface for broadcasting messages.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
async def register(self, user_id: "UserId", connection: "ConnectionPort") -> None:
|
|
19
|
+
"""Register a new connection for a user."""
|
|
20
|
+
...
|
|
21
|
+
|
|
22
|
+
async def unregister(self, user_id: "UserId", connection: "ConnectionPort") -> None:
|
|
23
|
+
"""Unregister a connection."""
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
async def broadcast(
|
|
27
|
+
self,
|
|
28
|
+
user_id: "UserId",
|
|
29
|
+
message: dict[str, Any],
|
|
30
|
+
exclude: "ConnectionPort | None" = None,
|
|
31
|
+
) -> int:
|
|
32
|
+
"""Send message to all connections for a user.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
user_id: User to broadcast to.
|
|
36
|
+
message: Message dict to send.
|
|
37
|
+
exclude: Optional connection to exclude (e.g., the sender).
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Number of connections sent to.
|
|
41
|
+
"""
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
def total_connections(self) -> int:
|
|
45
|
+
"""Get total number of connections across all users."""
|
|
46
|
+
...
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import logging
|
|
4
4
|
from collections.abc import Callable
|
|
5
5
|
|
|
6
|
-
from porterminal.application.ports import ConnectionPort
|
|
6
|
+
from porterminal.application.ports import ConnectionPort, ConnectionRegistryPort
|
|
7
7
|
from porterminal.application.services.session_service import SessionService
|
|
8
8
|
from porterminal.application.services.tab_service import TabService
|
|
9
9
|
from porterminal.domain import (
|
|
@@ -11,7 +11,6 @@ from porterminal.domain import (
|
|
|
11
11
|
TerminalDimensions,
|
|
12
12
|
UserId,
|
|
13
13
|
)
|
|
14
|
-
from porterminal.infrastructure.registry import UserConnectionRegistry
|
|
15
14
|
|
|
16
15
|
logger = logging.getLogger(__name__)
|
|
17
16
|
|
|
@@ -27,7 +26,7 @@ class ManagementService:
|
|
|
27
26
|
self,
|
|
28
27
|
session_service: SessionService,
|
|
29
28
|
tab_service: TabService,
|
|
30
|
-
connection_registry:
|
|
29
|
+
connection_registry: ConnectionRegistryPort,
|
|
31
30
|
shell_provider: Callable[[str | None], ShellCommand | None],
|
|
32
31
|
default_dimensions: TerminalDimensions,
|
|
33
32
|
) -> None:
|
|
@@ -37,6 +36,23 @@ class ManagementService:
|
|
|
37
36
|
self._get_shell = shell_provider
|
|
38
37
|
self._default_dims = default_dimensions
|
|
39
38
|
|
|
39
|
+
async def _send_error(
|
|
40
|
+
self,
|
|
41
|
+
connection: ConnectionPort,
|
|
42
|
+
response_type: str,
|
|
43
|
+
request_id: str,
|
|
44
|
+
error: str,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Send an error response to a connection."""
|
|
47
|
+
await connection.send_message(
|
|
48
|
+
{
|
|
49
|
+
"type": response_type,
|
|
50
|
+
"request_id": request_id,
|
|
51
|
+
"success": False,
|
|
52
|
+
"error": error,
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
|
|
40
56
|
async def handle_message(
|
|
41
57
|
self,
|
|
42
58
|
user_id: UserId,
|
|
@@ -77,13 +93,8 @@ class ManagementService:
|
|
|
77
93
|
# Get shell
|
|
78
94
|
shell = self._get_shell(shell_id)
|
|
79
95
|
if not shell:
|
|
80
|
-
await
|
|
81
|
-
|
|
82
|
-
"type": "create_tab_response",
|
|
83
|
-
"request_id": request_id,
|
|
84
|
-
"success": False,
|
|
85
|
-
"error": "Invalid shell",
|
|
86
|
-
}
|
|
96
|
+
await self._send_error(
|
|
97
|
+
connection, "create_tab_response", request_id, "Invalid shell"
|
|
87
98
|
)
|
|
88
99
|
return
|
|
89
100
|
|
|
@@ -128,14 +139,7 @@ class ManagementService:
|
|
|
128
139
|
|
|
129
140
|
except ValueError as e:
|
|
130
141
|
logger.warning("Tab creation failed: %s", e)
|
|
131
|
-
await
|
|
132
|
-
{
|
|
133
|
-
"type": "create_tab_response",
|
|
134
|
-
"request_id": request_id,
|
|
135
|
-
"success": False,
|
|
136
|
-
"error": str(e),
|
|
137
|
-
}
|
|
138
|
-
)
|
|
142
|
+
await self._send_error(connection, "create_tab_response", request_id, str(e))
|
|
139
143
|
|
|
140
144
|
async def _handle_close_tab(
|
|
141
145
|
self,
|
|
@@ -148,27 +152,13 @@ class ManagementService:
|
|
|
148
152
|
tab_id = message.get("tab_id")
|
|
149
153
|
|
|
150
154
|
if not tab_id:
|
|
151
|
-
await
|
|
152
|
-
{
|
|
153
|
-
"type": "close_tab_response",
|
|
154
|
-
"request_id": request_id,
|
|
155
|
-
"success": False,
|
|
156
|
-
"error": "Missing tab_id",
|
|
157
|
-
}
|
|
158
|
-
)
|
|
155
|
+
await self._send_error(connection, "close_tab_response", request_id, "Missing tab_id")
|
|
159
156
|
return
|
|
160
157
|
|
|
161
158
|
# Get tab and session info before closing
|
|
162
159
|
tab = self._tab_service.get_tab(tab_id)
|
|
163
160
|
if not tab:
|
|
164
|
-
await
|
|
165
|
-
{
|
|
166
|
-
"type": "close_tab_response",
|
|
167
|
-
"request_id": request_id,
|
|
168
|
-
"success": False,
|
|
169
|
-
"error": "Tab not found",
|
|
170
|
-
}
|
|
171
|
-
)
|
|
161
|
+
await self._send_error(connection, "close_tab_response", request_id, "Tab not found")
|
|
172
162
|
return
|
|
173
163
|
|
|
174
164
|
session_id = tab.session_id
|
|
@@ -176,13 +166,8 @@ class ManagementService:
|
|
|
176
166
|
# Close the tab
|
|
177
167
|
closed_tab = self._tab_service.close_tab(tab_id, user_id)
|
|
178
168
|
if not closed_tab:
|
|
179
|
-
await
|
|
180
|
-
|
|
181
|
-
"type": "close_tab_response",
|
|
182
|
-
"request_id": request_id,
|
|
183
|
-
"success": False,
|
|
184
|
-
"error": "Failed to close tab",
|
|
185
|
-
}
|
|
169
|
+
await self._send_error(
|
|
170
|
+
connection, "close_tab_response", request_id, "Failed to close tab"
|
|
186
171
|
)
|
|
187
172
|
return
|
|
188
173
|
|
|
@@ -223,26 +208,16 @@ class ManagementService:
|
|
|
223
208
|
new_name = message.get("name")
|
|
224
209
|
|
|
225
210
|
if not tab_id or not new_name:
|
|
226
|
-
await
|
|
227
|
-
|
|
228
|
-
"type": "rename_tab_response",
|
|
229
|
-
"request_id": request_id,
|
|
230
|
-
"success": False,
|
|
231
|
-
"error": "Missing tab_id or name",
|
|
232
|
-
}
|
|
211
|
+
await self._send_error(
|
|
212
|
+
connection, "rename_tab_response", request_id, "Missing tab_id or name"
|
|
233
213
|
)
|
|
234
214
|
return
|
|
235
215
|
|
|
236
216
|
# Rename the tab
|
|
237
217
|
tab = self._tab_service.rename_tab(tab_id, user_id, new_name)
|
|
238
218
|
if not tab:
|
|
239
|
-
await
|
|
240
|
-
|
|
241
|
-
"type": "rename_tab_response",
|
|
242
|
-
"request_id": request_id,
|
|
243
|
-
"success": False,
|
|
244
|
-
"error": "Failed to rename tab",
|
|
245
|
-
}
|
|
219
|
+
await self._send_error(
|
|
220
|
+
connection, "rename_tab_response", request_id, "Failed to rename tab"
|
|
246
221
|
)
|
|
247
222
|
return
|
|
248
223
|
|
|
@@ -2,14 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import logging
|
|
5
|
-
import os
|
|
6
5
|
import uuid
|
|
7
6
|
from collections.abc import Awaitable, Callable
|
|
8
7
|
from datetime import UTC, datetime
|
|
9
8
|
|
|
10
9
|
from porterminal.domain import (
|
|
11
|
-
EnvironmentRules,
|
|
12
|
-
EnvironmentSanitizer,
|
|
13
10
|
PTYPort,
|
|
14
11
|
Session,
|
|
15
12
|
SessionId,
|
|
@@ -32,17 +29,13 @@ class SessionService:
|
|
|
32
29
|
def __init__(
|
|
33
30
|
self,
|
|
34
31
|
repository: SessionRepository[PTYPort],
|
|
35
|
-
pty_factory: Callable[
|
|
36
|
-
[ShellCommand, TerminalDimensions, dict[str, str], str | None], PTYPort
|
|
37
|
-
],
|
|
32
|
+
pty_factory: Callable[[ShellCommand, TerminalDimensions, str | None], PTYPort],
|
|
38
33
|
limit_checker: SessionLimitChecker | None = None,
|
|
39
|
-
environment_sanitizer: EnvironmentSanitizer | None = None,
|
|
40
34
|
working_directory: str | None = None,
|
|
41
35
|
) -> None:
|
|
42
36
|
self._repository = repository
|
|
43
37
|
self._pty_factory = pty_factory
|
|
44
38
|
self._limit_checker = limit_checker or SessionLimitChecker()
|
|
45
|
-
self._sanitizer = environment_sanitizer or EnvironmentSanitizer(EnvironmentRules())
|
|
46
39
|
self._cwd = working_directory
|
|
47
40
|
self._running = False
|
|
48
41
|
self._cleanup_task: asyncio.Task | None = None
|
|
@@ -108,9 +101,8 @@ class SessionService:
|
|
|
108
101
|
if not limit_result.allowed:
|
|
109
102
|
raise ValueError(limit_result.reason)
|
|
110
103
|
|
|
111
|
-
# Create PTY
|
|
112
|
-
|
|
113
|
-
pty = self._pty_factory(shell, dimensions, env, self._cwd)
|
|
104
|
+
# Create PTY (environment sanitization handled by PTY layer)
|
|
105
|
+
pty = self._pty_factory(shell, dimensions, self._cwd)
|
|
114
106
|
|
|
115
107
|
# Create session (starts with 0 clients, caller adds via add_client())
|
|
116
108
|
now = datetime.now(UTC)
|