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 +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 +2 -3
- porterminal/cli/args.py +53 -0
- porterminal/cli/display.py +1 -1
- porterminal/composition.py +6 -0
- porterminal/config.py +8 -0
- porterminal/container.py +4 -0
- porterminal/infrastructure/auth.py +131 -0
- porterminal/infrastructure/cloudflared.py +5 -1
- porterminal/infrastructure/config/shell_detector.py +341 -1
- porterminal/infrastructure/server.py +28 -3
- porterminal/static/assets/app-BkHv5qu0.css +32 -0
- porterminal/static/assets/app-CaIGfw7i.js +72 -0
- porterminal/static/assets/app-D9ELFbEO.js +72 -0
- porterminal/static/assets/app-DF3nl_io.js +72 -0
- porterminal/static/assets/{app-By4EXMHC.js → app-DoBiVkTD.js} +12 -12
- porterminal/static/assets/app-azbHOsRw.css +32 -0
- porterminal/static/assets/app-nMNFwMa6.css +32 -0
- porterminal/static/index.html +14 -2
- {ptn-0.2.5.dist-info → ptn-0.3.2.dist-info}/METADATA +36 -13
- {ptn-0.2.5.dist-info → ptn-0.3.2.dist-info}/RECORD +28 -20
- {ptn-0.2.5.dist-info → ptn-0.3.2.dist-info}/WHEEL +0 -0
- {ptn-0.2.5.dist-info → ptn-0.3.2.dist-info}/entry_points.txt +0 -0
- {ptn-0.2.5.dist-info → ptn-0.3.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.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
|
-
|
|
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:
|
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}")
|
porterminal/cli/display.py
CHANGED
|
@@ -136,7 +136,7 @@ def display_startup_screen(
|
|
|
136
136
|
*tagline_colored,
|
|
137
137
|
"",
|
|
138
138
|
f"[bold yellow]{get_caution()}[/bold yellow]",
|
|
139
|
-
"[
|
|
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
|
]
|
porterminal/composition.py
CHANGED
|
@@ -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
|
@@ -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
|
|
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,
|