ptn 0.2.7__tar.gz → 0.3.1__tar.gz

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 (79) hide show
  1. {ptn-0.2.7 → ptn-0.3.1}/PKG-INFO +19 -6
  2. {ptn-0.2.7 → ptn-0.3.1}/README.md +17 -5
  3. {ptn-0.2.7 → ptn-0.3.1}/porterminal/__init__.py +37 -8
  4. {ptn-0.2.7 → ptn-0.3.1}/porterminal/_version.py +2 -2
  5. {ptn-0.2.7 → ptn-0.3.1}/porterminal/app.py +25 -1
  6. {ptn-0.2.7 → ptn-0.3.1}/porterminal/application/ports/__init__.py +2 -0
  7. ptn-0.3.1/porterminal/application/ports/connection_registry_port.py +46 -0
  8. {ptn-0.2.7 → ptn-0.3.1}/porterminal/application/services/management_service.py +2 -3
  9. {ptn-0.2.7 → ptn-0.3.1}/porterminal/cli/args.py +53 -0
  10. {ptn-0.2.7 → ptn-0.3.1}/porterminal/cli/display.py +1 -1
  11. {ptn-0.2.7 → ptn-0.3.1}/porterminal/composition.py +6 -0
  12. {ptn-0.2.7 → ptn-0.3.1}/porterminal/config.py +8 -0
  13. {ptn-0.2.7 → ptn-0.3.1}/porterminal/container.py +4 -0
  14. ptn-0.3.1/porterminal/infrastructure/auth.py +131 -0
  15. {ptn-0.2.7 → ptn-0.3.1}/porterminal/infrastructure/server.py +14 -2
  16. ptn-0.3.1/porterminal/static/assets/app-BkHv5qu0.css +32 -0
  17. ptn-0.3.1/porterminal/static/assets/app-CaIGfw7i.js +72 -0
  18. ptn-0.3.1/porterminal/static/assets/app-D9ELFbEO.js +72 -0
  19. ptn-0.3.1/porterminal/static/assets/app-DF3nl_io.js +72 -0
  20. ptn-0.3.1/porterminal/static/assets/app-azbHOsRw.css +32 -0
  21. ptn-0.3.1/porterminal/static/assets/app-nMNFwMa6.css +32 -0
  22. {ptn-0.2.7 → ptn-0.3.1}/porterminal/static/index.html +14 -2
  23. {ptn-0.2.7 → ptn-0.3.1}/pyproject.toml +1 -0
  24. {ptn-0.2.7 → ptn-0.3.1}/.gitignore +0 -0
  25. {ptn-0.2.7 → ptn-0.3.1}/LICENSE +0 -0
  26. {ptn-0.2.7 → ptn-0.3.1}/porterminal/__main__.py +0 -0
  27. {ptn-0.2.7 → ptn-0.3.1}/porterminal/application/__init__.py +0 -0
  28. {ptn-0.2.7 → ptn-0.3.1}/porterminal/application/ports/connection_port.py +0 -0
  29. {ptn-0.2.7 → ptn-0.3.1}/porterminal/application/services/__init__.py +0 -0
  30. {ptn-0.2.7 → ptn-0.3.1}/porterminal/application/services/session_service.py +0 -0
  31. {ptn-0.2.7 → ptn-0.3.1}/porterminal/application/services/tab_service.py +0 -0
  32. {ptn-0.2.7 → ptn-0.3.1}/porterminal/application/services/terminal_service.py +0 -0
  33. {ptn-0.2.7 → ptn-0.3.1}/porterminal/asgi.py +0 -0
  34. {ptn-0.2.7 → ptn-0.3.1}/porterminal/cli/__init__.py +0 -0
  35. {ptn-0.2.7 → ptn-0.3.1}/porterminal/domain/__init__.py +0 -0
  36. {ptn-0.2.7 → ptn-0.3.1}/porterminal/domain/entities/__init__.py +0 -0
  37. {ptn-0.2.7 → ptn-0.3.1}/porterminal/domain/entities/output_buffer.py +0 -0
  38. {ptn-0.2.7 → ptn-0.3.1}/porterminal/domain/entities/session.py +0 -0
  39. {ptn-0.2.7 → ptn-0.3.1}/porterminal/domain/entities/tab.py +0 -0
  40. {ptn-0.2.7 → ptn-0.3.1}/porterminal/domain/ports/__init__.py +0 -0
  41. {ptn-0.2.7 → ptn-0.3.1}/porterminal/domain/ports/pty_port.py +0 -0
  42. {ptn-0.2.7 → ptn-0.3.1}/porterminal/domain/ports/session_repository.py +0 -0
  43. {ptn-0.2.7 → ptn-0.3.1}/porterminal/domain/ports/tab_repository.py +0 -0
  44. {ptn-0.2.7 → ptn-0.3.1}/porterminal/domain/services/__init__.py +0 -0
  45. {ptn-0.2.7 → ptn-0.3.1}/porterminal/domain/services/environment_sanitizer.py +0 -0
  46. {ptn-0.2.7 → ptn-0.3.1}/porterminal/domain/services/rate_limiter.py +0 -0
  47. {ptn-0.2.7 → ptn-0.3.1}/porterminal/domain/services/session_limits.py +0 -0
  48. {ptn-0.2.7 → ptn-0.3.1}/porterminal/domain/services/tab_limits.py +0 -0
  49. {ptn-0.2.7 → ptn-0.3.1}/porterminal/domain/values/__init__.py +0 -0
  50. {ptn-0.2.7 → ptn-0.3.1}/porterminal/domain/values/environment_rules.py +0 -0
  51. {ptn-0.2.7 → ptn-0.3.1}/porterminal/domain/values/rate_limit_config.py +0 -0
  52. {ptn-0.2.7 → ptn-0.3.1}/porterminal/domain/values/session_id.py +0 -0
  53. {ptn-0.2.7 → ptn-0.3.1}/porterminal/domain/values/shell_command.py +0 -0
  54. {ptn-0.2.7 → ptn-0.3.1}/porterminal/domain/values/tab_id.py +0 -0
  55. {ptn-0.2.7 → ptn-0.3.1}/porterminal/domain/values/terminal_dimensions.py +0 -0
  56. {ptn-0.2.7 → ptn-0.3.1}/porterminal/domain/values/user_id.py +0 -0
  57. {ptn-0.2.7 → ptn-0.3.1}/porterminal/infrastructure/__init__.py +0 -0
  58. {ptn-0.2.7 → ptn-0.3.1}/porterminal/infrastructure/cloudflared.py +0 -0
  59. {ptn-0.2.7 → ptn-0.3.1}/porterminal/infrastructure/config/__init__.py +0 -0
  60. {ptn-0.2.7 → ptn-0.3.1}/porterminal/infrastructure/config/shell_detector.py +0 -0
  61. {ptn-0.2.7 → ptn-0.3.1}/porterminal/infrastructure/network.py +0 -0
  62. {ptn-0.2.7 → ptn-0.3.1}/porterminal/infrastructure/registry/__init__.py +0 -0
  63. {ptn-0.2.7 → ptn-0.3.1}/porterminal/infrastructure/registry/user_connection_registry.py +0 -0
  64. {ptn-0.2.7 → ptn-0.3.1}/porterminal/infrastructure/repositories/__init__.py +0 -0
  65. {ptn-0.2.7 → ptn-0.3.1}/porterminal/infrastructure/repositories/in_memory_session.py +0 -0
  66. {ptn-0.2.7 → ptn-0.3.1}/porterminal/infrastructure/repositories/in_memory_tab.py +0 -0
  67. {ptn-0.2.7 → ptn-0.3.1}/porterminal/infrastructure/web/__init__.py +0 -0
  68. {ptn-0.2.7 → ptn-0.3.1}/porterminal/infrastructure/web/websocket_adapter.py +0 -0
  69. {ptn-0.2.7 → ptn-0.3.1}/porterminal/logging_setup.py +0 -0
  70. {ptn-0.2.7 → ptn-0.3.1}/porterminal/pty/__init__.py +0 -0
  71. {ptn-0.2.7 → ptn-0.3.1}/porterminal/pty/env.py +0 -0
  72. {ptn-0.2.7 → ptn-0.3.1}/porterminal/pty/manager.py +0 -0
  73. {ptn-0.2.7 → ptn-0.3.1}/porterminal/pty/protocol.py +0 -0
  74. {ptn-0.2.7 → ptn-0.3.1}/porterminal/pty/unix.py +0 -0
  75. {ptn-0.2.7 → ptn-0.3.1}/porterminal/pty/windows.py +0 -0
  76. {ptn-0.2.7 → ptn-0.3.1}/porterminal/static/assets/app-DQePboVd.css +0 -0
  77. {ptn-0.2.7 → ptn-0.3.1}/porterminal/static/assets/app-DoBiVkTD.js +0 -0
  78. {ptn-0.2.7 → ptn-0.3.1}/porterminal/static/icon.svg +0 -0
  79. {ptn-0.2.7 → ptn-0.3.1}/porterminal/updater.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ptn
3
- Version: 0.2.7
3
+ Version: 0.3.1
4
4
  Summary: Web-based terminal accessible from phone via Cloudflare Tunnel
5
5
  Project-URL: Homepage, https://github.com/lyehe/porterminal
6
6
  Project-URL: Repository, https://github.com/lyehe/porterminal
@@ -26,6 +26,7 @@ Classifier: Topic :: Internet :: WWW/HTTP
26
26
  Classifier: Topic :: System :: Shells
27
27
  Classifier: Topic :: Terminals :: Terminal Emulators/X Terminals
28
28
  Requires-Python: >=3.12
29
+ Requires-Dist: bcrypt>=4.0.0
29
30
  Requires-Dist: fastapi>=0.104.0
30
31
  Requires-Dist: pydantic>=2.0
31
32
  Requires-Dist: pywinpty>=2.0.0; sys_platform == 'win32'
@@ -103,6 +104,8 @@ ptn # Start in current directory
103
104
  ptn ~/projects/myapp # Start in specific folder
104
105
  ptn --no-tunnel # Local network only
105
106
  ptn -b # Run in background
107
+ ptn -p # Enable password protection
108
+ ptn -dp # Toggle default password requirement in config
106
109
  ptn -v # Verbose startup logs
107
110
  ptn --init # Create .ptn/ptn.yaml config
108
111
  ptn -V # Show version
@@ -129,18 +132,28 @@ buttons:
129
132
  update:
130
133
  notify_on_startup: true # Show update notification
131
134
  check_interval: 86400 # Seconds between checks (default: 24h)
135
+
136
+ # Security settings
137
+ security:
138
+ require_password: true # Always prompt for password at startup
139
+ max_auth_attempts: 5 # Max failed attempts before disconnect
132
140
  ```
133
141
 
134
142
  Config is searched in order: `$PORTERMINAL_CONFIG_PATH`, `./ptn.yaml`, `./.ptn/ptn.yaml`, `~/.ptn/ptn.yaml`.
135
143
 
136
144
  ## Security
137
145
 
138
- > **Warning:** The URL is the only authentication. Anyone with the link has full terminal access.
146
+ Use password if your screen can be exposed to others:
147
+ ```bash
148
+ ptn -p # Prompt for password this session
149
+ ptn -dp # Enable password by default (toggle)
150
+ ```
151
+
152
+ Password is per-session (never saved to disk). See [docs/security.md](docs/security.md) for details.
153
+
154
+ ## Troubleshooting
139
155
 
140
- - Don't share the URL
141
- - Stop the server when not in use (`Ctrl+C`)
142
- - Use `--no-tunnel` for local network only
143
- - Environment variables are sanitized (API keys, tokens stripped)
156
+ **Connection fails?** Cloudflare tunnel sometimes blocks connections. Restart the server (`Ctrl+C`, then `ptn`) to get a fresh tunnel URL.
144
157
 
145
158
  ## Contributing
146
159
 
@@ -64,6 +64,8 @@ ptn # Start in current directory
64
64
  ptn ~/projects/myapp # Start in specific folder
65
65
  ptn --no-tunnel # Local network only
66
66
  ptn -b # Run in background
67
+ ptn -p # Enable password protection
68
+ ptn -dp # Toggle default password requirement in config
67
69
  ptn -v # Verbose startup logs
68
70
  ptn --init # Create .ptn/ptn.yaml config
69
71
  ptn -V # Show version
@@ -90,18 +92,28 @@ buttons:
90
92
  update:
91
93
  notify_on_startup: true # Show update notification
92
94
  check_interval: 86400 # Seconds between checks (default: 24h)
95
+
96
+ # Security settings
97
+ security:
98
+ require_password: true # Always prompt for password at startup
99
+ max_auth_attempts: 5 # Max failed attempts before disconnect
93
100
  ```
94
101
 
95
102
  Config is searched in order: `$PORTERMINAL_CONFIG_PATH`, `./ptn.yaml`, `./.ptn/ptn.yaml`, `~/.ptn/ptn.yaml`.
96
103
 
97
104
  ## Security
98
105
 
99
- > **Warning:** The URL is the only authentication. Anyone with the link has full terminal access.
106
+ Use password if your screen can be exposed to others:
107
+ ```bash
108
+ ptn -p # Prompt for password this session
109
+ ptn -dp # Enable password by default (toggle)
110
+ ```
111
+
112
+ Password is per-session (never saved to disk). See [docs/security.md](docs/security.md) for details.
113
+
114
+ ## Troubleshooting
100
115
 
101
- - Don't share the URL
102
- - Stop the server when not in use (`Ctrl+C`)
103
- - Use `--no-tunnel` for local network only
104
- - Environment variables are sanitized (API keys, tokens stripped)
116
+ **Connection fails?** Cloudflare tunnel sometimes blocks connections. Restart the server (`Ctrl+C`, then `ptn`) to get a fresh tunnel URL.
105
117
 
106
118
  ## Contributing
107
119
 
@@ -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]:
@@ -314,11 +340,14 @@ def main() -> int:
314
340
  proc.kill()
315
341
  proc.wait()
316
342
 
317
- cleanup_process(server_process, "server")
318
- cleanup_process(tunnel_process, "tunnel")
319
-
320
- # Give processes time to release the port
321
- time.sleep(0.5)
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)
322
351
 
323
352
  return 0
324
353
 
@@ -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.7'
32
- __version_tuple__ = version_tuple = (0, 2, 7)
31
+ __version__ = version = '0.3.1'
32
+ __version_tuple__ = version_tuple = (0, 3, 1)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -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:
@@ -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
  )
@@ -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:
@@ -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)
@@ -164,7 +164,7 @@ def start_cloudflared(port: int) -> tuple[subprocess.Popen, str | None]:
164
164
 
165
165
 
166
166
  def drain_process_output(process: subprocess.Popen) -> None:
167
- """Drain process output silently (only print errors).
167
+ """Drain process output silently (only print errors and security warnings).
168
168
 
169
169
  Args:
170
170
  process: Subprocess to drain output from.
@@ -174,8 +174,20 @@ def drain_process_output(process: subprocess.Popen) -> None:
174
174
  if not line:
175
175
  break
176
176
  line = line.strip()
177
+ if not line:
178
+ continue
179
+ # Always print security warnings and related messages
180
+ if any(
181
+ kw in line.lower()
182
+ for kw in (
183
+ "security warning",
184
+ "authentication attempts",
185
+ "url may have been leaked",
186
+ )
187
+ ):
188
+ console.print(f"[bold red]{line}[/bold red]")
177
189
  # Print errors, but ignore harmless ICMP/ping warnings
178
- if line and "error" in line.lower() and not _is_icmp_warning(line):
190
+ elif "error" in line.lower() and not _is_icmp_warning(line):
179
191
  console.print(f"[red]{line}[/red]")
180
192
  except (OSError, ValueError):
181
193
  pass