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.
Files changed (39) hide show
  1. porterminal/__init__.py +63 -11
  2. porterminal/_version.py +2 -2
  3. porterminal/app.py +25 -1
  4. porterminal/application/ports/__init__.py +2 -0
  5. porterminal/application/ports/connection_registry_port.py +46 -0
  6. porterminal/application/services/management_service.py +30 -55
  7. porterminal/application/services/session_service.py +3 -11
  8. porterminal/application/services/terminal_service.py +97 -56
  9. porterminal/cli/args.py +91 -30
  10. porterminal/cli/display.py +18 -16
  11. porterminal/cli/script_discovery.py +112 -0
  12. porterminal/composition.py +8 -7
  13. porterminal/config.py +12 -2
  14. porterminal/container.py +4 -0
  15. porterminal/domain/__init__.py +0 -9
  16. porterminal/domain/entities/output_buffer.py +56 -1
  17. porterminal/domain/entities/tab.py +11 -10
  18. porterminal/domain/services/__init__.py +0 -2
  19. porterminal/domain/values/__init__.py +0 -4
  20. porterminal/domain/values/environment_rules.py +3 -0
  21. porterminal/infrastructure/auth.py +131 -0
  22. porterminal/infrastructure/cloudflared.py +18 -12
  23. porterminal/infrastructure/config/shell_detector.py +407 -1
  24. porterminal/infrastructure/repositories/in_memory_session.py +1 -4
  25. porterminal/infrastructure/repositories/in_memory_tab.py +2 -10
  26. porterminal/infrastructure/server.py +28 -3
  27. porterminal/pty/env.py +16 -78
  28. porterminal/pty/manager.py +6 -4
  29. porterminal/static/assets/app-DlWNJWFE.js +87 -0
  30. porterminal/static/assets/app-xPAM7YhQ.css +1 -0
  31. porterminal/static/index.html +14 -2
  32. porterminal/updater.py +13 -5
  33. {ptn-0.2.5.dist-info → ptn-0.4.2.dist-info}/METADATA +84 -23
  34. {ptn-0.2.5.dist-info → ptn-0.4.2.dist-info}/RECORD +37 -34
  35. porterminal/static/assets/app-By4EXMHC.js +0 -72
  36. porterminal/static/assets/app-DQePboVd.css +0 -32
  37. {ptn-0.2.5.dist-info → ptn-0.4.2.dist-info}/WHEEL +0 -0
  38. {ptn-0.2.5.dist-info → ptn-0.4.2.dist-info}/entry_points.txt +0 -0
  39. {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
- cleanup_process(server_process, "server")
298
- cleanup_process(tunnel_process, "tunnel")
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.5'
32
- __version_tuple__ = version_tuple = (0, 2, 5)
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
- container = create_container(config_path=None, cwd=cwd)
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
 
@@ -1,7 +1,9 @@
1
1
  """Application layer ports - interfaces for presentation layer."""
2
2
 
3
3
  from .connection_port import ConnectionPort
4
+ from .connection_registry_port import ConnectionRegistryPort
4
5
 
5
6
  __all__ = [
6
7
  "ConnectionPort",
8
+ "ConnectionRegistryPort",
7
9
  ]
@@ -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: UserConnectionRegistry,
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 connection.send_message(
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 connection.send_message(
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 connection.send_message(
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 connection.send_message(
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 connection.send_message(
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 connection.send_message(
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 connection.send_message(
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 with sanitized environment
112
- env = self._sanitizer.sanitize(dict(os.environ))
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)