ptn 0.2.7__tar.gz → 0.4.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ptn-0.2.7 → ptn-0.4.2}/PKG-INFO +70 -19
- {ptn-0.2.7 → ptn-0.4.2}/README.md +68 -18
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/__init__.py +37 -8
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/_version.py +2 -2
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/app.py +25 -1
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/application/ports/__init__.py +2 -0
- ptn-0.4.2/porterminal/application/ports/connection_registry_port.py +46 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/application/services/management_service.py +30 -55
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/application/services/session_service.py +3 -11
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/application/services/terminal_service.py +97 -56
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/cli/args.py +91 -30
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/cli/display.py +18 -16
- ptn-0.4.2/porterminal/cli/script_discovery.py +112 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/composition.py +8 -7
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/config.py +12 -2
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/container.py +4 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/__init__.py +0 -9
- ptn-0.4.2/porterminal/domain/entities/output_buffer.py +124 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/entities/tab.py +11 -10
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/services/__init__.py +0 -2
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/values/__init__.py +0 -4
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/values/environment_rules.py +3 -0
- ptn-0.4.2/porterminal/infrastructure/auth.py +131 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/infrastructure/cloudflared.py +13 -11
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/infrastructure/config/shell_detector.py +115 -16
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/infrastructure/repositories/in_memory_session.py +1 -4
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/infrastructure/repositories/in_memory_tab.py +2 -10
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/infrastructure/server.py +14 -2
- ptn-0.4.2/porterminal/pty/env.py +35 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/pty/manager.py +6 -4
- ptn-0.4.2/porterminal/static/assets/app-DlWNJWFE.js +87 -0
- ptn-0.4.2/porterminal/static/assets/app-xPAM7YhQ.css +1 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/static/index.html +14 -2
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/updater.py +13 -5
- {ptn-0.2.7 → ptn-0.4.2}/pyproject.toml +1 -0
- ptn-0.2.7/porterminal/domain/entities/output_buffer.py +0 -69
- ptn-0.2.7/porterminal/pty/env.py +0 -97
- ptn-0.2.7/porterminal/static/assets/app-DQePboVd.css +0 -32
- ptn-0.2.7/porterminal/static/assets/app-DoBiVkTD.js +0 -72
- {ptn-0.2.7 → ptn-0.4.2}/.gitignore +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/LICENSE +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/__main__.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/application/__init__.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/application/ports/connection_port.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/application/services/__init__.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/application/services/tab_service.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/asgi.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/cli/__init__.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/entities/__init__.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/entities/session.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/ports/__init__.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/ports/pty_port.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/ports/session_repository.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/ports/tab_repository.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/services/environment_sanitizer.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/services/rate_limiter.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/services/session_limits.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/services/tab_limits.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/values/rate_limit_config.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/values/session_id.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/values/shell_command.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/values/tab_id.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/values/terminal_dimensions.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/domain/values/user_id.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/infrastructure/__init__.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/infrastructure/config/__init__.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/infrastructure/network.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/infrastructure/registry/__init__.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/infrastructure/registry/user_connection_registry.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/infrastructure/repositories/__init__.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/infrastructure/web/__init__.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/infrastructure/web/websocket_adapter.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/logging_setup.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/pty/__init__.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/pty/protocol.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/pty/unix.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/pty/windows.py +0 -0
- {ptn-0.2.7 → ptn-0.4.2}/porterminal/static/icon.svg +0 -0
{ptn-0.2.7 → ptn-0.4.2}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ptn
|
|
3
|
-
Version: 0.2
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: Web-based terminal accessible from phone via Cloudflare Tunnel
|
|
5
5
|
Project-URL: Homepage, https://github.com/lyehe/porterminal
|
|
6
6
|
Project-URL: Repository, https://github.com/lyehe/porterminal
|
|
@@ -26,6 +26,7 @@ Classifier: Topic :: Internet :: WWW/HTTP
|
|
|
26
26
|
Classifier: Topic :: System :: Shells
|
|
27
27
|
Classifier: Topic :: Terminals :: Terminal Emulators/X Terminals
|
|
28
28
|
Requires-Python: >=3.12
|
|
29
|
+
Requires-Dist: bcrypt>=4.0.0
|
|
29
30
|
Requires-Dist: fastapi>=0.104.0
|
|
30
31
|
Requires-Dist: pydantic>=2.0
|
|
31
32
|
Requires-Dist: pywinpty>=2.0.0; sys_platform == 'win32'
|
|
@@ -74,9 +75,10 @@ So I built something simpler: **run a command, scan a QR, start typing.**
|
|
|
74
75
|
## Features
|
|
75
76
|
|
|
76
77
|
- **One command, instant access** - No SSH, no port forwarding, no config files. Cloudflare tunnel + QR code.
|
|
77
|
-
- **Actually usable on mobile** -
|
|
78
|
-
- **
|
|
79
|
-
- **
|
|
78
|
+
- **Actually usable on mobile** - Touch-optimized with momentum scrolling, pinch-to-zoom, swipe gestures, and modifier keys (Ctrl, Alt).
|
|
79
|
+
- **Full terminal apps** - vim, htop, less, tmux all work correctly with proper alt-screen buffer handling.
|
|
80
|
+
- **Persistent multi-tab sessions** - Sessions survive disconnects. Close the browser, switch networks, reconnect from another device—your shell and running processes are still there. Multiple devices can view the same session simultaneously.
|
|
81
|
+
- **Cross-platform** - Windows (PowerShell, CMD, WSL), Linux/macOS (Bash, Zsh, Fish, Nushell, and any shell via `$SHELL`). Auto-detects your shells.
|
|
80
82
|
|
|
81
83
|
## Install
|
|
82
84
|
|
|
@@ -101,46 +103,95 @@ Requires Python 3.12+ and [cloudflared](https://developers.cloudflare.com/cloudf
|
|
|
101
103
|
```bash
|
|
102
104
|
ptn # Start in current directory
|
|
103
105
|
ptn ~/projects/myapp # Start in specific folder
|
|
104
|
-
ptn --no-tunnel # Local network only
|
|
105
|
-
ptn -b # Run in background
|
|
106
|
-
ptn -v # Verbose startup logs
|
|
107
|
-
ptn --init # Create .ptn/ptn.yaml config
|
|
108
|
-
ptn -V # Show version
|
|
109
|
-
ptn -U # Update to latest version
|
|
110
|
-
ptn --check-update # Check if update available
|
|
111
106
|
```
|
|
112
107
|
|
|
108
|
+
| Flag | Description |
|
|
109
|
+
|------|-------------|
|
|
110
|
+
| `-n, --no-tunnel` | Local network only (no Cloudflare tunnel) |
|
|
111
|
+
| `-b, --background` | Run in background and return immediately |
|
|
112
|
+
| `-p, --password` | Prompt for password to protect this session |
|
|
113
|
+
| `-dp, --default-password` | Toggle password requirement in config (on/off) |
|
|
114
|
+
| `-v, --verbose` | Show detailed startup logs |
|
|
115
|
+
| `-i, --init` | Create `.ptn/ptn.yaml` config with auto-discovered project scripts |
|
|
116
|
+
| `-u, --update` | Update to the latest version |
|
|
117
|
+
| `-c, --check-update` | Check if a newer version is available |
|
|
118
|
+
| `-V, --version` | Show version |
|
|
119
|
+
|
|
120
|
+
## Mobile Gestures
|
|
121
|
+
|
|
122
|
+
| Gesture | Action |
|
|
123
|
+
|---------|--------|
|
|
124
|
+
| **Tap** | Focus terminal, clear selection |
|
|
125
|
+
| **Long-press** | Start text selection |
|
|
126
|
+
| **Double-tap** | Select word |
|
|
127
|
+
| **Swipe left/right** | Arrow keys (← →) |
|
|
128
|
+
| **Scroll** | Momentum scrolling with physics |
|
|
129
|
+
| **Pinch** | Zoom text (10-24px) |
|
|
130
|
+
|
|
131
|
+
**Modifier keys** (Ctrl, Alt, Shift): Tap once for sticky (one keystroke), double-tap for lock.
|
|
132
|
+
|
|
113
133
|
## Configuration
|
|
114
134
|
|
|
115
|
-
Run `ptn --init` to create a starter config
|
|
135
|
+
Run `ptn --init` to create a starter config. It auto-discovers project scripts from `package.json`, `pyproject.toml`, or `Makefile` and adds them as buttons:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
ptn -i
|
|
139
|
+
# Created: .ptn/ptn.yaml
|
|
140
|
+
# Discovered 3 project script(s): build, dev, test
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Or create `ptn.yaml` manually:
|
|
116
144
|
|
|
117
145
|
```yaml
|
|
146
|
+
# Terminal settings
|
|
147
|
+
terminal:
|
|
148
|
+
default_shell: nu # Default shell ID
|
|
149
|
+
shells: # Custom shell definitions
|
|
150
|
+
- id: nu
|
|
151
|
+
name: Nushell
|
|
152
|
+
command: nu
|
|
153
|
+
args: []
|
|
154
|
+
|
|
118
155
|
# Custom buttons (appear in toolbar)
|
|
156
|
+
# row: 1 = default row, 2+ = additional rows
|
|
119
157
|
buttons:
|
|
120
158
|
- label: "claude"
|
|
121
159
|
send:
|
|
122
160
|
- "claude"
|
|
123
161
|
- 100 # delay in ms
|
|
124
162
|
- "\r"
|
|
125
|
-
- label: "
|
|
126
|
-
send: "
|
|
163
|
+
- label: "build"
|
|
164
|
+
send: "npm run build\r"
|
|
165
|
+
row: 2 # second button row
|
|
127
166
|
|
|
128
167
|
# Update checker settings
|
|
129
168
|
update:
|
|
130
169
|
notify_on_startup: true # Show update notification
|
|
131
170
|
check_interval: 86400 # Seconds between checks (default: 24h)
|
|
171
|
+
|
|
172
|
+
# Security settings
|
|
173
|
+
security:
|
|
174
|
+
require_password: true # Always prompt for password at startup
|
|
175
|
+
max_auth_attempts: 5 # Max failed attempts before disconnect
|
|
132
176
|
```
|
|
133
177
|
|
|
134
178
|
Config is searched in order: `$PORTERMINAL_CONFIG_PATH`, `./ptn.yaml`, `./.ptn/ptn.yaml`, `~/.ptn/ptn.yaml`.
|
|
135
179
|
|
|
136
180
|
## Security
|
|
137
181
|
|
|
138
|
-
|
|
182
|
+
Use password if your screen can be exposed to others:
|
|
183
|
+
```bash
|
|
184
|
+
ptn -p # Prompt for password this session
|
|
185
|
+
ptn -dp # Enable password by default (toggle)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Password is per-session (never saved to disk). See [docs/security.md](docs/security.md) for details.
|
|
189
|
+
|
|
190
|
+
## Troubleshooting
|
|
191
|
+
|
|
192
|
+
**Connection fails?** Cloudflare tunnel sometimes blocks connections. Restart the server (`Ctrl+C`, then `ptn`) to get a fresh tunnel URL.
|
|
139
193
|
|
|
140
|
-
|
|
141
|
-
- Stop the server when not in use (`Ctrl+C`)
|
|
142
|
-
- Use `--no-tunnel` for local network only
|
|
143
|
-
- Environment variables are sanitized (API keys, tokens stripped)
|
|
194
|
+
**Shell not detected?** Set your `$SHELL` environment variable or configure shells in `ptn.yaml`.
|
|
144
195
|
|
|
145
196
|
## Contributing
|
|
146
197
|
|
|
@@ -35,9 +35,10 @@ So I built something simpler: **run a command, scan a QR, start typing.**
|
|
|
35
35
|
## Features
|
|
36
36
|
|
|
37
37
|
- **One command, instant access** - No SSH, no port forwarding, no config files. Cloudflare tunnel + QR code.
|
|
38
|
-
- **Actually usable on mobile** -
|
|
39
|
-
- **
|
|
40
|
-
- **
|
|
38
|
+
- **Actually usable on mobile** - Touch-optimized with momentum scrolling, pinch-to-zoom, swipe gestures, and modifier keys (Ctrl, Alt).
|
|
39
|
+
- **Full terminal apps** - vim, htop, less, tmux all work correctly with proper alt-screen buffer handling.
|
|
40
|
+
- **Persistent multi-tab sessions** - Sessions survive disconnects. Close the browser, switch networks, reconnect from another device—your shell and running processes are still there. Multiple devices can view the same session simultaneously.
|
|
41
|
+
- **Cross-platform** - Windows (PowerShell, CMD, WSL), Linux/macOS (Bash, Zsh, Fish, Nushell, and any shell via `$SHELL`). Auto-detects your shells.
|
|
41
42
|
|
|
42
43
|
## Install
|
|
43
44
|
|
|
@@ -62,46 +63,95 @@ Requires Python 3.12+ and [cloudflared](https://developers.cloudflare.com/cloudf
|
|
|
62
63
|
```bash
|
|
63
64
|
ptn # Start in current directory
|
|
64
65
|
ptn ~/projects/myapp # Start in specific folder
|
|
65
|
-
ptn --no-tunnel # Local network only
|
|
66
|
-
ptn -b # Run in background
|
|
67
|
-
ptn -v # Verbose startup logs
|
|
68
|
-
ptn --init # Create .ptn/ptn.yaml config
|
|
69
|
-
ptn -V # Show version
|
|
70
|
-
ptn -U # Update to latest version
|
|
71
|
-
ptn --check-update # Check if update available
|
|
72
66
|
```
|
|
73
67
|
|
|
68
|
+
| Flag | Description |
|
|
69
|
+
|------|-------------|
|
|
70
|
+
| `-n, --no-tunnel` | Local network only (no Cloudflare tunnel) |
|
|
71
|
+
| `-b, --background` | Run in background and return immediately |
|
|
72
|
+
| `-p, --password` | Prompt for password to protect this session |
|
|
73
|
+
| `-dp, --default-password` | Toggle password requirement in config (on/off) |
|
|
74
|
+
| `-v, --verbose` | Show detailed startup logs |
|
|
75
|
+
| `-i, --init` | Create `.ptn/ptn.yaml` config with auto-discovered project scripts |
|
|
76
|
+
| `-u, --update` | Update to the latest version |
|
|
77
|
+
| `-c, --check-update` | Check if a newer version is available |
|
|
78
|
+
| `-V, --version` | Show version |
|
|
79
|
+
|
|
80
|
+
## Mobile Gestures
|
|
81
|
+
|
|
82
|
+
| Gesture | Action |
|
|
83
|
+
|---------|--------|
|
|
84
|
+
| **Tap** | Focus terminal, clear selection |
|
|
85
|
+
| **Long-press** | Start text selection |
|
|
86
|
+
| **Double-tap** | Select word |
|
|
87
|
+
| **Swipe left/right** | Arrow keys (← →) |
|
|
88
|
+
| **Scroll** | Momentum scrolling with physics |
|
|
89
|
+
| **Pinch** | Zoom text (10-24px) |
|
|
90
|
+
|
|
91
|
+
**Modifier keys** (Ctrl, Alt, Shift): Tap once for sticky (one keystroke), double-tap for lock.
|
|
92
|
+
|
|
74
93
|
## Configuration
|
|
75
94
|
|
|
76
|
-
Run `ptn --init` to create a starter config
|
|
95
|
+
Run `ptn --init` to create a starter config. It auto-discovers project scripts from `package.json`, `pyproject.toml`, or `Makefile` and adds them as buttons:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
ptn -i
|
|
99
|
+
# Created: .ptn/ptn.yaml
|
|
100
|
+
# Discovered 3 project script(s): build, dev, test
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Or create `ptn.yaml` manually:
|
|
77
104
|
|
|
78
105
|
```yaml
|
|
106
|
+
# Terminal settings
|
|
107
|
+
terminal:
|
|
108
|
+
default_shell: nu # Default shell ID
|
|
109
|
+
shells: # Custom shell definitions
|
|
110
|
+
- id: nu
|
|
111
|
+
name: Nushell
|
|
112
|
+
command: nu
|
|
113
|
+
args: []
|
|
114
|
+
|
|
79
115
|
# Custom buttons (appear in toolbar)
|
|
116
|
+
# row: 1 = default row, 2+ = additional rows
|
|
80
117
|
buttons:
|
|
81
118
|
- label: "claude"
|
|
82
119
|
send:
|
|
83
120
|
- "claude"
|
|
84
121
|
- 100 # delay in ms
|
|
85
122
|
- "\r"
|
|
86
|
-
- label: "
|
|
87
|
-
send: "
|
|
123
|
+
- label: "build"
|
|
124
|
+
send: "npm run build\r"
|
|
125
|
+
row: 2 # second button row
|
|
88
126
|
|
|
89
127
|
# Update checker settings
|
|
90
128
|
update:
|
|
91
129
|
notify_on_startup: true # Show update notification
|
|
92
130
|
check_interval: 86400 # Seconds between checks (default: 24h)
|
|
131
|
+
|
|
132
|
+
# Security settings
|
|
133
|
+
security:
|
|
134
|
+
require_password: true # Always prompt for password at startup
|
|
135
|
+
max_auth_attempts: 5 # Max failed attempts before disconnect
|
|
93
136
|
```
|
|
94
137
|
|
|
95
138
|
Config is searched in order: `$PORTERMINAL_CONFIG_PATH`, `./ptn.yaml`, `./.ptn/ptn.yaml`, `~/.ptn/ptn.yaml`.
|
|
96
139
|
|
|
97
140
|
## Security
|
|
98
141
|
|
|
99
|
-
|
|
142
|
+
Use password if your screen can be exposed to others:
|
|
143
|
+
```bash
|
|
144
|
+
ptn -p # Prompt for password this session
|
|
145
|
+
ptn -dp # Enable password by default (toggle)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Password is per-session (never saved to disk). See [docs/security.md](docs/security.md) for details.
|
|
149
|
+
|
|
150
|
+
## Troubleshooting
|
|
151
|
+
|
|
152
|
+
**Connection fails?** Cloudflare tunnel sometimes blocks connections. Restart the server (`Ctrl+C`, then `ptn`) to get a fresh tunnel URL.
|
|
100
153
|
|
|
101
|
-
|
|
102
|
-
- Stop the server when not in use (`Ctrl+C`)
|
|
103
|
-
- Use `--no-tunnel` for local network only
|
|
104
|
-
- Environment variables are sanitized (API keys, tokens stripped)
|
|
154
|
+
**Shell not detected?** Set your `$SHELL` environment variable or configure shells in `ptn.yaml`.
|
|
105
155
|
|
|
106
156
|
## Contributing
|
|
107
157
|
|
|
@@ -14,6 +14,7 @@ except ImportError:
|
|
|
14
14
|
__version__ = "0.0.0-dev" # Fallback before first build
|
|
15
15
|
|
|
16
16
|
import os
|
|
17
|
+
import signal
|
|
17
18
|
import subprocess
|
|
18
19
|
import sys
|
|
19
20
|
import time
|
|
@@ -149,6 +150,30 @@ def main() -> int:
|
|
|
149
150
|
check_and_notify()
|
|
150
151
|
verbose = args.verbose
|
|
151
152
|
|
|
153
|
+
# Load config to check require_password setting
|
|
154
|
+
from porterminal.config import get_config
|
|
155
|
+
|
|
156
|
+
config = get_config()
|
|
157
|
+
|
|
158
|
+
# Handle password mode (CLI flag or config setting)
|
|
159
|
+
if args.password or config.security.require_password:
|
|
160
|
+
import getpass
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
password = getpass.getpass("Enter password: ")
|
|
164
|
+
if not password:
|
|
165
|
+
console.print("[red]Error:[/red] Password cannot be empty")
|
|
166
|
+
return 1
|
|
167
|
+
|
|
168
|
+
import bcrypt
|
|
169
|
+
|
|
170
|
+
password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt())
|
|
171
|
+
os.environ["PORTERMINAL_PASSWORD_HASH"] = password_hash.decode()
|
|
172
|
+
console.print("[green]Password protection enabled[/green]")
|
|
173
|
+
except KeyboardInterrupt:
|
|
174
|
+
console.print("\n[dim]Cancelled[/dim]")
|
|
175
|
+
return 0
|
|
176
|
+
|
|
152
177
|
# Handle background mode
|
|
153
178
|
if args.background:
|
|
154
179
|
return _run_in_background(args)
|
|
@@ -170,9 +195,6 @@ def main() -> int:
|
|
|
170
195
|
cwd_str = str(cwd)
|
|
171
196
|
os.environ["PORTERMINAL_CWD"] = cwd_str
|
|
172
197
|
|
|
173
|
-
from porterminal.config import get_config
|
|
174
|
-
|
|
175
|
-
config = get_config()
|
|
176
198
|
bind_host = config.server.host
|
|
177
199
|
preferred_port = config.server.port
|
|
178
200
|
port = preferred_port
|
|
@@ -226,6 +248,10 @@ def main() -> int:
|
|
|
226
248
|
status.update("[cyan]Establishing tunnel...[/cyan]")
|
|
227
249
|
tunnel_process, tunnel_url = start_cloudflared(port)
|
|
228
250
|
|
|
251
|
+
if tunnel_url:
|
|
252
|
+
# Wait for tunnel to stabilize before showing URL
|
|
253
|
+
time.sleep(1)
|
|
254
|
+
|
|
229
255
|
if not tunnel_url:
|
|
230
256
|
console.print("[red]Error:[/red] Failed to establish tunnel")
|
|
231
257
|
for proc in [server_process, tunnel_process]:
|
|
@@ -314,11 +340,14 @@ def main() -> int:
|
|
|
314
340
|
proc.kill()
|
|
315
341
|
proc.wait()
|
|
316
342
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
343
|
+
# Ignore Ctrl+C during cleanup to prevent orphaned processes
|
|
344
|
+
# Cleanup has timeouts so it won't hang forever
|
|
345
|
+
old_handler = signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
346
|
+
try:
|
|
347
|
+
cleanup_process(server_process, "server")
|
|
348
|
+
cleanup_process(tunnel_process, "tunnel")
|
|
349
|
+
finally:
|
|
350
|
+
signal.signal(signal.SIGINT, old_handler)
|
|
322
351
|
|
|
323
352
|
return 0
|
|
324
353
|
|
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '0.2
|
|
32
|
-
__version_tuple__ = version_tuple = (0,
|
|
31
|
+
__version__ = version = '0.4.2'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 4, 2)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
|
@@ -17,6 +17,7 @@ from . import __version__
|
|
|
17
17
|
from .composition import create_container
|
|
18
18
|
from .container import Container
|
|
19
19
|
from .domain import UserId
|
|
20
|
+
from .infrastructure.auth import authenticate_connection, validate_auth_message
|
|
20
21
|
from .infrastructure.web import FastAPIWebSocketAdapter
|
|
21
22
|
from .logging_setup import setup_logging_from_env
|
|
22
23
|
|
|
@@ -55,7 +56,12 @@ async def lifespan(app: FastAPI):
|
|
|
55
56
|
# config_path=None uses find_config_file() to search standard locations
|
|
56
57
|
cwd = os.environ.get("PORTERMINAL_CWD")
|
|
57
58
|
|
|
58
|
-
|
|
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:
|
|
@@ -37,6 +36,23 @@ class ManagementService:
|
|
|
37
36
|
self._get_shell = shell_provider
|
|
38
37
|
self._default_dims = default_dimensions
|
|
39
38
|
|
|
39
|
+
async def _send_error(
|
|
40
|
+
self,
|
|
41
|
+
connection: ConnectionPort,
|
|
42
|
+
response_type: str,
|
|
43
|
+
request_id: str,
|
|
44
|
+
error: str,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Send an error response to a connection."""
|
|
47
|
+
await connection.send_message(
|
|
48
|
+
{
|
|
49
|
+
"type": response_type,
|
|
50
|
+
"request_id": request_id,
|
|
51
|
+
"success": False,
|
|
52
|
+
"error": error,
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
|
|
40
56
|
async def handle_message(
|
|
41
57
|
self,
|
|
42
58
|
user_id: UserId,
|
|
@@ -77,13 +93,8 @@ class ManagementService:
|
|
|
77
93
|
# Get shell
|
|
78
94
|
shell = self._get_shell(shell_id)
|
|
79
95
|
if not shell:
|
|
80
|
-
await
|
|
81
|
-
|
|
82
|
-
"type": "create_tab_response",
|
|
83
|
-
"request_id": request_id,
|
|
84
|
-
"success": False,
|
|
85
|
-
"error": "Invalid shell",
|
|
86
|
-
}
|
|
96
|
+
await self._send_error(
|
|
97
|
+
connection, "create_tab_response", request_id, "Invalid shell"
|
|
87
98
|
)
|
|
88
99
|
return
|
|
89
100
|
|
|
@@ -128,14 +139,7 @@ class ManagementService:
|
|
|
128
139
|
|
|
129
140
|
except ValueError as e:
|
|
130
141
|
logger.warning("Tab creation failed: %s", e)
|
|
131
|
-
await
|
|
132
|
-
{
|
|
133
|
-
"type": "create_tab_response",
|
|
134
|
-
"request_id": request_id,
|
|
135
|
-
"success": False,
|
|
136
|
-
"error": str(e),
|
|
137
|
-
}
|
|
138
|
-
)
|
|
142
|
+
await self._send_error(connection, "create_tab_response", request_id, str(e))
|
|
139
143
|
|
|
140
144
|
async def _handle_close_tab(
|
|
141
145
|
self,
|
|
@@ -148,27 +152,13 @@ class ManagementService:
|
|
|
148
152
|
tab_id = message.get("tab_id")
|
|
149
153
|
|
|
150
154
|
if not tab_id:
|
|
151
|
-
await
|
|
152
|
-
{
|
|
153
|
-
"type": "close_tab_response",
|
|
154
|
-
"request_id": request_id,
|
|
155
|
-
"success": False,
|
|
156
|
-
"error": "Missing tab_id",
|
|
157
|
-
}
|
|
158
|
-
)
|
|
155
|
+
await self._send_error(connection, "close_tab_response", request_id, "Missing tab_id")
|
|
159
156
|
return
|
|
160
157
|
|
|
161
158
|
# Get tab and session info before closing
|
|
162
159
|
tab = self._tab_service.get_tab(tab_id)
|
|
163
160
|
if not tab:
|
|
164
|
-
await
|
|
165
|
-
{
|
|
166
|
-
"type": "close_tab_response",
|
|
167
|
-
"request_id": request_id,
|
|
168
|
-
"success": False,
|
|
169
|
-
"error": "Tab not found",
|
|
170
|
-
}
|
|
171
|
-
)
|
|
161
|
+
await self._send_error(connection, "close_tab_response", request_id, "Tab not found")
|
|
172
162
|
return
|
|
173
163
|
|
|
174
164
|
session_id = tab.session_id
|
|
@@ -176,13 +166,8 @@ class ManagementService:
|
|
|
176
166
|
# Close the tab
|
|
177
167
|
closed_tab = self._tab_service.close_tab(tab_id, user_id)
|
|
178
168
|
if not closed_tab:
|
|
179
|
-
await
|
|
180
|
-
|
|
181
|
-
"type": "close_tab_response",
|
|
182
|
-
"request_id": request_id,
|
|
183
|
-
"success": False,
|
|
184
|
-
"error": "Failed to close tab",
|
|
185
|
-
}
|
|
169
|
+
await self._send_error(
|
|
170
|
+
connection, "close_tab_response", request_id, "Failed to close tab"
|
|
186
171
|
)
|
|
187
172
|
return
|
|
188
173
|
|
|
@@ -223,26 +208,16 @@ class ManagementService:
|
|
|
223
208
|
new_name = message.get("name")
|
|
224
209
|
|
|
225
210
|
if not tab_id or not new_name:
|
|
226
|
-
await
|
|
227
|
-
|
|
228
|
-
"type": "rename_tab_response",
|
|
229
|
-
"request_id": request_id,
|
|
230
|
-
"success": False,
|
|
231
|
-
"error": "Missing tab_id or name",
|
|
232
|
-
}
|
|
211
|
+
await self._send_error(
|
|
212
|
+
connection, "rename_tab_response", request_id, "Missing tab_id or name"
|
|
233
213
|
)
|
|
234
214
|
return
|
|
235
215
|
|
|
236
216
|
# Rename the tab
|
|
237
217
|
tab = self._tab_service.rename_tab(tab_id, user_id, new_name)
|
|
238
218
|
if not tab:
|
|
239
|
-
await
|
|
240
|
-
|
|
241
|
-
"type": "rename_tab_response",
|
|
242
|
-
"request_id": request_id,
|
|
243
|
-
"success": False,
|
|
244
|
-
"error": "Failed to rename tab",
|
|
245
|
-
}
|
|
219
|
+
await self._send_error(
|
|
220
|
+
connection, "rename_tab_response", request_id, "Failed to rename tab"
|
|
246
221
|
)
|
|
247
222
|
return
|
|
248
223
|
|
|
@@ -2,14 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import logging
|
|
5
|
-
import os
|
|
6
5
|
import uuid
|
|
7
6
|
from collections.abc import Awaitable, Callable
|
|
8
7
|
from datetime import UTC, datetime
|
|
9
8
|
|
|
10
9
|
from porterminal.domain import (
|
|
11
|
-
EnvironmentRules,
|
|
12
|
-
EnvironmentSanitizer,
|
|
13
10
|
PTYPort,
|
|
14
11
|
Session,
|
|
15
12
|
SessionId,
|
|
@@ -32,17 +29,13 @@ class SessionService:
|
|
|
32
29
|
def __init__(
|
|
33
30
|
self,
|
|
34
31
|
repository: SessionRepository[PTYPort],
|
|
35
|
-
pty_factory: Callable[
|
|
36
|
-
[ShellCommand, TerminalDimensions, dict[str, str], str | None], PTYPort
|
|
37
|
-
],
|
|
32
|
+
pty_factory: Callable[[ShellCommand, TerminalDimensions, str | None], PTYPort],
|
|
38
33
|
limit_checker: SessionLimitChecker | None = None,
|
|
39
|
-
environment_sanitizer: EnvironmentSanitizer | None = None,
|
|
40
34
|
working_directory: str | None = None,
|
|
41
35
|
) -> None:
|
|
42
36
|
self._repository = repository
|
|
43
37
|
self._pty_factory = pty_factory
|
|
44
38
|
self._limit_checker = limit_checker or SessionLimitChecker()
|
|
45
|
-
self._sanitizer = environment_sanitizer or EnvironmentSanitizer(EnvironmentRules())
|
|
46
39
|
self._cwd = working_directory
|
|
47
40
|
self._running = False
|
|
48
41
|
self._cleanup_task: asyncio.Task | None = None
|
|
@@ -108,9 +101,8 @@ class SessionService:
|
|
|
108
101
|
if not limit_result.allowed:
|
|
109
102
|
raise ValueError(limit_result.reason)
|
|
110
103
|
|
|
111
|
-
# Create PTY
|
|
112
|
-
|
|
113
|
-
pty = self._pty_factory(shell, dimensions, env, self._cwd)
|
|
104
|
+
# Create PTY (environment sanitization handled by PTY layer)
|
|
105
|
+
pty = self._pty_factory(shell, dimensions, self._cwd)
|
|
114
106
|
|
|
115
107
|
# Create session (starts with 0 clients, caller adds via add_client())
|
|
116
108
|
now = datetime.now(UTC)
|