webgate 0.3.2__tar.gz → 0.4.0__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.
- {webgate-0.3.2 → webgate-0.4.0}/CHANGELOG.md +34 -0
- {webgate-0.3.2 → webgate-0.4.0}/PKG-INFO +48 -1
- {webgate-0.3.2 → webgate-0.4.0}/README.md +47 -0
- webgate-0.4.0/after-reload.yaml +64 -0
- webgate-0.4.0/docs/screenshots/v0.3/01-login-demo-banner.png +0 -0
- webgate-0.4.0/docs/screenshots/v0.3/02-dashboard-jump-host.png +0 -0
- webgate-0.4.0/docs/screenshots/v0.3/03-terminal-snippets-jump.png +0 -0
- webgate-0.4.0/docs/screenshots/v0.3/04-snippet-executed.png +0 -0
- webgate-0.4.0/docs/screenshots/v0.3/05-sftp-via-jump.png +0 -0
- webgate-0.4.0/docs/screenshots/v0.3/06-webhooks-modal.png +0 -0
- webgate-0.4.0/docs/screenshots/v0.3/07-webhook-test-fired.png +0 -0
- webgate-0.4.0/docs/screenshots/v0.3/08-add-server-jump-via.png +0 -0
- webgate-0.4.0/docs/screenshots/v0.4/01-shared-terminal-owner.png +0 -0
- webgate-0.4.0/docs/screenshots/v0.4/02-shared-terminal-joiner.png +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/pyproject.toml +1 -1
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/app.py +4 -1
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/static/index.html +50 -4
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/terminal/routes.py +54 -10
- webgate-0.4.0/src/webgate/terminal/shared.py +129 -0
- webgate-0.4.0/src/webgate/terminal/ws_handler.py +131 -0
- webgate-0.3.2/src/webgate/terminal/ws_handler.py +0 -102
- {webgate-0.3.2 → webgate-0.4.0}/.dockerignore +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/.github/workflows/docs.yml +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/.gitignore +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/Dockerfile +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/Dockerfile.demo +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/Dockerfile.ssh-demo +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/LICENSE +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/ROADMAP.md +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/VERSION +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/compose.dev.yml +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/compose.yml +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/docs/api/auth.md +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/docs/api/files.md +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/docs/api/servers.md +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/docs/api/terminal.md +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/docs/changelog.md +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/docs/getting-started/installation.md +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/docs/getting-started/quickstart.md +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/docs/guide/files.md +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/docs/guide/servers.md +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/docs/guide/split.md +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/docs/guide/terminal.md +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/docs/guide/users.md +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/docs/index.md +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/docs/screenshots/access-control.png +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/docs/screenshots/audit.png +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/docs/screenshots/edit-access-control.png +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/docs/screenshots/editor.png +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/docs/screenshots/light-theme.png +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/docs/screenshots/login.png +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/docs/screenshots/new-server-form.png +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/docs/screenshots/sftp-restricted.png +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/docs/screenshots/sftp.png +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/docs/screenshots/site-manager.png +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/docs/screenshots/split-view.png +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/docs/screenshots/ssh-disabled.png +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/docs/screenshots/terminal.png +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/docs/screenshots/users.png +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/fly.toml +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/mkdocs.yml +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/__init__.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/__main__.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/audit/__init__.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/audit/models.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/audit/service.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/auth/__init__.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/auth/models.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/auth/routes.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/auth/service.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/config.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/db/__init__.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/db/engine.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/demo.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/files/__init__.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/files/models.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/files/pool.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/files/routes.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/files/sftp_service.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/servers/__init__.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/servers/crypto.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/servers/models.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/servers/monitor.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/servers/routes.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/servers/service.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/snippets/__init__.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/snippets/models.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/snippets/routes.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/terminal/__init__.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/terminal/ssh_session.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/webhooks/__init__.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/webhooks/dispatcher.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/webhooks/models.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/src/webgate/webhooks/routes.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/tests/__init__.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/tests/conftest.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/tests/test_auth.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/tests/test_files.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/tests/test_servers.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/tests/test_terminal.py +0 -0
- {webgate-0.3.2 → webgate-0.4.0}/uv.lock +0 -0
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.4.0 (2026-04-15)
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- **Shared terminal sessions** -- the killer feature. The owner of an active SSH terminal can click **🔗 Share** to mint a one-time URL; anyone they send it to can join the same live session. Output is broadcast to every participant, and any RW participant can type. Useful for pair debugging in production, onboarding juniors, or remote support.
|
|
8
|
+
- Backend: new `SharedSession` registry holds one `asyncssh` process per session and broadcasts its PTY output to N WebSockets. `_client_input_loop` multiplexes input from any RW client into the same `stdin`.
|
|
9
|
+
- Endpoints: `POST /api/terminal/share/{session_id}` mints/returns the token, `DELETE` revokes, `WS /api/ws/terminal/join/{token}?mode=rw|ro` attaches a joiner.
|
|
10
|
+
- Frontend: **🔗 Share** button copies the URL to clipboard; toolbar shows `👥 joined (rw|ro)` for participants. Pasting `?join=<token>` into the URL after login auto-attaches.
|
|
11
|
+
- Demo middleware whitelists `/api/terminal/share/*` so the public demo can showcase the feature.
|
|
12
|
+
|
|
13
|
+
### Verified
|
|
14
|
+
|
|
15
|
+
End-to-end with two browser sessions against the local container:
|
|
16
|
+
|
|
17
|
+
- Owner opens SSH to `bastion`, clicks Share → token minted, URL copied
|
|
18
|
+
- Joiner navigates to `?join=<token>` → "Joined session of demo on bastion (rw)"
|
|
19
|
+
- Joiner types `echo HELLO_FROM_JOINER` → output appears in BOTH terminals
|
|
20
|
+
- Owner types `uname -n` → output appears in BOTH terminals
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## v0.3.3 (2026-04-15)
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- Snippets toolbar was empty after a fresh login until the page was reloaded. `loadSnippets()` was only called from `init()` (which only runs when a token is already in localStorage). Now also called after a successful login and after a forced password change.
|
|
29
|
+
|
|
30
|
+
### Docs
|
|
31
|
+
|
|
32
|
+
- Added end-to-end UI screenshots for v0.3.x features: jump host, snippets toolbar, snippet execution, SFTP browse via jump, webhooks management modal with delivery telemetry, server form with Jump Via dropdown.
|
|
33
|
+
- Browser-tested every UI flow with `playwright-cli` against both the live Fly.io demo and a local container.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
3
37
|
## v0.3.2 (2026-04-15)
|
|
4
38
|
|
|
5
39
|
### Features
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: webgate
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Self-hosted web application for remote server management via SSH terminal and SFTP file browser
|
|
5
5
|
Project-URL: Homepage, https://github.com/kalexnolasco/webgate
|
|
6
6
|
Project-URL: Documentation, https://kalexnolasco.github.io/webgate/
|
|
@@ -242,6 +242,53 @@ flowchart TB
|
|
|
242
242
|
### Light Theme
|
|
243
243
|

|
|
244
244
|
|
|
245
|
+
### v0.4.0 — Shared Terminal Sessions
|
|
246
|
+
|
|
247
|
+
Pair-debugging or onboarding from anywhere: the owner clicks **🔗 Share** in the terminal toolbar, copies the URL, and any colleague who opens it joins the same live session. Output is broadcast to every participant; any RW participant can type.
|
|
248
|
+
|
|
249
|
+
| Owner | Joiner |
|
|
250
|
+
| --- | --- |
|
|
251
|
+
|  |  |
|
|
252
|
+
|
|
253
|
+
The `?join=<token>` URL parameter is recognized after login -- a joiner can be sent the URL directly even if they aren't logged in yet.
|
|
254
|
+
|
|
255
|
+
### v0.3.x — Operations Pack
|
|
256
|
+
|
|
257
|
+
These screenshots come from the live demo at https://webgate-demo.fly.dev (login `demo` / `demo`).
|
|
258
|
+
|
|
259
|
+
#### Demo banner on login
|
|
260
|
+

|
|
261
|
+
|
|
262
|
+
#### Site Manager with jump-host indicator
|
|
263
|
+
The `↺` next to `internal-app` means it tunnels through `bastion`.
|
|
264
|
+
|
|
265
|
+

|
|
266
|
+
|
|
267
|
+
#### Terminal with snippets toolbar
|
|
268
|
+
Click a snippet button to send `command + Enter` to the active session. Right-click to delete, `+` to create.
|
|
269
|
+
|
|
270
|
+

|
|
271
|
+
|
|
272
|
+
#### Snippet executed
|
|
273
|
+
Clicking `ls -lah` sent the command and the output appears in the terminal.
|
|
274
|
+
|
|
275
|
+

|
|
276
|
+
|
|
277
|
+
#### SFTP via jump host
|
|
278
|
+
Browsing the `internal-app` filesystem — the SFTP connection is also tunneled through the bastion.
|
|
279
|
+
|
|
280
|
+

|
|
281
|
+
|
|
282
|
+
#### Webhooks management (admin)
|
|
283
|
+
Per-event subscription, optional HMAC signing, last delivery status.
|
|
284
|
+
|
|
285
|
+

|
|
286
|
+
|
|
287
|
+
#### Add Server form with Jump Via dropdown
|
|
288
|
+
Pick any other registered server as the bastion.
|
|
289
|
+
|
|
290
|
+

|
|
291
|
+
|
|
245
292
|
## Quick Start
|
|
246
293
|
|
|
247
294
|
### Docker (recommended)
|
|
@@ -194,6 +194,53 @@ flowchart TB
|
|
|
194
194
|
### Light Theme
|
|
195
195
|

|
|
196
196
|
|
|
197
|
+
### v0.4.0 — Shared Terminal Sessions
|
|
198
|
+
|
|
199
|
+
Pair-debugging or onboarding from anywhere: the owner clicks **🔗 Share** in the terminal toolbar, copies the URL, and any colleague who opens it joins the same live session. Output is broadcast to every participant; any RW participant can type.
|
|
200
|
+
|
|
201
|
+
| Owner | Joiner |
|
|
202
|
+
| --- | --- |
|
|
203
|
+
|  |  |
|
|
204
|
+
|
|
205
|
+
The `?join=<token>` URL parameter is recognized after login -- a joiner can be sent the URL directly even if they aren't logged in yet.
|
|
206
|
+
|
|
207
|
+
### v0.3.x — Operations Pack
|
|
208
|
+
|
|
209
|
+
These screenshots come from the live demo at https://webgate-demo.fly.dev (login `demo` / `demo`).
|
|
210
|
+
|
|
211
|
+
#### Demo banner on login
|
|
212
|
+

|
|
213
|
+
|
|
214
|
+
#### Site Manager with jump-host indicator
|
|
215
|
+
The `↺` next to `internal-app` means it tunnels through `bastion`.
|
|
216
|
+
|
|
217
|
+

|
|
218
|
+
|
|
219
|
+
#### Terminal with snippets toolbar
|
|
220
|
+
Click a snippet button to send `command + Enter` to the active session. Right-click to delete, `+` to create.
|
|
221
|
+
|
|
222
|
+

|
|
223
|
+
|
|
224
|
+
#### Snippet executed
|
|
225
|
+
Clicking `ls -lah` sent the command and the output appears in the terminal.
|
|
226
|
+
|
|
227
|
+

|
|
228
|
+
|
|
229
|
+
#### SFTP via jump host
|
|
230
|
+
Browsing the `internal-app` filesystem — the SFTP connection is also tunneled through the bastion.
|
|
231
|
+
|
|
232
|
+

|
|
233
|
+
|
|
234
|
+
#### Webhooks management (admin)
|
|
235
|
+
Per-event subscription, optional HMAC signing, last delivery status.
|
|
236
|
+
|
|
237
|
+

|
|
238
|
+
|
|
239
|
+
#### Add Server form with Jump Via dropdown
|
|
240
|
+
Pick any other registered server as the bastion.
|
|
241
|
+
|
|
242
|
+

|
|
243
|
+
|
|
197
244
|
## Quick Start
|
|
198
245
|
|
|
199
246
|
### Docker (recommended)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
- generic [ref=e2]:
|
|
2
|
+
- generic [ref=e3]:
|
|
3
|
+
- text: 🎮
|
|
4
|
+
- strong [ref=e4]: Demo mode
|
|
5
|
+
- text: — login with
|
|
6
|
+
- code [ref=e5]: demo
|
|
7
|
+
- text: /
|
|
8
|
+
- code [ref=e6]: demo
|
|
9
|
+
- text: · read-only (servers cannot be added or modified) · state resets hourly ·
|
|
10
|
+
- link "GitHub" [ref=e7] [cursor=pointer]:
|
|
11
|
+
- /url: https://github.com/kalexnolasco/webgate
|
|
12
|
+
- generic [ref=e8]:
|
|
13
|
+
- generic [ref=e9]:
|
|
14
|
+
- generic [ref=e10]:
|
|
15
|
+
- generic [ref=e11]: webgate
|
|
16
|
+
- button "Site Manager" [ref=e12] [cursor=pointer]
|
|
17
|
+
- button "Keys" [ref=e13] [cursor=pointer]
|
|
18
|
+
- generic [ref=e14]:
|
|
19
|
+
- generic [ref=e15]: demo
|
|
20
|
+
- 'button "2FA: Off" [ref=e16] [cursor=pointer]'
|
|
21
|
+
- button "☀️" [ref=e17] [cursor=pointer]
|
|
22
|
+
- button "Logout" [ref=e18] [cursor=pointer]
|
|
23
|
+
- generic [ref=e19]:
|
|
24
|
+
- generic [ref=e20]: "Host:"
|
|
25
|
+
- textbox "hostname" [ref=e21]
|
|
26
|
+
- generic [ref=e22]: "Username:"
|
|
27
|
+
- textbox "user" [ref=e23]
|
|
28
|
+
- generic [ref=e24]: "Password:"
|
|
29
|
+
- textbox [ref=e25]
|
|
30
|
+
- generic [ref=e26]: "Port:"
|
|
31
|
+
- spinbutton [ref=e27]: "22"
|
|
32
|
+
- button "Quickconnect ▶" [ref=e28] [cursor=pointer]
|
|
33
|
+
- generic [ref=e29]:
|
|
34
|
+
- generic [ref=e51]: 8:17:34 PM Connecting to 127.0.0.1:22...
|
|
35
|
+
- generic [ref=e52]: 8:17:34 PM webgate initialized
|
|
36
|
+
- generic [ref=e53]: "8:17:34 PM Connected: internal-app"
|
|
37
|
+
- generic [ref=e31]:
|
|
38
|
+
- generic [ref=e32] [cursor=pointer]: 💻 Site Manager
|
|
39
|
+
- generic [ref=e54] [cursor=pointer]:
|
|
40
|
+
- generic [ref=e55]: ⌨ internal-app
|
|
41
|
+
- generic [ref=e56]: ×
|
|
42
|
+
- generic [ref=e58]:
|
|
43
|
+
- generic [ref=e59]:
|
|
44
|
+
- generic [ref=e60]: "Snippets:"
|
|
45
|
+
- button "disk usage" [ref=e61] [cursor=pointer]
|
|
46
|
+
- button "ls -lah" [ref=e62] [cursor=pointer]
|
|
47
|
+
- button "top procs" [ref=e63] [cursor=pointer]
|
|
48
|
+
- button "uptime" [ref=e64] [cursor=pointer]
|
|
49
|
+
- button "+" [ref=e65] [cursor=pointer]
|
|
50
|
+
- generic [ref=e69]:
|
|
51
|
+
- textbox "Terminal input" [ref=e70]
|
|
52
|
+
- generic [ref=e71]:
|
|
53
|
+
- generic [ref=e73]: "Linux 1853454a533968 6.12.47-fly #1 SMP PREEMPT_DYNAMIC Wed Mar 25 00:13:15 UTC 2026 x86_64"
|
|
54
|
+
- generic [ref=e76]: The programs included with the Debian GNU/Linux system are free software;
|
|
55
|
+
- generic [ref=e78]: the exact distribution terms for each program are described in the
|
|
56
|
+
- generic [ref=e80]: individual files in /usr/share/doc/*/copyright.
|
|
57
|
+
- generic [ref=e83]: Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
|
|
58
|
+
- generic [ref=e85]: permitted by applicable law.
|
|
59
|
+
- generic [ref=e86]:
|
|
60
|
+
- generic [ref=e87]: demo@1853454a533968
|
|
61
|
+
- generic [ref=e88]: ":"
|
|
62
|
+
- generic [ref=e89]: ~
|
|
63
|
+
- generic [ref=e90]: $
|
|
64
|
+
- generic [ref=e50]: "SSH: internal-app"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -75,7 +75,10 @@ def create_app() -> FastAPI:
|
|
|
75
75
|
@app.middleware("http")
|
|
76
76
|
async def _demo_readonly(request: Request, call_next): # pyright: ignore[reportUnusedFunction]
|
|
77
77
|
path = request.url.path
|
|
78
|
-
|
|
78
|
+
# Allow share-token mint/revoke even in demo mode so the shared
|
|
79
|
+
# terminal feature can be exercised from the public demo.
|
|
80
|
+
allowed = path in WRITE_ALLOWLIST or path.startswith("/api/terminal/share/")
|
|
81
|
+
if request.method in WRITE_METHODS and path.startswith("/api/") and not allowed:
|
|
79
82
|
return JSONResponse(
|
|
80
83
|
status_code=403,
|
|
81
84
|
content={"detail": "Demo mode: write operations are disabled"},
|
|
@@ -358,6 +358,9 @@ window._showToastImpl = function(message, type) {
|
|
|
358
358
|
<button @click="runSnippet(tab, s)" @contextmenu.prevent="confirmDeleteSnippet(s)" class="fz-btn" style="font-size:11px;padding:2px 8px" :title="(s.description || s.command) + ' (right-click to delete)'" x-text="s.name"></button>
|
|
359
359
|
</template>
|
|
360
360
|
<button @click="promptNewSnippet()" class="fz-btn" style="font-size:11px;padding:2px 8px" title="Create new snippet">+</button>
|
|
361
|
+
<span style="flex:1"></span>
|
|
362
|
+
<button x-show="tab._sessionId && !tab._isJoined" @click="shareSession(tab)" class="fz-btn" style="font-size:11px;padding:2px 8px" :style="tab._shareToken ? 'color:var(--success);border-color:var(--success)' : ''" :title="tab._shareToken ? 'Click to copy URL again' : 'Generate a share URL for this session'" x-text="tab._shareToken ? '🔗 Shared' : '🔗 Share'"></button>
|
|
363
|
+
<span x-show="tab._isJoined" style="font-size:11px;color:var(--accent)" :title="'Joined session as ' + (tab._joinMode || 'rw')" x-text="'👥 joined (' + (tab._joinMode || 'rw') + ')'"></span>
|
|
361
364
|
</div>
|
|
362
365
|
<div style="flex:1;min-height:0" :id="tab.id"></div>
|
|
363
366
|
</div>
|
|
@@ -963,12 +966,21 @@ function app() {
|
|
|
963
966
|
async init() {
|
|
964
967
|
document.documentElement.dataset.theme = this.theme;
|
|
965
968
|
try { const cfg = await this.apiCall('/api/config'); this.demoMode = !!cfg.demo_mode; } catch {}
|
|
969
|
+
// Pick up ?join=<token>[&mode=ro|rw] from the URL after auth
|
|
970
|
+
const params = new URLSearchParams(location.search);
|
|
971
|
+
this._pendingJoinToken = params.get('join');
|
|
972
|
+
this._pendingJoinMode = params.get('mode') === 'ro' ? 'ro' : 'rw';
|
|
966
973
|
if (this.token) {
|
|
967
974
|
await this.fetchMe();
|
|
968
975
|
if (this.currentUser?.must_change_password) return;
|
|
969
976
|
await this.loadServers(); await this.loadGroups(); await this.loadStatuses(); await this.loadSnippets();
|
|
970
977
|
setInterval(() => this.loadStatuses(), 30000);
|
|
971
978
|
this._restoreTabs();
|
|
979
|
+
if (this._pendingJoinToken) {
|
|
980
|
+
this.joinSharedSession(this._pendingJoinToken, this._pendingJoinMode);
|
|
981
|
+
history.replaceState({}, '', BASE + '/');
|
|
982
|
+
this._pendingJoinToken = null;
|
|
983
|
+
}
|
|
972
984
|
}
|
|
973
985
|
this.log('status', 'webgate initialized');
|
|
974
986
|
},
|
|
@@ -1012,9 +1024,14 @@ function app() {
|
|
|
1012
1024
|
this.totpRequired = false; this.totpCode = ''; this.tempToken = '';
|
|
1013
1025
|
await this.fetchMe();
|
|
1014
1026
|
if (this.currentUser?.must_change_password) { this.showChangePassword = true; return; }
|
|
1015
|
-
await this.loadServers(); await this.loadGroups();
|
|
1027
|
+
await this.loadServers(); await this.loadGroups(); await this.loadStatuses(); await this.loadSnippets();
|
|
1016
1028
|
this.username = ''; this.password = '';
|
|
1017
1029
|
this.log('status', 'Logged in as ' + this.currentUser?.username);
|
|
1030
|
+
if (this._pendingJoinToken) {
|
|
1031
|
+
this.joinSharedSession(this._pendingJoinToken, this._pendingJoinMode);
|
|
1032
|
+
history.replaceState({}, '', BASE + '/');
|
|
1033
|
+
this._pendingJoinToken = null;
|
|
1034
|
+
}
|
|
1018
1035
|
} catch (e) { this.showToast(e.message, 'error'); }
|
|
1019
1036
|
},
|
|
1020
1037
|
async fetchMe() { try { this.currentUser = await this.apiCall('/api/auth/me'); } catch { this.logout(); } },
|
|
@@ -1023,7 +1040,7 @@ function app() {
|
|
|
1023
1040
|
try {
|
|
1024
1041
|
await this.apiCall('/api/auth/change-password', { method: 'POST', body: JSON.stringify({ new_password: this.newPassword }) });
|
|
1025
1042
|
this.showChangePassword = false; this.newPassword = ''; this.confirmPassword = '';
|
|
1026
|
-
await this.fetchMe(); await this.loadServers(); await this.loadGroups();
|
|
1043
|
+
await this.fetchMe(); await this.loadServers(); await this.loadGroups(); await this.loadStatuses(); await this.loadSnippets();
|
|
1027
1044
|
this.username = ''; this.password = '';
|
|
1028
1045
|
this.showToast('Password changed');
|
|
1029
1046
|
this.log('status', 'Logged in as ' + this.currentUser?.username);
|
|
@@ -1083,6 +1100,23 @@ function app() {
|
|
|
1083
1100
|
tab._ws.send(s.command + '\r');
|
|
1084
1101
|
this.log('cmd', `Sent snippet "${s.name}"`);
|
|
1085
1102
|
},
|
|
1103
|
+
|
|
1104
|
+
// Shared sessions
|
|
1105
|
+
async shareSession(tab) {
|
|
1106
|
+
if (!tab._sessionId) { this.showToast('Session not ready yet', 'error'); return; }
|
|
1107
|
+
try {
|
|
1108
|
+
const res = await this.apiCall(`/api/terminal/share/${tab._sessionId}`, { method: 'POST' });
|
|
1109
|
+
tab._shareToken = res.token;
|
|
1110
|
+
const url = `${location.origin}${BASE}/?join=${res.token}`;
|
|
1111
|
+
try { await navigator.clipboard.writeText(url); this.showToast('Share URL copied to clipboard'); }
|
|
1112
|
+
catch { prompt('Copy this share URL:', url); }
|
|
1113
|
+
this.log('status', `Shared session URL: ${url}`);
|
|
1114
|
+
} catch (e) { this.showToast(e.message, 'error'); }
|
|
1115
|
+
},
|
|
1116
|
+
joinSharedSession(token, mode = 'rw') {
|
|
1117
|
+
const tab = this._createTab('terminal', `shared (${mode})`, { _isJoined: true, _joinMode: mode });
|
|
1118
|
+
this.$nextTick(() => this._initTerminal(tab, `/api/ws/terminal/join/${token}?mode=${mode}`, {}));
|
|
1119
|
+
},
|
|
1086
1120
|
async loadStatuses() { try { this.serverStatuses = await this.apiCall('/api/servers/status'); } catch {} },
|
|
1087
1121
|
async loadGroups() { try { this.groups = await this.apiCall('/api/servers/groups'); } catch {} },
|
|
1088
1122
|
resetServerForm() { this.editingServerId = null; this.serverForm = { name: '', hostname: '', port: 22, username: '', auth_method: 'password', password: '', private_key: '', keyFileName: '', group: '', tagsStr: '', description: '', ssh_enabled: true, sftp_enabled: true, sftp_read_only: false, sftp_allowed_paths_str: '', jump_via_id: null }; },
|
|
@@ -1203,10 +1237,22 @@ function app() {
|
|
|
1203
1237
|
requestAnimationFrame(() => {
|
|
1204
1238
|
fitAddon.fit();
|
|
1205
1239
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
1206
|
-
const
|
|
1240
|
+
const sep = wsPath.includes('?') ? '&' : '?';
|
|
1241
|
+
const ws = new WebSocket(`${protocol}//${location.host}${BASE}${wsPath}${sep}token=${encodeURIComponent(this.token)}`);
|
|
1207
1242
|
tab._ws = ws;
|
|
1208
1243
|
ws.onopen = () => { ws.send(JSON.stringify({ ...connInfo, cols: term.cols, rows: term.rows })); this.log('status', `Connected: ${tab.title}`); };
|
|
1209
|
-
ws.onmessage = (e) =>
|
|
1244
|
+
ws.onmessage = (e) => {
|
|
1245
|
+
// Backend may send a control JSON frame (session id on connect, join ack)
|
|
1246
|
+
if (typeof e.data === 'string' && e.data.startsWith('{')) {
|
|
1247
|
+
try {
|
|
1248
|
+
const ctrl = JSON.parse(e.data);
|
|
1249
|
+
if (ctrl.type === 'session') { tab._sessionId = ctrl.session_id; return; }
|
|
1250
|
+
if (ctrl.type === 'joined') { tab._isJoined = true; tab._joinMode = ctrl.mode; this.log('status', `Joined session of ${ctrl.owner} on ${ctrl.server} (${ctrl.mode})`); return; }
|
|
1251
|
+
if (ctrl.type === 'error') { this.log('error', ctrl.message); return; }
|
|
1252
|
+
} catch {}
|
|
1253
|
+
}
|
|
1254
|
+
term.write(e.data);
|
|
1255
|
+
};
|
|
1210
1256
|
ws.onerror = () => this.log('error', `Connection error: ${tab.title}`);
|
|
1211
1257
|
ws.onclose = (e) => { if (e.code === 4001) this.log('error', 'Authentication failed'); this.log('status', `Disconnected: ${tab.title}`); term.write('\r\n\x1b[31m--- Disconnected ---\x1b[0m\r\n'); };
|
|
1212
1258
|
term.onData(data => { if (ws.readyState === WebSocket.OPEN) ws.send(data); });
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, status
|
|
4
6
|
|
|
5
7
|
from webgate.audit.service import log_action
|
|
6
8
|
from webgate.auth.models import UserOut
|
|
9
|
+
from webgate.auth.routes import get_current_user
|
|
7
10
|
from webgate.auth.service import get_user_by_id
|
|
8
11
|
from webgate.db.engine import async_session_factory
|
|
9
12
|
from webgate.servers.service import (
|
|
@@ -12,11 +15,41 @@ from webgate.servers.service import (
|
|
|
12
15
|
resolve_jump_creds,
|
|
13
16
|
update_last_connected,
|
|
14
17
|
)
|
|
15
|
-
from webgate.terminal.
|
|
18
|
+
from webgate.terminal.shared import manager as shared_manager
|
|
19
|
+
from webgate.terminal.ws_handler import authenticate_websocket, handle_join_ws, handle_terminal_ws
|
|
16
20
|
from webgate.webhooks.dispatcher import fire as fire_webhook
|
|
17
21
|
|
|
18
22
|
router = APIRouter(tags=["terminal"])
|
|
19
23
|
|
|
24
|
+
CurrentUserDep = Annotated[UserOut, Depends(get_current_user)]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@router.post("/api/terminal/share/{session_id}")
|
|
28
|
+
async def create_share_token(session_id: str, user: CurrentUserDep) -> dict[str, object]:
|
|
29
|
+
"""Mint a shareable token for an active terminal session. Only the
|
|
30
|
+
session owner may share; other users will get 403."""
|
|
31
|
+
sess = shared_manager.get_by_id(session_id)
|
|
32
|
+
if sess is None or sess.closed:
|
|
33
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not active")
|
|
34
|
+
if sess.owner_username != user.username:
|
|
35
|
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only the session owner can share")
|
|
36
|
+
token = shared_manager.mint_token(session_id)
|
|
37
|
+
return {
|
|
38
|
+
"token": token,
|
|
39
|
+
"server": sess.server_label,
|
|
40
|
+
"participants": len(sess.participants),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@router.delete("/api/terminal/share/{session_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
45
|
+
async def revoke_share_token(session_id: str, user: CurrentUserDep) -> None:
|
|
46
|
+
sess = shared_manager.get_by_id(session_id)
|
|
47
|
+
if sess is None:
|
|
48
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not active")
|
|
49
|
+
if sess.owner_username != user.username:
|
|
50
|
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only the session owner can revoke")
|
|
51
|
+
shared_manager.revoke_token(session_id)
|
|
52
|
+
|
|
20
53
|
|
|
21
54
|
@router.websocket("/api/ws/terminal/quick")
|
|
22
55
|
async def ws_terminal_quick(ws: WebSocket) -> None:
|
|
@@ -45,15 +78,11 @@ async def ws_terminal_quick(ws: WebSocket) -> None:
|
|
|
45
78
|
await ws.close(code=1008)
|
|
46
79
|
return
|
|
47
80
|
|
|
81
|
+
payload_username = str(payload.get("username", "user")) if payload else "user"
|
|
48
82
|
await handle_terminal_ws(
|
|
49
|
-
ws,
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
username=username,
|
|
53
|
-
password=password,
|
|
54
|
-
private_key=private_key,
|
|
55
|
-
cols=cols,
|
|
56
|
-
rows=rows,
|
|
83
|
+
ws, host=host, port=port, username=username,
|
|
84
|
+
password=password, private_key=private_key, cols=cols, rows=rows,
|
|
85
|
+
owner_username=payload_username, server_label=f"{username}@{host}",
|
|
57
86
|
)
|
|
58
87
|
|
|
59
88
|
|
|
@@ -123,4 +152,19 @@ async def ws_terminal_server(ws: WebSocket, server_id: int) -> None:
|
|
|
123
152
|
cols=cols,
|
|
124
153
|
rows=rows,
|
|
125
154
|
jump_kwargs=jump_kwargs,
|
|
155
|
+
owner_username=user_out.username,
|
|
156
|
+
server_label=server.name,
|
|
126
157
|
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@router.websocket("/api/ws/terminal/join/{token}")
|
|
161
|
+
async def ws_terminal_join(ws: WebSocket, token: str, mode: str = "rw") -> None:
|
|
162
|
+
"""Join an existing shared SSH session via its share token."""
|
|
163
|
+
payload = await authenticate_websocket(ws)
|
|
164
|
+
if payload is None:
|
|
165
|
+
await ws.close(code=4001, reason="Unauthorized")
|
|
166
|
+
return
|
|
167
|
+
username = str(payload.get("username", "guest"))
|
|
168
|
+
if mode not in ("rw", "ro"):
|
|
169
|
+
mode = "ro"
|
|
170
|
+
await handle_join_ws(ws, share_token=token, username=username, mode=mode)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Shared SSH session manager.
|
|
2
|
+
|
|
3
|
+
A SharedSession owns ONE asyncssh process and broadcasts its output to many
|
|
4
|
+
connected WebSocket clients. Any client (in `rw` mode) may also send input.
|
|
5
|
+
|
|
6
|
+
Lifecycle:
|
|
7
|
+
- Owner opens an SSH terminal → handle_terminal_ws registers a SharedSession
|
|
8
|
+
with a generated session_id (no share token yet).
|
|
9
|
+
- Owner clicks "Share" → POST /api/terminal/share/{session_id} mints a token
|
|
10
|
+
and stores it on the SharedSession.
|
|
11
|
+
- Joiner connects WS /api/ws/terminal/join/{token} → added as RW or RO client.
|
|
12
|
+
- Owner closes their tab → SharedSession is torn down, all joiners disconnected.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import contextlib
|
|
19
|
+
import logging
|
|
20
|
+
import secrets
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
|
|
23
|
+
from fastapi import WebSocket
|
|
24
|
+
|
|
25
|
+
from webgate.terminal.ssh_session import SSHSession
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Participant:
|
|
32
|
+
ws: WebSocket
|
|
33
|
+
username: str
|
|
34
|
+
mode: str = "rw" # "rw" or "ro"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class SharedSession:
|
|
39
|
+
session_id: str
|
|
40
|
+
server_label: str
|
|
41
|
+
owner_username: str
|
|
42
|
+
ssh: SSHSession
|
|
43
|
+
participants: list[Participant] = field(default_factory=list)
|
|
44
|
+
share_token: str | None = None # set when owner clicks "Share"
|
|
45
|
+
closed: bool = False
|
|
46
|
+
|
|
47
|
+
async def broadcast(self, text: str) -> None:
|
|
48
|
+
dead: list[Participant] = []
|
|
49
|
+
for p in self.participants:
|
|
50
|
+
try:
|
|
51
|
+
await p.ws.send_text(text)
|
|
52
|
+
except Exception:
|
|
53
|
+
dead.append(p)
|
|
54
|
+
for p in dead:
|
|
55
|
+
self.participants.remove(p)
|
|
56
|
+
|
|
57
|
+
async def write_input(self, data: str, from_username: str) -> None:
|
|
58
|
+
# Only RW participants can drive input; ROs are no-ops.
|
|
59
|
+
sender = next((p for p in self.participants if p.username == from_username), None)
|
|
60
|
+
if sender is None or sender.mode != "rw":
|
|
61
|
+
return
|
|
62
|
+
await self.ssh.write(data)
|
|
63
|
+
|
|
64
|
+
async def close(self) -> None:
|
|
65
|
+
if self.closed:
|
|
66
|
+
return
|
|
67
|
+
self.closed = True
|
|
68
|
+
for p in self.participants:
|
|
69
|
+
with contextlib.suppress(Exception):
|
|
70
|
+
await p.ws.close()
|
|
71
|
+
self.participants.clear()
|
|
72
|
+
await self.ssh.close()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class SharedSessionManager:
|
|
76
|
+
"""In-memory registry of active shared SSH sessions."""
|
|
77
|
+
|
|
78
|
+
def __init__(self) -> None:
|
|
79
|
+
self._by_id: dict[str, SharedSession] = {}
|
|
80
|
+
self._by_token: dict[str, SharedSession] = {}
|
|
81
|
+
|
|
82
|
+
def new_session_id(self) -> str:
|
|
83
|
+
return secrets.token_urlsafe(12)
|
|
84
|
+
|
|
85
|
+
def register(self, sess: SharedSession) -> None:
|
|
86
|
+
self._by_id[sess.session_id] = sess
|
|
87
|
+
|
|
88
|
+
def unregister(self, session_id: str) -> SharedSession | None:
|
|
89
|
+
sess = self._by_id.pop(session_id, None)
|
|
90
|
+
if sess and sess.share_token:
|
|
91
|
+
self._by_token.pop(sess.share_token, None)
|
|
92
|
+
return sess
|
|
93
|
+
|
|
94
|
+
def get_by_id(self, session_id: str) -> SharedSession | None:
|
|
95
|
+
return self._by_id.get(session_id)
|
|
96
|
+
|
|
97
|
+
def get_by_token(self, token: str) -> SharedSession | None:
|
|
98
|
+
return self._by_token.get(token)
|
|
99
|
+
|
|
100
|
+
def mint_token(self, session_id: str) -> str | None:
|
|
101
|
+
sess = self._by_id.get(session_id)
|
|
102
|
+
if sess is None or sess.closed:
|
|
103
|
+
return None
|
|
104
|
+
if sess.share_token is None:
|
|
105
|
+
sess.share_token = secrets.token_urlsafe(16)
|
|
106
|
+
self._by_token[sess.share_token] = sess
|
|
107
|
+
return sess.share_token
|
|
108
|
+
|
|
109
|
+
def revoke_token(self, session_id: str) -> None:
|
|
110
|
+
sess = self._by_id.get(session_id)
|
|
111
|
+
if sess and sess.share_token:
|
|
112
|
+
self._by_token.pop(sess.share_token, None)
|
|
113
|
+
sess.share_token = None
|
|
114
|
+
|
|
115
|
+
async def run_read_loop(self, sess: SharedSession) -> None:
|
|
116
|
+
"""Single read loop per shared session. Broadcasts SSH output to all
|
|
117
|
+
clients. Returns when the SSH session ends or is closed."""
|
|
118
|
+
try:
|
|
119
|
+
while not sess.ssh.is_closed and not sess.closed:
|
|
120
|
+
data = await sess.ssh.read()
|
|
121
|
+
if data is None:
|
|
122
|
+
break
|
|
123
|
+
await sess.broadcast(data)
|
|
124
|
+
finally:
|
|
125
|
+
await sess.close()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# Module-level singleton
|
|
129
|
+
manager = SharedSessionManager()
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
from fastapi import WebSocket, WebSocketDisconnect
|
|
9
|
+
|
|
10
|
+
from webgate.auth.service import authenticate_api_key, decode_access_token
|
|
11
|
+
from webgate.db.engine import async_session_factory
|
|
12
|
+
from webgate.terminal.shared import Participant, SharedSession, manager
|
|
13
|
+
from webgate.terminal.ssh_session import SSHSession
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def authenticate_websocket(ws: WebSocket) -> dict[str, object] | None:
|
|
19
|
+
token = ws.query_params.get("token")
|
|
20
|
+
if not token:
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
# Support API key authentication (keys start with "wg_")
|
|
24
|
+
if token.startswith("wg_"):
|
|
25
|
+
async with async_session_factory() as session:
|
|
26
|
+
user = await authenticate_api_key(session, token)
|
|
27
|
+
if user:
|
|
28
|
+
return {"sub": str(user.id), "username": user.username}
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
payload = decode_access_token(token)
|
|
32
|
+
return payload
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def handle_terminal_ws(
|
|
36
|
+
ws: WebSocket,
|
|
37
|
+
host: str,
|
|
38
|
+
port: int,
|
|
39
|
+
username: str,
|
|
40
|
+
password: str | None = None,
|
|
41
|
+
private_key: str | None = None,
|
|
42
|
+
cols: int = 80,
|
|
43
|
+
rows: int = 24,
|
|
44
|
+
jump_kwargs: dict[str, object] | None = None,
|
|
45
|
+
owner_username: str = "owner",
|
|
46
|
+
server_label: str | None = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Open an SSH session as the owner. The session is registered with the
|
|
49
|
+
shared-session manager so other users can join via a share token."""
|
|
50
|
+
session = SSHSession(
|
|
51
|
+
host=host, port=port, username=username,
|
|
52
|
+
password=password, private_key=private_key, jump_kwargs=jump_kwargs,
|
|
53
|
+
)
|
|
54
|
+
try:
|
|
55
|
+
await session.connect(cols=cols, rows=rows)
|
|
56
|
+
except Exception as e:
|
|
57
|
+
logger.error("SSH connection failed: %s", e)
|
|
58
|
+
await ws.send_json({"type": "error", "message": f"SSH connection failed: {e}"})
|
|
59
|
+
await ws.close(code=1011)
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
sess = SharedSession(
|
|
63
|
+
session_id=manager.new_session_id(),
|
|
64
|
+
server_label=server_label or f"{username}@{host}",
|
|
65
|
+
owner_username=owner_username,
|
|
66
|
+
ssh=session,
|
|
67
|
+
)
|
|
68
|
+
sess.participants.append(Participant(ws=ws, username=owner_username, mode="rw"))
|
|
69
|
+
manager.register(sess)
|
|
70
|
+
|
|
71
|
+
# Tell the owner their session_id so the UI can build a share URL.
|
|
72
|
+
with contextlib.suppress(Exception):
|
|
73
|
+
await ws.send_text(json.dumps({"type": "session", "session_id": sess.session_id}))
|
|
74
|
+
|
|
75
|
+
read_task = asyncio.create_task(manager.run_read_loop(sess))
|
|
76
|
+
try:
|
|
77
|
+
await _client_input_loop(ws, session, sess, owner_username)
|
|
78
|
+
finally:
|
|
79
|
+
read_task.cancel()
|
|
80
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
81
|
+
await read_task
|
|
82
|
+
manager.unregister(sess.session_id)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def handle_join_ws(ws: WebSocket, share_token: str, username: str, mode: str) -> None:
|
|
86
|
+
"""Attach a joiner to an existing SharedSession via its share token."""
|
|
87
|
+
sess = manager.get_by_token(share_token)
|
|
88
|
+
if sess is None or sess.closed:
|
|
89
|
+
await ws.close(code=4004, reason="Share session not found or closed")
|
|
90
|
+
return
|
|
91
|
+
await ws.accept()
|
|
92
|
+
participant = Participant(ws=ws, username=username, mode=mode)
|
|
93
|
+
sess.participants.append(participant)
|
|
94
|
+
with contextlib.suppress(Exception):
|
|
95
|
+
await ws.send_text(json.dumps({
|
|
96
|
+
"type": "joined",
|
|
97
|
+
"server": sess.server_label,
|
|
98
|
+
"owner": sess.owner_username,
|
|
99
|
+
"mode": mode,
|
|
100
|
+
}))
|
|
101
|
+
try:
|
|
102
|
+
await _client_input_loop(ws, sess.ssh, sess, username)
|
|
103
|
+
finally:
|
|
104
|
+
if participant in sess.participants:
|
|
105
|
+
sess.participants.remove(participant)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def _client_input_loop(
|
|
109
|
+
ws: WebSocket, ssh: SSHSession, sess: SharedSession, username: str,
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Receives input + control messages from one client and dispatches to
|
|
112
|
+
the SSH process via the shared session (RO clients can only resize)."""
|
|
113
|
+
try:
|
|
114
|
+
while True:
|
|
115
|
+
message = await ws.receive_text()
|
|
116
|
+
try:
|
|
117
|
+
raw: object = json.loads(message)
|
|
118
|
+
if isinstance(raw, dict):
|
|
119
|
+
parsed: dict[str, object] = raw # pyright: ignore[reportUnknownVariableType]
|
|
120
|
+
if parsed.get("type") == "resize":
|
|
121
|
+
c = parsed.get("cols", 80)
|
|
122
|
+
r = parsed.get("rows", 24)
|
|
123
|
+
await ssh.resize(int(str(c)), int(str(r)))
|
|
124
|
+
continue
|
|
125
|
+
except (json.JSONDecodeError, KeyError, ValueError):
|
|
126
|
+
pass
|
|
127
|
+
await sess.write_input(message, username)
|
|
128
|
+
except WebSocketDisconnect:
|
|
129
|
+
pass
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import asyncio
|
|
4
|
-
import contextlib
|
|
5
|
-
import json
|
|
6
|
-
import logging
|
|
7
|
-
|
|
8
|
-
from fastapi import WebSocket, WebSocketDisconnect
|
|
9
|
-
|
|
10
|
-
from webgate.auth.service import authenticate_api_key, decode_access_token
|
|
11
|
-
from webgate.db.engine import async_session_factory
|
|
12
|
-
from webgate.terminal.ssh_session import SSHSession
|
|
13
|
-
|
|
14
|
-
logger = logging.getLogger(__name__)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
async def authenticate_websocket(ws: WebSocket) -> dict[str, object] | None:
|
|
18
|
-
token = ws.query_params.get("token")
|
|
19
|
-
if not token:
|
|
20
|
-
return None
|
|
21
|
-
|
|
22
|
-
# Support API key authentication (keys start with "wg_")
|
|
23
|
-
if token.startswith("wg_"):
|
|
24
|
-
async with async_session_factory() as session:
|
|
25
|
-
user = await authenticate_api_key(session, token)
|
|
26
|
-
if user:
|
|
27
|
-
return {"sub": str(user.id), "username": user.username}
|
|
28
|
-
return None
|
|
29
|
-
|
|
30
|
-
payload = decode_access_token(token)
|
|
31
|
-
return payload
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
async def handle_terminal_ws(
|
|
35
|
-
ws: WebSocket,
|
|
36
|
-
host: str,
|
|
37
|
-
port: int,
|
|
38
|
-
username: str,
|
|
39
|
-
password: str | None = None,
|
|
40
|
-
private_key: str | None = None,
|
|
41
|
-
cols: int = 80,
|
|
42
|
-
rows: int = 24,
|
|
43
|
-
jump_kwargs: dict[str, object] | None = None,
|
|
44
|
-
) -> None:
|
|
45
|
-
session = SSHSession(
|
|
46
|
-
host=host,
|
|
47
|
-
port=port,
|
|
48
|
-
username=username,
|
|
49
|
-
password=password,
|
|
50
|
-
private_key=private_key,
|
|
51
|
-
jump_kwargs=jump_kwargs,
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
try:
|
|
55
|
-
await session.connect(cols=cols, rows=rows)
|
|
56
|
-
except Exception as e:
|
|
57
|
-
logger.error("SSH connection failed: %s", e)
|
|
58
|
-
await ws.send_json({"type": "error", "message": f"SSH connection failed: {e}"})
|
|
59
|
-
await ws.close(code=1011)
|
|
60
|
-
return
|
|
61
|
-
|
|
62
|
-
async def ssh_to_ws() -> None:
|
|
63
|
-
try:
|
|
64
|
-
while not session.is_closed:
|
|
65
|
-
data = await session.read()
|
|
66
|
-
if data is None:
|
|
67
|
-
break
|
|
68
|
-
await ws.send_text(data)
|
|
69
|
-
except Exception:
|
|
70
|
-
pass
|
|
71
|
-
finally:
|
|
72
|
-
if not session.is_closed:
|
|
73
|
-
await session.close()
|
|
74
|
-
with contextlib.suppress(Exception):
|
|
75
|
-
await ws.close()
|
|
76
|
-
|
|
77
|
-
read_task = asyncio.create_task(ssh_to_ws())
|
|
78
|
-
|
|
79
|
-
try:
|
|
80
|
-
while True:
|
|
81
|
-
message = await ws.receive_text()
|
|
82
|
-
try:
|
|
83
|
-
raw: object = json.loads(message)
|
|
84
|
-
if isinstance(raw, dict):
|
|
85
|
-
parsed: dict[str, object] = raw # pyright: ignore[reportUnknownVariableType]
|
|
86
|
-
if parsed.get("type") == "resize":
|
|
87
|
-
c = parsed.get("cols", 80)
|
|
88
|
-
r = parsed.get("rows", 24)
|
|
89
|
-
await session.resize(int(str(c)), int(str(r)))
|
|
90
|
-
continue
|
|
91
|
-
except (json.JSONDecodeError, KeyError, ValueError):
|
|
92
|
-
pass
|
|
93
|
-
await session.write(message)
|
|
94
|
-
except WebSocketDisconnect:
|
|
95
|
-
pass
|
|
96
|
-
except Exception:
|
|
97
|
-
pass
|
|
98
|
-
finally:
|
|
99
|
-
read_task.cancel()
|
|
100
|
-
with contextlib.suppress(asyncio.CancelledError):
|
|
101
|
-
await read_task
|
|
102
|
-
await session.close()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|