ptn 0.1.4__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.
Files changed (45) hide show
  1. porterminal/__init__.py +82 -14
  2. porterminal/_version.py +34 -0
  3. porterminal/app.py +32 -4
  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 +2 -3
  7. porterminal/application/services/terminal_service.py +116 -28
  8. porterminal/asgi.py +8 -3
  9. porterminal/cli/args.py +103 -0
  10. porterminal/cli/display.py +1 -1
  11. porterminal/composition.py +19 -5
  12. porterminal/config.py +62 -70
  13. porterminal/container.py +3 -10
  14. porterminal/domain/__init__.py +0 -2
  15. porterminal/domain/entities/output_buffer.py +0 -4
  16. porterminal/domain/ports/__init__.py +1 -2
  17. porterminal/domain/ports/pty_port.py +0 -29
  18. porterminal/domain/ports/tab_repository.py +0 -5
  19. porterminal/infrastructure/auth.py +131 -0
  20. porterminal/infrastructure/cloudflared.py +5 -1
  21. porterminal/infrastructure/config/__init__.py +0 -2
  22. porterminal/infrastructure/config/shell_detector.py +342 -1
  23. porterminal/infrastructure/repositories/in_memory_tab.py +0 -4
  24. porterminal/infrastructure/server.py +37 -5
  25. porterminal/static/assets/app-BkHv5qu0.css +32 -0
  26. porterminal/static/assets/app-CaIGfw7i.js +72 -0
  27. porterminal/static/assets/app-D9ELFbEO.js +72 -0
  28. porterminal/static/assets/app-DF3nl_io.js +72 -0
  29. porterminal/static/assets/app-DQePboVd.css +32 -0
  30. porterminal/static/assets/app-DoBiVkTD.js +72 -0
  31. porterminal/static/assets/app-azbHOsRw.css +32 -0
  32. porterminal/static/assets/app-nMNFwMa6.css +32 -0
  33. porterminal/static/index.html +28 -25
  34. porterminal/updater.py +115 -168
  35. ptn-0.3.2.dist-info/METADATA +171 -0
  36. {ptn-0.1.4.dist-info → ptn-0.3.2.dist-info}/RECORD +39 -33
  37. porterminal/infrastructure/config/yaml_loader.py +0 -34
  38. porterminal/static/assets/app-BQiuUo6Q.css +0 -32
  39. porterminal/static/assets/app-YNN_jEhv.js +0 -71
  40. porterminal/static/manifest.json +0 -31
  41. porterminal/static/sw.js +0 -66
  42. ptn-0.1.4.dist-info/METADATA +0 -191
  43. {ptn-0.1.4.dist-info → ptn-0.3.2.dist-info}/WHEEL +0 -0
  44. {ptn-0.1.4.dist-info → ptn-0.3.2.dist-info}/entry_points.txt +0 -0
  45. {ptn-0.1.4.dist-info → ptn-0.3.2.dist-info}/licenses/LICENSE +0 -0
porterminal/cli/args.py CHANGED
@@ -61,6 +61,23 @@ def parse_args() -> argparse.Namespace:
61
61
  action="store_true",
62
62
  help="Run in background and return immediately",
63
63
  )
64
+ parser.add_argument(
65
+ "--init",
66
+ action="store_true",
67
+ help="Create .ptn/ptn.yaml config file in current directory",
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
+ )
64
81
  # Internal argument for background mode communication
65
82
  parser.add_argument(
66
83
  "--_url-file",
@@ -88,4 +105,90 @@ def parse_args() -> argparse.Namespace:
88
105
  success = update_package()
89
106
  sys.exit(0 if success else 1)
90
107
 
108
+ if args.init:
109
+ _init_config()
110
+ sys.exit(0)
111
+
112
+ if args.default_password:
113
+ _toggle_password_requirement()
114
+ sys.exit(0)
115
+
91
116
  return args
117
+
118
+
119
+ DEFAULT_CONFIG = """\
120
+ # ptn configuration file
121
+ # Docs: https://github.com/lyehe/porterminal/blob/master/docs/configuration.md
122
+
123
+ # Custom buttons (appear in third toolbar row)
124
+ buttons:
125
+ - label: "git"
126
+ send: "git status\\r"
127
+ - label: "build"
128
+ send: "npm run build\\r"
129
+ # Multi-step button with delays (ms):
130
+ # - label: "deploy"
131
+ # send:
132
+ # - "npm run build"
133
+ # - 100
134
+ # - "\\r"
135
+
136
+ # Terminal settings (optional)
137
+ # terminal:
138
+ # default_shell: bash
139
+ # cols: 120
140
+ # rows: 30
141
+ """
142
+
143
+
144
+ def _init_config() -> None:
145
+ """Create .ptn/ptn.yaml in current directory."""
146
+ from pathlib import Path
147
+
148
+ config_dir = Path.cwd() / ".ptn"
149
+ config_file = config_dir / "ptn.yaml"
150
+
151
+ if config_file.exists():
152
+ print(f"Config already exists: {config_file}")
153
+ return
154
+
155
+ config_dir.mkdir(exist_ok=True)
156
+ config_file.write_text(DEFAULT_CONFIG)
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
  ]
@@ -3,12 +3,15 @@
3
3
  from collections.abc import Callable
4
4
  from pathlib import Path
5
5
 
6
+ import yaml
7
+
6
8
  from porterminal.application.services import (
7
9
  ManagementService,
8
10
  SessionService,
9
11
  TabService,
10
12
  TerminalService,
11
13
  )
14
+ from porterminal.config import find_config_file
12
15
  from porterminal.container import Container
13
16
  from porterminal.domain import (
14
17
  EnvironmentRules,
@@ -19,7 +22,7 @@ from porterminal.domain import (
19
22
  TabLimitChecker,
20
23
  TerminalDimensions,
21
24
  )
22
- from porterminal.infrastructure.config import ShellDetector, YAMLConfigLoader
25
+ from porterminal.infrastructure.config import ShellDetector
23
26
  from porterminal.infrastructure.registry import UserConnectionRegistry
24
27
  from porterminal.infrastructure.repositories import InMemorySessionRepository, InMemoryTabRepository
25
28
 
@@ -107,8 +110,9 @@ class PTYManagerAdapter:
107
110
 
108
111
 
109
112
  def create_container(
110
- config_path: Path | str = "config.yaml",
113
+ config_path: Path | str | None = None,
111
114
  cwd: str | None = None,
115
+ password_hash: bytes | None = None,
112
116
  ) -> Container:
113
117
  """Create the dependency container with all wired dependencies.
114
118
 
@@ -116,15 +120,21 @@ def create_container(
116
120
  dependencies are created and wired together.
117
121
 
118
122
  Args:
119
- config_path: Path to config file.
123
+ config_path: Path to config file, or None to search standard locations.
120
124
  cwd: Working directory for PTY sessions.
125
+ password_hash: Bcrypt hash of password for authentication (None = no auth).
121
126
 
122
127
  Returns:
123
128
  Fully wired dependency container.
124
129
  """
125
130
  # Load configuration
126
- loader = YAMLConfigLoader(config_path)
127
- config_data = loader.load()
131
+ if config_path is None:
132
+ config_path = find_config_file()
133
+
134
+ config_data: dict = {}
135
+ if config_path is not None and Path(config_path).exists():
136
+ with open(config_path, encoding="utf-8") as f:
137
+ config_data = yaml.safe_load(f) or {}
128
138
 
129
139
  # Detect shells
130
140
  detector = ShellDetector()
@@ -133,6 +143,7 @@ def create_container(
133
143
  # Get config values with defaults
134
144
  server_data = config_data.get("server", {})
135
145
  terminal_data = config_data.get("terminal", {})
146
+ security_data = config_data.get("security", {})
136
147
 
137
148
  server_host = server_data.get("host", "127.0.0.1")
138
149
  server_port = server_data.get("port", 8000)
@@ -140,6 +151,7 @@ def create_container(
140
151
  default_rows = terminal_data.get("rows", 30)
141
152
  default_shell_id = terminal_data.get("default_shell") or detector.get_default_shell_id()
142
153
  buttons = config_data.get("buttons", [])
154
+ max_auth_attempts = security_data.get("max_auth_attempts", 5)
143
155
 
144
156
  # Use configured shells if provided, otherwise use detected
145
157
  configured_shells = terminal_data.get("shells", [])
@@ -205,4 +217,6 @@ def create_container(
205
217
  default_rows=default_rows,
206
218
  buttons=buttons,
207
219
  cwd=cwd,
220
+ password_hash=password_hash,
221
+ max_auth_attempts=max_auth_attempts,
208
222
  )
porterminal/config.py CHANGED
@@ -1,12 +1,14 @@
1
1
  """Configuration loading and validation using Pydantic."""
2
2
 
3
+ import os
3
4
  import shutil
4
- import sys
5
5
  from pathlib import Path
6
6
 
7
7
  import yaml
8
8
  from pydantic import BaseModel, Field, field_validator
9
9
 
10
+ from porterminal.infrastructure.config import ShellDetector
11
+
10
12
 
11
13
  class ServerConfig(BaseModel):
12
14
  """Server configuration."""
@@ -37,67 +39,6 @@ class ShellConfig(BaseModel):
37
39
  raise ValueError(f"Shell executable not found: {v}")
38
40
 
39
41
 
40
- def detect_available_shells() -> list[ShellConfig]:
41
- """Auto-detect available shells based on the platform."""
42
- shells = []
43
-
44
- if sys.platform == "win32":
45
- # Windows shells
46
- candidates = [
47
- ("PowerShell", "powershell", "powershell.exe", ["-NoLogo"]),
48
- ("PowerShell 7", "pwsh", "pwsh.exe", ["-NoLogo"]),
49
- ("CMD", "cmd", "cmd.exe", []),
50
- ("WSL", "wsl", "wsl.exe", []),
51
- ("Git Bash", "gitbash", r"C:\Program Files\Git\bin\bash.exe", ["--login"]),
52
- ]
53
- else:
54
- # Unix-like shells (Linux, macOS)
55
- candidates = [
56
- ("Bash", "bash", "bash", ["--login"]),
57
- ("Zsh", "zsh", "zsh", ["--login"]),
58
- ("Fish", "fish", "fish", []),
59
- ("Sh", "sh", "sh", []),
60
- ]
61
-
62
- for name, shell_id, command, args in candidates:
63
- # Check if shell exists
64
- shell_path = shutil.which(command)
65
- if shell_path or Path(command).exists():
66
- shells.append(
67
- ShellConfig(
68
- name=name,
69
- id=shell_id,
70
- command=shell_path or command,
71
- args=args,
72
- )
73
- )
74
-
75
- return shells
76
-
77
-
78
- def get_default_shell_id() -> str:
79
- """Get the default shell ID for the current platform."""
80
- if sys.platform == "win32":
81
- # Prefer PowerShell 7, then PowerShell, then CMD
82
- if shutil.which("pwsh"):
83
- return "pwsh"
84
- if shutil.which("powershell"):
85
- return "powershell"
86
- return "cmd"
87
- elif sys.platform == "darwin":
88
- # macOS defaults to zsh
89
- if shutil.which("zsh"):
90
- return "zsh"
91
- return "bash"
92
- else:
93
- # Linux - prefer bash
94
- if shutil.which("bash"):
95
- return "bash"
96
- if shutil.which("zsh"):
97
- return "zsh"
98
- return "sh"
99
-
100
-
101
42
  class TerminalConfig(BaseModel):
102
43
  """Terminal configuration."""
103
44
 
@@ -118,7 +59,7 @@ class ButtonConfig(BaseModel):
118
59
  """Custom button configuration."""
119
60
 
120
61
  label: str
121
- send: str
62
+ send: str | list[str | int] = "" # string or list of strings/ints (ints = wait ms)
122
63
 
123
64
 
124
65
  class CloudflareConfig(BaseModel):
@@ -128,6 +69,20 @@ class CloudflareConfig(BaseModel):
128
69
  access_aud: str = ""
129
70
 
130
71
 
72
+ class UpdateConfig(BaseModel):
73
+ """Update checker configuration."""
74
+
75
+ notify_on_startup: bool = True # Show "update available" on startup
76
+ check_interval: int = Field(default=86400, ge=0) # Seconds between checks (0 = always)
77
+
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
+
131
86
  class Config(BaseModel):
132
87
  """Application configuration."""
133
88
 
@@ -135,13 +90,47 @@ class Config(BaseModel):
135
90
  terminal: TerminalConfig = Field(default_factory=TerminalConfig)
136
91
  buttons: list[ButtonConfig] = Field(default_factory=list)
137
92
  cloudflare: CloudflareConfig = Field(default_factory=CloudflareConfig)
93
+ update: UpdateConfig = Field(default_factory=UpdateConfig)
94
+ security: SecurityConfig = Field(default_factory=SecurityConfig)
95
+
96
+
97
+ def find_config_file(cwd: Path | None = None) -> Path | None:
98
+ """Find config file in standard locations.
99
+
100
+ Search order:
101
+ 1. PORTERMINAL_CONFIG_PATH env var (if set)
102
+ 2. ptn.yaml in cwd
103
+ 3. .ptn/ptn.yaml in cwd
104
+ 4. ~/.ptn/ptn.yaml (user home directory)
105
+ """
106
+ # Check env var first
107
+ if env_path := os.environ.get("PORTERMINAL_CONFIG_PATH"):
108
+ return Path(env_path)
109
+
110
+ base = cwd or Path.cwd()
138
111
 
112
+ # Search order: cwd first, then home
113
+ candidates = [
114
+ base / "ptn.yaml",
115
+ base / ".ptn" / "ptn.yaml",
116
+ Path.home() / ".ptn" / "ptn.yaml",
117
+ ]
139
118
 
140
- def load_config(config_path: Path | str = "config.yaml") -> Config:
119
+ for path in candidates:
120
+ if path.exists():
121
+ return path
122
+
123
+ return None # No config found, use defaults
124
+
125
+
126
+ def load_config(config_path: Path | str | None = None) -> Config:
141
127
  """Load configuration from YAML file."""
142
- config_path = Path(config_path)
128
+ if config_path is None:
129
+ config_path = find_config_file()
130
+
131
+ detector = ShellDetector()
143
132
 
144
- if not config_path.exists():
133
+ if config_path is None or not Path(config_path).exists():
145
134
  data = {}
146
135
  else:
147
136
  with open(config_path, encoding="utf-8") as f:
@@ -162,10 +151,13 @@ def load_config(config_path: Path | str = "config.yaml") -> Config:
162
151
  except Exception:
163
152
  pass
164
153
 
165
- # If no valid shells from config, auto-detect
154
+ # If no valid shells from config, auto-detect using ShellDetector
166
155
  if not valid_shells:
167
- detected = detect_available_shells()
168
- terminal_data["shells"] = [s.model_dump() for s in detected]
156
+ detected = detector.detect_shells()
157
+ terminal_data["shells"] = [
158
+ {"id": s.id, "name": s.name, "command": s.command, "args": list(s.args)}
159
+ for s in detected
160
+ ]
169
161
  else:
170
162
  terminal_data["shells"] = valid_shells
171
163
 
@@ -173,7 +165,7 @@ def load_config(config_path: Path | str = "config.yaml") -> Config:
173
165
  default_shell = terminal_data.get("default_shell", "")
174
166
  shell_ids = [s.get("id") or s.get("name", "").lower() for s in terminal_data.get("shells", [])]
175
167
  if not default_shell or default_shell not in shell_ids:
176
- terminal_data["default_shell"] = get_default_shell_id()
168
+ terminal_data["default_shell"] = detector.get_default_shell_id()
177
169
  # Make sure the default shell is in the list
178
170
  if terminal_data["default_shell"] not in shell_ids and terminal_data.get("shells"):
179
171
  terminal_data["default_shell"] = terminal_data["shells"][0].get("id", "")
porterminal/container.py CHANGED
@@ -53,13 +53,6 @@ class Container:
53
53
  # Working directory
54
54
  cwd: str | None = None
55
55
 
56
- def get_shell(self, shell_id: str | None = None) -> ShellCommand | None:
57
- """Get shell by ID or default."""
58
- target_id = shell_id or self.default_shell_id
59
-
60
- for shell in self.available_shells:
61
- if shell.id == target_id:
62
- return shell
63
-
64
- # Return first available if target not found
65
- return self.available_shells[0] if self.available_shells else None
56
+ # Security
57
+ password_hash: bytes | None = None
58
+ max_auth_attempts: int = 5
@@ -15,7 +15,6 @@ from .entities import (
15
15
 
16
16
  # Ports
17
17
  from .ports import (
18
- PTYFactory,
19
18
  PTYPort,
20
19
  SessionRepository,
21
20
  TabRepository,
@@ -87,5 +86,4 @@ __all__ = [
87
86
  "SessionRepository",
88
87
  "TabRepository",
89
88
  "PTYPort",
90
- "PTYFactory",
91
89
  ]
@@ -67,7 +67,3 @@ class OutputBuffer:
67
67
  """Clear the buffer."""
68
68
  self._buffer.clear()
69
69
  self._size = 0
70
-
71
- def __len__(self) -> int:
72
- """Return number of chunks in buffer."""
73
- return len(self._buffer)
@@ -1,6 +1,6 @@
1
1
  """Domain ports - interfaces for infrastructure to implement."""
2
2
 
3
- from .pty_port import PTYFactory, PTYPort
3
+ from .pty_port import PTYPort
4
4
  from .session_repository import SessionRepository
5
5
  from .tab_repository import TabRepository
6
6
 
@@ -8,5 +8,4 @@ __all__ = [
8
8
  "SessionRepository",
9
9
  "TabRepository",
10
10
  "PTYPort",
11
- "PTYFactory",
12
11
  ]
@@ -2,7 +2,6 @@
2
2
 
3
3
  from abc import ABC, abstractmethod
4
4
 
5
- from ..values.shell_command import ShellCommand
6
5
  from ..values.terminal_dimensions import TerminalDimensions
7
6
 
8
7
 
@@ -50,31 +49,3 @@ class PTYPort(ABC):
50
49
  def dimensions(self) -> TerminalDimensions:
51
50
  """Get current terminal dimensions."""
52
51
  ...
53
-
54
-
55
- class PTYFactory(ABC):
56
- """Abstract interface for PTY creation.
57
-
58
- Infrastructure provides platform-specific factory.
59
- """
60
-
61
- @abstractmethod
62
- def create(
63
- self,
64
- shell: ShellCommand,
65
- dimensions: TerminalDimensions,
66
- environment: dict[str, str],
67
- working_directory: str | None = None,
68
- ) -> PTYPort:
69
- """Create and spawn a new PTY.
70
-
71
- Args:
72
- shell: Shell command to run.
73
- dimensions: Initial terminal dimensions.
74
- environment: Sanitized environment variables.
75
- working_directory: Optional working directory.
76
-
77
- Returns:
78
- PTY port implementation.
79
- """
80
- ...
@@ -68,8 +68,3 @@ class TabRepository(ABC):
68
68
  def count_for_user(self, user_id: UserId) -> int:
69
69
  """Get tab count for a user."""
70
70
  ...
71
-
72
- @abstractmethod
73
- def all_tabs(self) -> list[Tab]:
74
- """Get all tabs (for cleanup iteration)."""
75
- ...
@@ -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,
@@ -1,9 +1,7 @@
1
1
  """Configuration infrastructure - loading and detection."""
2
2
 
3
3
  from .shell_detector import ShellDetector
4
- from .yaml_loader import YAMLConfigLoader
5
4
 
6
5
  __all__ = [
7
- "YAMLConfigLoader",
8
6
  "ShellDetector",
9
7
  ]