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.
- porterminal/__init__.py +82 -14
- porterminal/_version.py +34 -0
- porterminal/app.py +32 -4
- 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/application/services/terminal_service.py +116 -28
- porterminal/asgi.py +8 -3
- porterminal/cli/args.py +103 -0
- porterminal/cli/display.py +1 -1
- porterminal/composition.py +19 -5
- porterminal/config.py +62 -70
- porterminal/container.py +3 -10
- porterminal/domain/__init__.py +0 -2
- porterminal/domain/entities/output_buffer.py +0 -4
- porterminal/domain/ports/__init__.py +1 -2
- porterminal/domain/ports/pty_port.py +0 -29
- porterminal/domain/ports/tab_repository.py +0 -5
- porterminal/infrastructure/auth.py +131 -0
- porterminal/infrastructure/cloudflared.py +5 -1
- porterminal/infrastructure/config/__init__.py +0 -2
- porterminal/infrastructure/config/shell_detector.py +342 -1
- porterminal/infrastructure/repositories/in_memory_tab.py +0 -4
- porterminal/infrastructure/server.py +37 -5
- 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-DQePboVd.css +32 -0
- porterminal/static/assets/app-DoBiVkTD.js +72 -0
- porterminal/static/assets/app-azbHOsRw.css +32 -0
- porterminal/static/assets/app-nMNFwMa6.css +32 -0
- porterminal/static/index.html +28 -25
- porterminal/updater.py +115 -168
- ptn-0.3.2.dist-info/METADATA +171 -0
- {ptn-0.1.4.dist-info → ptn-0.3.2.dist-info}/RECORD +39 -33
- porterminal/infrastructure/config/yaml_loader.py +0 -34
- porterminal/static/assets/app-BQiuUo6Q.css +0 -32
- porterminal/static/assets/app-YNN_jEhv.js +0 -71
- porterminal/static/manifest.json +0 -31
- porterminal/static/sw.js +0 -66
- ptn-0.1.4.dist-info/METADATA +0 -191
- {ptn-0.1.4.dist-info → ptn-0.3.2.dist-info}/WHEEL +0 -0
- {ptn-0.1.4.dist-info → ptn-0.3.2.dist-info}/entry_points.txt +0 -0
- {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}")
|
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
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
168
|
-
terminal_data["shells"] = [
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
porterminal/domain/__init__.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Domain ports - interfaces for infrastructure to implement."""
|
|
2
2
|
|
|
3
|
-
from .pty_port import
|
|
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
|
-
...
|
|
@@ -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,
|