ptn 0.2.5__py3-none-any.whl → 0.3.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 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.3.2'
32
+ __version_tuple__ = version_tuple = (0, 3, 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:
porterminal/cli/args.py CHANGED
@@ -66,6 +66,18 @@ def parse_args() -> argparse.Namespace:
66
66
  action="store_true",
67
67
  help="Create .ptn/ptn.yaml config file in current directory",
68
68
  )
69
+ parser.add_argument(
70
+ "-p",
71
+ "--password",
72
+ action="store_true",
73
+ help="Prompt for password to protect terminal access",
74
+ )
75
+ parser.add_argument(
76
+ "-dp",
77
+ "--default-password",
78
+ action="store_true",
79
+ help="Toggle password requirement in config (on/off)",
80
+ )
69
81
  # Internal argument for background mode communication
70
82
  parser.add_argument(
71
83
  "--_url-file",
@@ -97,6 +109,10 @@ def parse_args() -> argparse.Namespace:
97
109
  _init_config()
98
110
  sys.exit(0)
99
111
 
112
+ if args.default_password:
113
+ _toggle_password_requirement()
114
+ sys.exit(0)
115
+
100
116
  return args
101
117
 
102
118
 
@@ -139,3 +155,40 @@ def _init_config() -> None:
139
155
  config_dir.mkdir(exist_ok=True)
140
156
  config_file.write_text(DEFAULT_CONFIG)
141
157
  print(f"Created: {config_file}")
158
+
159
+
160
+ def _toggle_password_requirement() -> None:
161
+ """Toggle security.require_password in config file."""
162
+ from pathlib import Path
163
+
164
+ import yaml
165
+
166
+ from porterminal.config import find_config_file
167
+
168
+ # Find existing config or use default location
169
+ config_path = find_config_file()
170
+ if config_path is None:
171
+ config_dir = Path.cwd() / ".ptn"
172
+ config_path = config_dir / "ptn.yaml"
173
+ config_dir.mkdir(exist_ok=True)
174
+
175
+ # Read existing config or create empty
176
+ if config_path.exists():
177
+ with open(config_path, encoding="utf-8") as f:
178
+ data = yaml.safe_load(f) or {}
179
+ else:
180
+ data = {}
181
+
182
+ # Toggle the value
183
+ if "security" not in data:
184
+ data["security"] = {}
185
+ current = data["security"].get("require_password", False)
186
+ data["security"]["require_password"] = not current
187
+
188
+ # Write back
189
+ with open(config_path, "w", encoding="utf-8") as f:
190
+ yaml.safe_dump(data, f, default_flow_style=False, sort_keys=False)
191
+
192
+ new_value = data["security"]["require_password"]
193
+ status = "enabled" if new_value else "disabled"
194
+ print(f"Password requirement {status} in {config_path}")
@@ -136,7 +136,7 @@ def display_startup_screen(
136
136
  *tagline_colored,
137
137
  "",
138
138
  f"[bold yellow]{get_caution()}[/bold yellow]",
139
- "[bright_red]The URL is the only security. Use at your own risk.[/bright_red]",
139
+ "[dim]Use -p for password protection if your screen is exposed[/dim]",
140
140
  status,
141
141
  f"[bold cyan]{url}[/bold cyan]",
142
142
  ]
@@ -112,6 +112,7 @@ class PTYManagerAdapter:
112
112
  def create_container(
113
113
  config_path: Path | str | None = None,
114
114
  cwd: str | None = None,
115
+ password_hash: bytes | None = None,
115
116
  ) -> Container:
116
117
  """Create the dependency container with all wired dependencies.
117
118
 
@@ -121,6 +122,7 @@ def create_container(
121
122
  Args:
122
123
  config_path: Path to config file, or None to search standard locations.
123
124
  cwd: Working directory for PTY sessions.
125
+ password_hash: Bcrypt hash of password for authentication (None = no auth).
124
126
 
125
127
  Returns:
126
128
  Fully wired dependency container.
@@ -141,6 +143,7 @@ def create_container(
141
143
  # Get config values with defaults
142
144
  server_data = config_data.get("server", {})
143
145
  terminal_data = config_data.get("terminal", {})
146
+ security_data = config_data.get("security", {})
144
147
 
145
148
  server_host = server_data.get("host", "127.0.0.1")
146
149
  server_port = server_data.get("port", 8000)
@@ -148,6 +151,7 @@ def create_container(
148
151
  default_rows = terminal_data.get("rows", 30)
149
152
  default_shell_id = terminal_data.get("default_shell") or detector.get_default_shell_id()
150
153
  buttons = config_data.get("buttons", [])
154
+ max_auth_attempts = security_data.get("max_auth_attempts", 5)
151
155
 
152
156
  # Use configured shells if provided, otherwise use detected
153
157
  configured_shells = terminal_data.get("shells", [])
@@ -213,4 +217,6 @@ def create_container(
213
217
  default_rows=default_rows,
214
218
  buttons=buttons,
215
219
  cwd=cwd,
220
+ password_hash=password_hash,
221
+ max_auth_attempts=max_auth_attempts,
216
222
  )
porterminal/config.py CHANGED
@@ -76,6 +76,13 @@ class UpdateConfig(BaseModel):
76
76
  check_interval: int = Field(default=86400, ge=0) # Seconds between checks (0 = always)
77
77
 
78
78
 
79
+ class SecurityConfig(BaseModel):
80
+ """Security configuration."""
81
+
82
+ require_password: bool = False # Prompt for password at startup
83
+ max_auth_attempts: int = Field(default=5, ge=1, le=100)
84
+
85
+
79
86
  class Config(BaseModel):
80
87
  """Application configuration."""
81
88
 
@@ -84,6 +91,7 @@ class Config(BaseModel):
84
91
  buttons: list[ButtonConfig] = Field(default_factory=list)
85
92
  cloudflare: CloudflareConfig = Field(default_factory=CloudflareConfig)
86
93
  update: UpdateConfig = Field(default_factory=UpdateConfig)
94
+ security: SecurityConfig = Field(default_factory=SecurityConfig)
87
95
 
88
96
 
89
97
  def find_config_file(cwd: Path | None = None) -> Path | None:
porterminal/container.py CHANGED
@@ -52,3 +52,7 @@ class Container:
52
52
 
53
53
  # Working directory
54
54
  cwd: str | None = None
55
+
56
+ # Security
57
+ password_hash: bytes | None = None
58
+ max_auth_attempts: int = 5
@@ -0,0 +1,131 @@
1
+ """Authentication utilities for WebSocket connections."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ import signal
9
+ from typing import TYPE_CHECKING
10
+
11
+ import bcrypt
12
+
13
+ if TYPE_CHECKING:
14
+ from porterminal.application.ports import ConnectionPort
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def _shutdown_server() -> None:
20
+ """Trigger server shutdown due to auth failure."""
21
+ import time
22
+
23
+ # Print plain text - parent's drain_process_output handles formatting
24
+ print("", flush=True)
25
+ print("SECURITY WARNING", flush=True)
26
+ print("Max authentication attempts exceeded.", flush=True)
27
+ print("Your URL may have been leaked. Investigate before restarting.", flush=True)
28
+ print("", flush=True)
29
+
30
+ logger.warning(
31
+ "SECURITY: Max authentication attempts exceeded. "
32
+ "Shutting down server to prevent brute force attack."
33
+ )
34
+
35
+ # Delay to ensure message is visible before shutdown
36
+ time.sleep(1)
37
+ os.kill(os.getpid(), signal.SIGTERM)
38
+
39
+
40
+ async def authenticate_connection(
41
+ connection: ConnectionPort,
42
+ password_hash: bytes,
43
+ max_attempts: int = 5,
44
+ timeout_seconds: int = 30,
45
+ ) -> bool:
46
+ """Authenticate a WebSocket connection with password.
47
+
48
+ Sends auth_required, waits for auth message, validates password.
49
+ Returns True if authenticated, False otherwise.
50
+
51
+ Args:
52
+ connection: WebSocket connection adapter
53
+ password_hash: bcrypt hash of the expected password
54
+ max_attempts: Maximum number of password attempts
55
+ timeout_seconds: Timeout for receiving auth message
56
+
57
+ Returns:
58
+ True if successfully authenticated, False otherwise
59
+ """
60
+ await connection.send_message({"type": "auth_required"})
61
+
62
+ attempts = 0
63
+ while attempts < max_attempts:
64
+ try:
65
+ message = await asyncio.wait_for(connection.receive(), timeout=timeout_seconds)
66
+ except TimeoutError:
67
+ await connection.send_message(
68
+ {
69
+ "type": "auth_failed",
70
+ "attempts_remaining": 0,
71
+ "error": "Authentication timeout",
72
+ }
73
+ )
74
+ return False
75
+
76
+ if not isinstance(message, dict) or message.get("type") != "auth":
77
+ await connection.send_message(
78
+ {
79
+ "type": "error",
80
+ "error": "Authentication required",
81
+ }
82
+ )
83
+ continue
84
+
85
+ password = message.get("password", "")
86
+ if bcrypt.checkpw(password.encode(), password_hash):
87
+ await connection.send_message({"type": "auth_success"})
88
+ return True
89
+
90
+ attempts += 1
91
+ remaining = max_attempts - attempts
92
+ await connection.send_message(
93
+ {
94
+ "type": "auth_failed",
95
+ "attempts_remaining": remaining,
96
+ "error": "Invalid password" if remaining > 0 else "Too many failed attempts",
97
+ }
98
+ )
99
+
100
+ # Max attempts exhausted - shutdown to prevent brute force
101
+ _shutdown_server()
102
+ return False
103
+
104
+
105
+ async def validate_auth_message(
106
+ connection: ConnectionPort,
107
+ password_hash: bytes,
108
+ timeout_seconds: int = 10,
109
+ ) -> bool:
110
+ """Validate a single auth message from a connection.
111
+
112
+ For terminal WebSocket where we expect auth as first message.
113
+
114
+ Args:
115
+ connection: WebSocket connection adapter
116
+ password_hash: bcrypt hash of the expected password
117
+ timeout_seconds: Timeout for receiving auth message
118
+
119
+ Returns:
120
+ True if valid, False otherwise
121
+ """
122
+ try:
123
+ message = await asyncio.wait_for(connection.receive(), timeout=timeout_seconds)
124
+ except TimeoutError:
125
+ return False
126
+
127
+ if not isinstance(message, dict) or message.get("type") != "auth":
128
+ return False
129
+
130
+ password = message.get("password", "")
131
+ return bcrypt.checkpw(password.encode(), password_hash)
@@ -172,12 +172,16 @@ class CloudflaredInstaller:
172
172
  try:
173
173
  # Add Cloudflare repo first for apt
174
174
  if name == "apt":
175
+ # Use "any" distribution - works on all Debian-based systems
176
+ # (Ubuntu, Debian, Mint, Pop!_OS, etc.) without codename detection
177
+ # See: https://pkg.cloudflare.com/
175
178
  subprocess.run(
176
179
  [
177
180
  "bash",
178
181
  "-c",
182
+ "sudo mkdir -p --mode=0755 /usr/share/keyrings && "
179
183
  "curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null && "
180
- "echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared $(lsb_release -cs) main' | sudo tee /etc/apt/sources.list.d/cloudflared.list && "
184
+ "echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main' | sudo tee /etc/apt/sources.list.d/cloudflared.list && "
181
185
  "sudo apt-get update",
182
186
  ],
183
187
  capture_output=True,