ptn 0.2.5__py3-none-any.whl → 0.4.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +30 -55
- porterminal/application/services/session_service.py +3 -11
- porterminal/application/services/terminal_service.py +97 -56
- porterminal/cli/args.py +91 -30
- porterminal/cli/display.py +18 -16
- porterminal/cli/script_discovery.py +112 -0
- porterminal/composition.py +8 -7
- porterminal/config.py +12 -2
- porterminal/container.py +4 -0
- porterminal/domain/__init__.py +0 -9
- porterminal/domain/entities/output_buffer.py +56 -1
- porterminal/domain/entities/tab.py +11 -10
- porterminal/domain/services/__init__.py +0 -2
- porterminal/domain/values/__init__.py +0 -4
- porterminal/domain/values/environment_rules.py +3 -0
- porterminal/infrastructure/auth.py +131 -0
- porterminal/infrastructure/cloudflared.py +18 -12
- porterminal/infrastructure/config/shell_detector.py +407 -1
- porterminal/infrastructure/repositories/in_memory_session.py +1 -4
- porterminal/infrastructure/repositories/in_memory_tab.py +2 -10
- porterminal/infrastructure/server.py +28 -3
- porterminal/pty/env.py +16 -78
- porterminal/pty/manager.py +6 -4
- porterminal/static/assets/app-DlWNJWFE.js +87 -0
- porterminal/static/assets/app-xPAM7YhQ.css +1 -0
- porterminal/static/index.html +14 -2
- porterminal/updater.py +13 -5
- {ptn-0.2.5.dist-info → ptn-0.4.2.dist-info}/METADATA +84 -23
- {ptn-0.2.5.dist-info → ptn-0.4.2.dist-info}/RECORD +37 -34
- porterminal/static/assets/app-By4EXMHC.js +0 -72
- porterminal/static/assets/app-DQePboVd.css +0 -32
- {ptn-0.2.5.dist-info → ptn-0.4.2.dist-info}/WHEEL +0 -0
- {ptn-0.2.5.dist-info → ptn-0.4.2.dist-info}/entry_points.txt +0 -0
- {ptn-0.2.5.dist-info → ptn-0.4.2.dist-info}/licenses/LICENSE +0 -0
porterminal/composition.py
CHANGED
|
@@ -14,8 +14,6 @@ from porterminal.application.services import (
|
|
|
14
14
|
from porterminal.config import find_config_file
|
|
15
15
|
from porterminal.container import Container
|
|
16
16
|
from porterminal.domain import (
|
|
17
|
-
EnvironmentRules,
|
|
18
|
-
EnvironmentSanitizer,
|
|
19
17
|
PTYPort,
|
|
20
18
|
SessionLimitChecker,
|
|
21
19
|
ShellCommand,
|
|
@@ -29,7 +27,7 @@ from porterminal.infrastructure.repositories import InMemorySessionRepository, I
|
|
|
29
27
|
|
|
30
28
|
def create_pty_factory(
|
|
31
29
|
cwd: str | None = None,
|
|
32
|
-
) -> Callable[[ShellCommand, TerminalDimensions,
|
|
30
|
+
) -> Callable[[ShellCommand, TerminalDimensions, str | None], PTYPort]:
|
|
33
31
|
"""Create a PTY factory function.
|
|
34
32
|
|
|
35
33
|
This bridges the domain PTYPort interface with the existing
|
|
@@ -40,7 +38,6 @@ def create_pty_factory(
|
|
|
40
38
|
def factory(
|
|
41
39
|
shell: ShellCommand,
|
|
42
40
|
dimensions: TerminalDimensions,
|
|
43
|
-
environment: dict[str, str],
|
|
44
41
|
working_directory: str | None = None,
|
|
45
42
|
) -> PTYPort:
|
|
46
43
|
# Use provided cwd or factory default
|
|
@@ -60,6 +57,7 @@ def create_pty_factory(
|
|
|
60
57
|
)
|
|
61
58
|
|
|
62
59
|
# Create manager (which implements PTY operations)
|
|
60
|
+
# Environment sanitization is handled internally by SecurePTYManager
|
|
63
61
|
manager = SecurePTYManager(
|
|
64
62
|
backend=backend,
|
|
65
63
|
shell_config=legacy_shell,
|
|
@@ -68,8 +66,6 @@ def create_pty_factory(
|
|
|
68
66
|
cwd=effective_cwd,
|
|
69
67
|
)
|
|
70
68
|
|
|
71
|
-
# Spawn with environment (manager handles sanitization internally,
|
|
72
|
-
# but we pass our sanitized env to be safe)
|
|
73
69
|
manager.spawn()
|
|
74
70
|
|
|
75
71
|
return PTYManagerAdapter(manager, dimensions)
|
|
@@ -112,6 +108,7 @@ class PTYManagerAdapter:
|
|
|
112
108
|
def create_container(
|
|
113
109
|
config_path: Path | str | None = None,
|
|
114
110
|
cwd: str | None = None,
|
|
111
|
+
password_hash: bytes | None = None,
|
|
115
112
|
) -> Container:
|
|
116
113
|
"""Create the dependency container with all wired dependencies.
|
|
117
114
|
|
|
@@ -121,6 +118,7 @@ def create_container(
|
|
|
121
118
|
Args:
|
|
122
119
|
config_path: Path to config file, or None to search standard locations.
|
|
123
120
|
cwd: Working directory for PTY sessions.
|
|
121
|
+
password_hash: Bcrypt hash of password for authentication (None = no auth).
|
|
124
122
|
|
|
125
123
|
Returns:
|
|
126
124
|
Fully wired dependency container.
|
|
@@ -141,6 +139,7 @@ def create_container(
|
|
|
141
139
|
# Get config values with defaults
|
|
142
140
|
server_data = config_data.get("server", {})
|
|
143
141
|
terminal_data = config_data.get("terminal", {})
|
|
142
|
+
security_data = config_data.get("security", {})
|
|
144
143
|
|
|
145
144
|
server_host = server_data.get("host", "127.0.0.1")
|
|
146
145
|
server_port = server_data.get("port", 8000)
|
|
@@ -148,6 +147,7 @@ def create_container(
|
|
|
148
147
|
default_rows = terminal_data.get("rows", 30)
|
|
149
148
|
default_shell_id = terminal_data.get("default_shell") or detector.get_default_shell_id()
|
|
150
149
|
buttons = config_data.get("buttons", [])
|
|
150
|
+
max_auth_attempts = security_data.get("max_auth_attempts", 5)
|
|
151
151
|
|
|
152
152
|
# Use configured shells if provided, otherwise use detected
|
|
153
153
|
configured_shells = terminal_data.get("shells", [])
|
|
@@ -169,7 +169,6 @@ def create_container(
|
|
|
169
169
|
repository=session_repository,
|
|
170
170
|
pty_factory=pty_factory,
|
|
171
171
|
limit_checker=SessionLimitChecker(),
|
|
172
|
-
environment_sanitizer=EnvironmentSanitizer(EnvironmentRules()),
|
|
173
172
|
working_directory=cwd,
|
|
174
173
|
)
|
|
175
174
|
|
|
@@ -213,4 +212,6 @@ def create_container(
|
|
|
213
212
|
default_rows=default_rows,
|
|
214
213
|
buttons=buttons,
|
|
215
214
|
cwd=cwd,
|
|
215
|
+
password_hash=password_hash,
|
|
216
|
+
max_auth_attempts=max_auth_attempts,
|
|
216
217
|
)
|
porterminal/config.py
CHANGED
|
@@ -7,6 +7,7 @@ from pathlib import Path
|
|
|
7
7
|
import yaml
|
|
8
8
|
from pydantic import BaseModel, Field, field_validator
|
|
9
9
|
|
|
10
|
+
from porterminal.domain.values import MAX_COLS, MAX_ROWS, MIN_COLS, MIN_ROWS
|
|
10
11
|
from porterminal.infrastructure.config import ShellDetector
|
|
11
12
|
|
|
12
13
|
|
|
@@ -43,8 +44,8 @@ class TerminalConfig(BaseModel):
|
|
|
43
44
|
"""Terminal configuration."""
|
|
44
45
|
|
|
45
46
|
default_shell: str = ""
|
|
46
|
-
cols: int = Field(default=120, ge=
|
|
47
|
-
rows: int = Field(default=30, ge=
|
|
47
|
+
cols: int = Field(default=120, ge=MIN_COLS, le=MAX_COLS)
|
|
48
|
+
rows: int = Field(default=30, ge=MIN_ROWS, le=MAX_ROWS)
|
|
48
49
|
shells: list[ShellConfig] = Field(default_factory=list)
|
|
49
50
|
|
|
50
51
|
def get_shell(self, shell_id: str) -> ShellConfig | None:
|
|
@@ -60,6 +61,7 @@ class ButtonConfig(BaseModel):
|
|
|
60
61
|
|
|
61
62
|
label: str
|
|
62
63
|
send: str | list[str | int] = "" # string or list of strings/ints (ints = wait ms)
|
|
64
|
+
row: int = Field(default=1, ge=1, le=10) # toolbar row (1-10)
|
|
63
65
|
|
|
64
66
|
|
|
65
67
|
class CloudflareConfig(BaseModel):
|
|
@@ -76,6 +78,13 @@ class UpdateConfig(BaseModel):
|
|
|
76
78
|
check_interval: int = Field(default=86400, ge=0) # Seconds between checks (0 = always)
|
|
77
79
|
|
|
78
80
|
|
|
81
|
+
class SecurityConfig(BaseModel):
|
|
82
|
+
"""Security configuration."""
|
|
83
|
+
|
|
84
|
+
require_password: bool = False # Prompt for password at startup
|
|
85
|
+
max_auth_attempts: int = Field(default=5, ge=1, le=100)
|
|
86
|
+
|
|
87
|
+
|
|
79
88
|
class Config(BaseModel):
|
|
80
89
|
"""Application configuration."""
|
|
81
90
|
|
|
@@ -84,6 +93,7 @@ class Config(BaseModel):
|
|
|
84
93
|
buttons: list[ButtonConfig] = Field(default_factory=list)
|
|
85
94
|
cloudflare: CloudflareConfig = Field(default_factory=CloudflareConfig)
|
|
86
95
|
update: UpdateConfig = Field(default_factory=UpdateConfig)
|
|
96
|
+
security: SecurityConfig = Field(default_factory=SecurityConfig)
|
|
87
97
|
|
|
88
98
|
|
|
89
99
|
def find_config_file(cwd: Path | None = None) -> Path | None:
|
porterminal/container.py
CHANGED
porterminal/domain/__init__.py
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
"""Pure domain layer - no infrastructure dependencies."""
|
|
2
2
|
|
|
3
|
-
# Value Objects
|
|
4
3
|
# Entities
|
|
5
4
|
from .entities import (
|
|
6
5
|
CLEAR_SCREEN_SEQUENCE,
|
|
@@ -23,7 +22,6 @@ from .ports import (
|
|
|
23
22
|
# Services
|
|
24
23
|
from .services import (
|
|
25
24
|
Clock,
|
|
26
|
-
EnvironmentSanitizer,
|
|
27
25
|
SessionLimitChecker,
|
|
28
26
|
SessionLimitConfig,
|
|
29
27
|
SessionLimitResult,
|
|
@@ -33,13 +31,10 @@ from .services import (
|
|
|
33
31
|
TokenBucketRateLimiter,
|
|
34
32
|
)
|
|
35
33
|
from .values import (
|
|
36
|
-
DEFAULT_BLOCKED_VARS,
|
|
37
|
-
DEFAULT_SAFE_VARS,
|
|
38
34
|
MAX_COLS,
|
|
39
35
|
MAX_ROWS,
|
|
40
36
|
MIN_COLS,
|
|
41
37
|
MIN_ROWS,
|
|
42
|
-
EnvironmentRules,
|
|
43
38
|
RateLimitConfig,
|
|
44
39
|
SessionId,
|
|
45
40
|
ShellCommand,
|
|
@@ -60,9 +55,6 @@ __all__ = [
|
|
|
60
55
|
"TabId",
|
|
61
56
|
"ShellCommand",
|
|
62
57
|
"RateLimitConfig",
|
|
63
|
-
"EnvironmentRules",
|
|
64
|
-
"DEFAULT_SAFE_VARS",
|
|
65
|
-
"DEFAULT_BLOCKED_VARS",
|
|
66
58
|
# Entities
|
|
67
59
|
"Session",
|
|
68
60
|
"MAX_SESSIONS_PER_USER",
|
|
@@ -75,7 +67,6 @@ __all__ = [
|
|
|
75
67
|
# Services
|
|
76
68
|
"TokenBucketRateLimiter",
|
|
77
69
|
"Clock",
|
|
78
|
-
"EnvironmentSanitizer",
|
|
79
70
|
"SessionLimitChecker",
|
|
80
71
|
"SessionLimitConfig",
|
|
81
72
|
"SessionLimitResult",
|
|
@@ -9,6 +9,11 @@ OUTPUT_BUFFER_MAX_BYTES = 1_000_000 # 1MB
|
|
|
9
9
|
# Terminal escape sequence for clear screen (ED2)
|
|
10
10
|
CLEAR_SCREEN_SEQUENCE = b"\x1b[2J"
|
|
11
11
|
|
|
12
|
+
# Alternate screen buffer sequences (DEC Private Mode)
|
|
13
|
+
# Used by vim, htop, less, tmux, etc.
|
|
14
|
+
ALT_SCREEN_ENTER = (b"\x1b[?47h", b"\x1b[?1047h", b"\x1b[?1049h")
|
|
15
|
+
ALT_SCREEN_EXIT = (b"\x1b[?47l", b"\x1b[?1047l", b"\x1b[?1049l")
|
|
16
|
+
|
|
12
17
|
|
|
13
18
|
@dataclass
|
|
14
19
|
class OutputBuffer:
|
|
@@ -16,12 +21,21 @@ class OutputBuffer:
|
|
|
16
21
|
|
|
17
22
|
Pure domain logic for buffering terminal output.
|
|
18
23
|
No async, no WebSocket - just data management.
|
|
24
|
+
|
|
25
|
+
Handles alternate screen buffer (used by vim, htop, less, etc.):
|
|
26
|
+
- On alt-screen enter: snapshots normal buffer, clears for alt content
|
|
27
|
+
- On alt-screen exit: restores normal buffer, discards alt content
|
|
19
28
|
"""
|
|
20
29
|
|
|
21
30
|
max_bytes: int = OUTPUT_BUFFER_MAX_BYTES
|
|
22
31
|
_buffer: deque[bytes] = field(default_factory=deque)
|
|
23
32
|
_size: int = 0
|
|
24
33
|
|
|
34
|
+
# Alt-screen state
|
|
35
|
+
_in_alt_screen: bool = False
|
|
36
|
+
_normal_snapshot: deque[bytes] | None = None
|
|
37
|
+
_normal_snapshot_size: int = 0
|
|
38
|
+
|
|
25
39
|
@property
|
|
26
40
|
def size(self) -> int:
|
|
27
41
|
"""Current buffer size in bytes."""
|
|
@@ -32,12 +46,53 @@ class OutputBuffer:
|
|
|
32
46
|
"""Check if buffer is empty."""
|
|
33
47
|
return self._size == 0
|
|
34
48
|
|
|
49
|
+
@property
|
|
50
|
+
def in_alt_screen(self) -> bool:
|
|
51
|
+
"""Check if currently in alternate screen mode."""
|
|
52
|
+
return self._in_alt_screen
|
|
53
|
+
|
|
54
|
+
def _enter_alt_screen(self) -> None:
|
|
55
|
+
"""Handle alt-screen entry: snapshot normal buffer."""
|
|
56
|
+
if self._in_alt_screen:
|
|
57
|
+
return # Already in alt-screen, ignore nested
|
|
58
|
+
self._in_alt_screen = True
|
|
59
|
+
self._normal_snapshot = self._buffer.copy()
|
|
60
|
+
self._normal_snapshot_size = self._size
|
|
61
|
+
self._clear_buffer()
|
|
62
|
+
|
|
63
|
+
def _exit_alt_screen(self) -> None:
|
|
64
|
+
"""Handle alt-screen exit: restore normal buffer."""
|
|
65
|
+
if not self._in_alt_screen:
|
|
66
|
+
return # Not in alt-screen, ignore
|
|
67
|
+
self._in_alt_screen = False
|
|
68
|
+
if self._normal_snapshot is not None:
|
|
69
|
+
self._buffer = self._normal_snapshot
|
|
70
|
+
self._size = self._normal_snapshot_size
|
|
71
|
+
self._normal_snapshot = None
|
|
72
|
+
self._normal_snapshot_size = 0
|
|
73
|
+
|
|
74
|
+
def _clear_buffer(self) -> None:
|
|
75
|
+
"""Clear the buffer contents only."""
|
|
76
|
+
self._buffer.clear()
|
|
77
|
+
self._size = 0
|
|
78
|
+
|
|
35
79
|
def add(self, data: bytes) -> None:
|
|
36
80
|
"""Add data to the buffer.
|
|
37
81
|
|
|
38
|
-
Handles clear screen detection and size limits.
|
|
82
|
+
Handles alt-screen transitions, clear screen detection, and size limits.
|
|
39
83
|
When clear screen is detected, only keep content AFTER the last clear sequence.
|
|
40
84
|
"""
|
|
85
|
+
# Check alt-screen transitions FIRST
|
|
86
|
+
for pattern in ALT_SCREEN_EXIT:
|
|
87
|
+
if pattern in data:
|
|
88
|
+
self._exit_alt_screen()
|
|
89
|
+
break
|
|
90
|
+
else:
|
|
91
|
+
for pattern in ALT_SCREEN_ENTER:
|
|
92
|
+
if pattern in data:
|
|
93
|
+
self._enter_alt_screen()
|
|
94
|
+
return # Don't buffer alt-screen enter data
|
|
95
|
+
|
|
41
96
|
# Check for clear screen sequence
|
|
42
97
|
if CLEAR_SCREEN_SEQUENCE in data:
|
|
43
98
|
# Clear old buffer
|
|
@@ -13,6 +13,15 @@ TAB_NAME_MIN_LENGTH = 1
|
|
|
13
13
|
TAB_NAME_MAX_LENGTH = 50
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
def _validate_tab_name(name: str) -> None:
|
|
17
|
+
"""Validate tab name length."""
|
|
18
|
+
if not (TAB_NAME_MIN_LENGTH <= len(name) <= TAB_NAME_MAX_LENGTH):
|
|
19
|
+
raise ValueError(
|
|
20
|
+
f"Tab name must be {TAB_NAME_MIN_LENGTH}-{TAB_NAME_MAX_LENGTH} "
|
|
21
|
+
f"characters, got {len(name)}"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
16
25
|
@dataclass
|
|
17
26
|
class Tab:
|
|
18
27
|
"""Terminal tab entity.
|
|
@@ -35,11 +44,7 @@ class Tab:
|
|
|
35
44
|
last_accessed: datetime
|
|
36
45
|
|
|
37
46
|
def __post_init__(self) -> None:
|
|
38
|
-
|
|
39
|
-
raise ValueError(
|
|
40
|
-
f"Tab name must be {TAB_NAME_MIN_LENGTH}-{TAB_NAME_MAX_LENGTH} "
|
|
41
|
-
f"characters, got {len(self.name)}"
|
|
42
|
-
)
|
|
47
|
+
_validate_tab_name(self.name)
|
|
43
48
|
|
|
44
49
|
@property
|
|
45
50
|
def tab_id(self) -> str:
|
|
@@ -52,11 +57,7 @@ class Tab:
|
|
|
52
57
|
|
|
53
58
|
def rename(self, new_name: str) -> None:
|
|
54
59
|
"""Rename the tab with validation."""
|
|
55
|
-
|
|
56
|
-
raise ValueError(
|
|
57
|
-
f"Tab name must be {TAB_NAME_MIN_LENGTH}-{TAB_NAME_MAX_LENGTH} "
|
|
58
|
-
f"characters, got {len(new_name)}"
|
|
59
|
-
)
|
|
60
|
+
_validate_tab_name(new_name)
|
|
60
61
|
self.name = new_name
|
|
61
62
|
|
|
62
63
|
def to_dict(self) -> dict:
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
"""Domain services - pure business logic operations."""
|
|
2
2
|
|
|
3
|
-
from .environment_sanitizer import EnvironmentSanitizer
|
|
4
3
|
from .rate_limiter import Clock, TokenBucketRateLimiter
|
|
5
4
|
from .session_limits import SessionLimitChecker, SessionLimitConfig, SessionLimitResult
|
|
6
5
|
from .tab_limits import TabLimitChecker, TabLimitConfig, TabLimitResult
|
|
@@ -8,7 +7,6 @@ from .tab_limits import TabLimitChecker, TabLimitConfig, TabLimitResult
|
|
|
8
7
|
__all__ = [
|
|
9
8
|
"TokenBucketRateLimiter",
|
|
10
9
|
"Clock",
|
|
11
|
-
"EnvironmentSanitizer",
|
|
12
10
|
"SessionLimitChecker",
|
|
13
11
|
"SessionLimitConfig",
|
|
14
12
|
"SessionLimitResult",
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
"""Domain value objects - immutable data structures."""
|
|
2
2
|
|
|
3
|
-
from .environment_rules import DEFAULT_BLOCKED_VARS, DEFAULT_SAFE_VARS, EnvironmentRules
|
|
4
3
|
from .rate_limit_config import RateLimitConfig
|
|
5
4
|
from .session_id import SessionId
|
|
6
5
|
from .shell_command import ShellCommand
|
|
@@ -19,7 +18,4 @@ __all__ = [
|
|
|
19
18
|
"TabId",
|
|
20
19
|
"ShellCommand",
|
|
21
20
|
"RateLimitConfig",
|
|
22
|
-
"EnvironmentRules",
|
|
23
|
-
"DEFAULT_SAFE_VARS",
|
|
24
|
-
"DEFAULT_BLOCKED_VARS",
|
|
25
21
|
]
|
|
@@ -22,12 +22,15 @@ DEFAULT_SAFE_VARS: frozenset[str] = frozenset(
|
|
|
22
22
|
"HOMEPATH",
|
|
23
23
|
"LOCALAPPDATA",
|
|
24
24
|
"APPDATA",
|
|
25
|
+
"PROGRAMDATA",
|
|
25
26
|
"PROGRAMFILES",
|
|
26
27
|
"PROGRAMFILES(X86)",
|
|
27
28
|
"COMMONPROGRAMFILES",
|
|
28
29
|
# System info
|
|
29
30
|
"COMPUTERNAME",
|
|
30
31
|
"USERNAME",
|
|
32
|
+
"USER",
|
|
33
|
+
"LOGNAME",
|
|
31
34
|
"USERDOMAIN",
|
|
32
35
|
"OS",
|
|
33
36
|
"PROCESSOR_ARCHITECTURE",
|
|
@@ -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)
|
|
@@ -17,6 +17,13 @@ console = Console()
|
|
|
17
17
|
class CloudflaredInstaller:
|
|
18
18
|
"""Platform-specific cloudflared installer."""
|
|
19
19
|
|
|
20
|
+
@staticmethod
|
|
21
|
+
def _add_to_path(path: str | Path) -> None:
|
|
22
|
+
"""Add directory to PATH for current process."""
|
|
23
|
+
path_str = str(path)
|
|
24
|
+
os.environ["PATH"] = path_str + os.pathsep + os.environ.get("PATH", "")
|
|
25
|
+
console.print(f"[dim]Added to PATH: {path_str}[/dim]")
|
|
26
|
+
|
|
20
27
|
@staticmethod
|
|
21
28
|
def is_installed() -> bool:
|
|
22
29
|
"""Check if cloudflared is installed."""
|
|
@@ -91,8 +98,7 @@ class CloudflaredInstaller:
|
|
|
91
98
|
# Try to find and add to PATH for current session
|
|
92
99
|
install_path = CloudflaredInstaller._find_cloudflared_windows()
|
|
93
100
|
if install_path:
|
|
94
|
-
|
|
95
|
-
console.print(f"[dim]Added to PATH: {install_path}[/dim]")
|
|
101
|
+
CloudflaredInstaller._add_to_path(install_path)
|
|
96
102
|
# Return True regardless - winget succeeded, may just need shell restart
|
|
97
103
|
return True
|
|
98
104
|
except (subprocess.TimeoutExpired, OSError) as e:
|
|
@@ -119,7 +125,7 @@ class CloudflaredInstaller:
|
|
|
119
125
|
|
|
120
126
|
exe_path = install_dir / "cloudflared.exe"
|
|
121
127
|
if exe_path.exists():
|
|
122
|
-
|
|
128
|
+
CloudflaredInstaller._add_to_path(install_dir)
|
|
123
129
|
console.print(f"[green]✓ Installed to {install_dir}[/green]")
|
|
124
130
|
return True
|
|
125
131
|
|
|
@@ -172,12 +178,16 @@ class CloudflaredInstaller:
|
|
|
172
178
|
try:
|
|
173
179
|
# Add Cloudflare repo first for apt
|
|
174
180
|
if name == "apt":
|
|
181
|
+
# Use "any" distribution - works on all Debian-based systems
|
|
182
|
+
# (Ubuntu, Debian, Mint, Pop!_OS, etc.) without codename detection
|
|
183
|
+
# See: https://pkg.cloudflare.com/
|
|
175
184
|
subprocess.run(
|
|
176
185
|
[
|
|
177
186
|
"bash",
|
|
178
187
|
"-c",
|
|
188
|
+
"sudo mkdir -p --mode=0755 /usr/share/keyrings && "
|
|
179
189
|
"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
|
|
190
|
+
"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
191
|
"sudo apt-get update",
|
|
182
192
|
],
|
|
183
193
|
capture_output=True,
|
|
@@ -199,10 +209,7 @@ class CloudflaredInstaller:
|
|
|
199
209
|
# Try to find and add to PATH
|
|
200
210
|
install_path = CloudflaredInstaller._find_cloudflared_unix()
|
|
201
211
|
if install_path:
|
|
202
|
-
|
|
203
|
-
install_path + os.pathsep + os.environ.get("PATH", "")
|
|
204
|
-
)
|
|
205
|
-
console.print(f"[dim]Added to PATH: {install_path}[/dim]")
|
|
212
|
+
CloudflaredInstaller._add_to_path(install_path)
|
|
206
213
|
# Return True regardless - package manager succeeded
|
|
207
214
|
return True
|
|
208
215
|
except (subprocess.TimeoutExpired, OSError) as e:
|
|
@@ -222,7 +229,7 @@ class CloudflaredInstaller:
|
|
|
222
229
|
os.chmod(bin_path, 0o755)
|
|
223
230
|
|
|
224
231
|
# Add to PATH for this session
|
|
225
|
-
|
|
232
|
+
CloudflaredInstaller._add_to_path(install_dir)
|
|
226
233
|
console.print(f"[green]✓ Installed to {bin_path}[/green]")
|
|
227
234
|
return True
|
|
228
235
|
|
|
@@ -253,8 +260,7 @@ class CloudflaredInstaller:
|
|
|
253
260
|
# Try to find and add to PATH
|
|
254
261
|
install_path = CloudflaredInstaller._find_cloudflared_unix()
|
|
255
262
|
if install_path:
|
|
256
|
-
|
|
257
|
-
console.print(f"[dim]Added to PATH: {install_path}[/dim]")
|
|
263
|
+
CloudflaredInstaller._add_to_path(install_path)
|
|
258
264
|
# Return True regardless - Homebrew succeeded
|
|
259
265
|
return True
|
|
260
266
|
except (subprocess.TimeoutExpired, OSError) as e:
|
|
@@ -285,7 +291,7 @@ class CloudflaredInstaller:
|
|
|
285
291
|
bin_path = install_dir / "cloudflared"
|
|
286
292
|
if bin_path.exists():
|
|
287
293
|
os.chmod(bin_path, 0o755)
|
|
288
|
-
|
|
294
|
+
CloudflaredInstaller._add_to_path(install_dir)
|
|
289
295
|
console.print(f"[green]✓ Installed to {bin_path}[/green]")
|
|
290
296
|
return True
|
|
291
297
|
|