webgate 0.3.3__tar.gz → 0.4.1__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.3 → webgate-0.4.1}/CHANGELOG.md +48 -0
- {webgate-0.3.3 → webgate-0.4.1}/PKG-INFO +28 -1
- {webgate-0.3.3 → webgate-0.4.1}/README.md +27 -0
- webgate-0.4.1/docs/screenshots/v0.4/01-shared-terminal-owner.png +0 -0
- webgate-0.4.1/docs/screenshots/v0.4/02-shared-terminal-joiner.png +0 -0
- webgate-0.4.1/docs/screenshots/v0.4/03-recording-replay.png +0 -0
- webgate-0.4.1/docs/screenshots/v0.4/04-recordings-modal.png +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/pyproject.toml +1 -1
- {webgate-0.3.3 → webgate-0.4.1}/src/webgate/app.py +6 -1
- {webgate-0.3.3 → webgate-0.4.1}/src/webgate/config.py +2 -0
- webgate-0.4.1/src/webgate/recordings/models.py +37 -0
- webgate-0.4.1/src/webgate/recordings/recorder.py +63 -0
- webgate-0.4.1/src/webgate/recordings/routes.py +139 -0
- {webgate-0.3.3 → webgate-0.4.1}/src/webgate/static/index.html +106 -2
- webgate-0.4.1/src/webgate/terminal/routes.py +212 -0
- webgate-0.4.1/src/webgate/terminal/shared.py +137 -0
- webgate-0.4.1/src/webgate/terminal/ws_handler.py +136 -0
- webgate-0.4.1/tests/__init__.py +0 -0
- webgate-0.3.3/src/webgate/terminal/routes.py +0 -126
- webgate-0.3.3/src/webgate/terminal/ws_handler.py +0 -102
- {webgate-0.3.3 → webgate-0.4.1}/.dockerignore +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/.github/workflows/docs.yml +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/.gitignore +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/Dockerfile +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/Dockerfile.demo +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/Dockerfile.ssh-demo +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/LICENSE +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/ROADMAP.md +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/VERSION +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/after-reload.yaml +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/compose.dev.yml +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/compose.yml +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/docs/api/auth.md +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/docs/api/files.md +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/docs/api/servers.md +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/docs/api/terminal.md +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/docs/changelog.md +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/docs/getting-started/installation.md +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/docs/getting-started/quickstart.md +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/docs/guide/files.md +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/docs/guide/servers.md +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/docs/guide/split.md +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/docs/guide/terminal.md +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/docs/guide/users.md +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/docs/index.md +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/access-control.png +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/audit.png +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/edit-access-control.png +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/editor.png +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/light-theme.png +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/login.png +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/new-server-form.png +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/sftp-restricted.png +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/sftp.png +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/site-manager.png +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/split-view.png +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/ssh-disabled.png +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/terminal.png +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/users.png +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/v0.3/01-login-demo-banner.png +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/v0.3/02-dashboard-jump-host.png +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/v0.3/03-terminal-snippets-jump.png +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/v0.3/04-snippet-executed.png +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/v0.3/05-sftp-via-jump.png +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/v0.3/06-webhooks-modal.png +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/v0.3/07-webhook-test-fired.png +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/v0.3/08-add-server-jump-via.png +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/fly.toml +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/mkdocs.yml +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/src/webgate/__init__.py +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/src/webgate/__main__.py +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/src/webgate/audit/__init__.py +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/src/webgate/audit/models.py +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/src/webgate/audit/service.py +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/src/webgate/auth/__init__.py +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/src/webgate/auth/models.py +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/src/webgate/auth/routes.py +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/src/webgate/auth/service.py +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/src/webgate/db/__init__.py +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/src/webgate/db/engine.py +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/src/webgate/demo.py +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/src/webgate/files/__init__.py +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/src/webgate/files/models.py +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/src/webgate/files/pool.py +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/src/webgate/files/routes.py +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/src/webgate/files/sftp_service.py +0 -0
- {webgate-0.3.3/src/webgate/servers → webgate-0.4.1/src/webgate/recordings}/__init__.py +0 -0
- {webgate-0.3.3/src/webgate/snippets → webgate-0.4.1/src/webgate/servers}/__init__.py +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/src/webgate/servers/crypto.py +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/src/webgate/servers/models.py +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/src/webgate/servers/monitor.py +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/src/webgate/servers/routes.py +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/src/webgate/servers/service.py +0 -0
- {webgate-0.3.3/src/webgate/terminal → webgate-0.4.1/src/webgate/snippets}/__init__.py +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/src/webgate/snippets/models.py +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/src/webgate/snippets/routes.py +0 -0
- {webgate-0.3.3/src/webgate/webhooks → webgate-0.4.1/src/webgate/terminal}/__init__.py +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/src/webgate/terminal/ssh_session.py +0 -0
- {webgate-0.3.3/tests → webgate-0.4.1/src/webgate/webhooks}/__init__.py +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/src/webgate/webhooks/dispatcher.py +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/src/webgate/webhooks/models.py +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/src/webgate/webhooks/routes.py +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/tests/conftest.py +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/tests/test_auth.py +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/tests/test_files.py +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/tests/test_servers.py +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/tests/test_terminal.py +0 -0
- {webgate-0.3.3 → webgate-0.4.1}/uv.lock +0 -0
|
@@ -1,5 +1,53 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.4.1 (2026-04-15)
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- **SSH session recording** -- when `WEBGATE_RECORD_SESSIONS=true`, every SSH terminal session is captured to an asciinema cast v2 file under `WEBGATE_RECORDINGS_DIR` (default `./recordings`). Both owner-only and shared sessions are recorded. A new `recordings` table tracks file path, server, user, start/end, duration and size.
|
|
8
|
+
- **Built-in web replay** -- `📹 Recordings` button (top toolbar) opens a list with **▶ Play** / **DL** / **Del** actions. Play opens an asciinema-player tab that streams the cast directly from the API. Non-admins see only their own recordings; admins see everyone's.
|
|
9
|
+
- **Compliance-grade audit trail** -- the recording captures the full PTY output that every participant saw, including pasted commands and environment, in a portable, replayable, vendor-independent format (you can also `asciinema play file.cast` locally).
|
|
10
|
+
|
|
11
|
+
### Details
|
|
12
|
+
|
|
13
|
+
- New `WEBGATE_RECORD_SESSIONS` and `WEBGATE_RECORDINGS_DIR` settings (both off / `./recordings` by default)
|
|
14
|
+
- New `Recording` model + REST CRUD at `/api/recordings`
|
|
15
|
+
- New `webgate.recordings.recorder.CastRecorder` writes asciinema cast v2 (JSON Lines) line-buffered, very low overhead
|
|
16
|
+
- Hook in `SharedSession.broadcast` -- recorder receives the same byte stream as every WS client
|
|
17
|
+
- Player endpoint accepts a `?token=` query param (Authorization header isn't available when opening a tab)
|
|
18
|
+
|
|
19
|
+
### Verified
|
|
20
|
+
|
|
21
|
+
End-to-end against a real container with `WEBGATE_RECORD_SESSIONS=true`:
|
|
22
|
+
|
|
23
|
+
- Open SSH, run `echo HELLO_RECORDED` + `uname -n`, disconnect
|
|
24
|
+
- DB row populated: `started_at`, `ended_at`, `duration_s=3.0`, `size_bytes=1142`
|
|
25
|
+
- File on disk: valid asciinema v2 cast, captures welcome banner + commands + responses
|
|
26
|
+
- Open `/api/recordings/{id}/play?token=...` in browser → asciinema-player loads, hit Play → terminal replays the captured commands
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## v0.4.0 (2026-04-15)
|
|
31
|
+
|
|
32
|
+
### Features
|
|
33
|
+
|
|
34
|
+
- **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.
|
|
35
|
+
- 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`.
|
|
36
|
+
- 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.
|
|
37
|
+
- 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.
|
|
38
|
+
- Demo middleware whitelists `/api/terminal/share/*` so the public demo can showcase the feature.
|
|
39
|
+
|
|
40
|
+
### Verified
|
|
41
|
+
|
|
42
|
+
End-to-end with two browser sessions against the local container:
|
|
43
|
+
|
|
44
|
+
- Owner opens SSH to `bastion`, clicks Share → token minted, URL copied
|
|
45
|
+
- Joiner navigates to `?join=<token>` → "Joined session of demo on bastion (rw)"
|
|
46
|
+
- Joiner types `echo HELLO_FROM_JOINER` → output appears in BOTH terminals
|
|
47
|
+
- Owner types `uname -n` → output appears in BOTH terminals
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
3
51
|
## v0.3.3 (2026-04-15)
|
|
4
52
|
|
|
5
53
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: webgate
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
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,33 @@ flowchart TB
|
|
|
242
242
|
### Light Theme
|
|
243
243
|

|
|
244
244
|
|
|
245
|
+
### v0.4.1 — SSH Session Recording
|
|
246
|
+
|
|
247
|
+
Set `WEBGATE_RECORD_SESSIONS=true` and every SSH session is captured to an asciinema cast v2 file. Replay in the browser with the embedded asciinema player, or download the `.cast` and `asciinema play` it locally.
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
docker run -e WEBGATE_RECORD_SESSIONS=true \
|
|
251
|
+
-e WEBGATE_RECORDINGS_DIR=/data/recordings \
|
|
252
|
+
-v webgate-data:/data \
|
|
253
|
+
kalexnolasco/webgate:0.4.1
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
| Recordings list | Replay in browser |
|
|
257
|
+
| --- | --- |
|
|
258
|
+
|  |  |
|
|
259
|
+
|
|
260
|
+
Non-admins see only their own recordings; admins see everyone's. Each recording row has **▶ Play** (browser), **DL** (download `.cast`), and **Del**.
|
|
261
|
+
|
|
262
|
+
### v0.4.0 — Shared Terminal Sessions
|
|
263
|
+
|
|
264
|
+
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.
|
|
265
|
+
|
|
266
|
+
| Owner | Joiner |
|
|
267
|
+
| --- | --- |
|
|
268
|
+
|  |  |
|
|
269
|
+
|
|
270
|
+
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.
|
|
271
|
+
|
|
245
272
|
### v0.3.x — Operations Pack
|
|
246
273
|
|
|
247
274
|
These screenshots come from the live demo at https://webgate-demo.fly.dev (login `demo` / `demo`).
|
|
@@ -194,6 +194,33 @@ flowchart TB
|
|
|
194
194
|
### Light Theme
|
|
195
195
|

|
|
196
196
|
|
|
197
|
+
### v0.4.1 — SSH Session Recording
|
|
198
|
+
|
|
199
|
+
Set `WEBGATE_RECORD_SESSIONS=true` and every SSH session is captured to an asciinema cast v2 file. Replay in the browser with the embedded asciinema player, or download the `.cast` and `asciinema play` it locally.
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
docker run -e WEBGATE_RECORD_SESSIONS=true \
|
|
203
|
+
-e WEBGATE_RECORDINGS_DIR=/data/recordings \
|
|
204
|
+
-v webgate-data:/data \
|
|
205
|
+
kalexnolasco/webgate:0.4.1
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
| Recordings list | Replay in browser |
|
|
209
|
+
| --- | --- |
|
|
210
|
+
|  |  |
|
|
211
|
+
|
|
212
|
+
Non-admins see only their own recordings; admins see everyone's. Each recording row has **▶ Play** (browser), **DL** (download `.cast`), and **Del**.
|
|
213
|
+
|
|
214
|
+
### v0.4.0 — Shared Terminal Sessions
|
|
215
|
+
|
|
216
|
+
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.
|
|
217
|
+
|
|
218
|
+
| Owner | Joiner |
|
|
219
|
+
| --- | --- |
|
|
220
|
+
|  |  |
|
|
221
|
+
|
|
222
|
+
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.
|
|
223
|
+
|
|
197
224
|
### v0.3.x — Operations Pack
|
|
198
225
|
|
|
199
226
|
These screenshots come from the live demo at https://webgate-demo.fly.dev (login `demo` / `demo`).
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -18,6 +18,7 @@ from webgate.files.pool import sftp_pool
|
|
|
18
18
|
from webgate.files.routes import router as files_router
|
|
19
19
|
from webgate.servers.monitor import server_monitor
|
|
20
20
|
from webgate.servers.routes import router as servers_router
|
|
21
|
+
from webgate.recordings.routes import router as recordings_router
|
|
21
22
|
from webgate.snippets.routes import router as snippets_router
|
|
22
23
|
from webgate.terminal.routes import router as terminal_router
|
|
23
24
|
from webgate.webhooks.routes import router as webhooks_router
|
|
@@ -75,7 +76,10 @@ def create_app() -> FastAPI:
|
|
|
75
76
|
@app.middleware("http")
|
|
76
77
|
async def _demo_readonly(request: Request, call_next): # pyright: ignore[reportUnusedFunction]
|
|
77
78
|
path = request.url.path
|
|
78
|
-
|
|
79
|
+
# Allow share-token mint/revoke even in demo mode so the shared
|
|
80
|
+
# terminal feature can be exercised from the public demo.
|
|
81
|
+
allowed = path in WRITE_ALLOWLIST or path.startswith("/api/terminal/share/")
|
|
82
|
+
if request.method in WRITE_METHODS and path.startswith("/api/") and not allowed:
|
|
79
83
|
return JSONResponse(
|
|
80
84
|
status_code=403,
|
|
81
85
|
content={"detail": "Demo mode: write operations are disabled"},
|
|
@@ -93,6 +97,7 @@ def create_app() -> FastAPI:
|
|
|
93
97
|
app.include_router(files_router)
|
|
94
98
|
app.include_router(snippets_router)
|
|
95
99
|
app.include_router(webhooks_router)
|
|
100
|
+
app.include_router(recordings_router)
|
|
96
101
|
|
|
97
102
|
app.mount("/", StaticFiles(directory=str(settings.static_dir), html=True), name="static")
|
|
98
103
|
|
|
@@ -13,6 +13,8 @@ class Settings(BaseSettings):
|
|
|
13
13
|
allowed_origins: str = "*"
|
|
14
14
|
root_path: str = "" # URL prefix when served behind a reverse proxy (e.g. "/webgate")
|
|
15
15
|
demo_mode: bool = False # Read-only public demo: blocks writes, hides admin UI
|
|
16
|
+
record_sessions: bool = False # Capture SSH sessions to asciinema cast files
|
|
17
|
+
recordings_dir: str = "./recordings" # Where to store .cast files
|
|
16
18
|
log_level: str = "info"
|
|
17
19
|
session_timeout: int = 3600
|
|
18
20
|
max_upload_size: int = 104857600 # 100MB
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
|
|
5
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
6
|
+
|
|
7
|
+
from webgate.db.engine import Base
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Recording(Base):
|
|
11
|
+
__tablename__ = "recordings"
|
|
12
|
+
|
|
13
|
+
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
|
14
|
+
server_id: Mapped[int | None] = mapped_column(
|
|
15
|
+
Integer, ForeignKey("servers.id"), nullable=True
|
|
16
|
+
)
|
|
17
|
+
server_name: Mapped[str] = mapped_column(String(255), default="")
|
|
18
|
+
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"))
|
|
19
|
+
username: Mapped[str] = mapped_column(String(255), default="")
|
|
20
|
+
file_path: Mapped[str] = mapped_column(Text)
|
|
21
|
+
started_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
|
22
|
+
ended_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
23
|
+
duration_s: Mapped[float] = mapped_column(default=0.0)
|
|
24
|
+
size_bytes: Mapped[int] = mapped_column(Integer, default=0)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RecordingOut(BaseModel):
|
|
28
|
+
id: int
|
|
29
|
+
server_id: int | None
|
|
30
|
+
server_name: str
|
|
31
|
+
username: str
|
|
32
|
+
started_at: datetime
|
|
33
|
+
ended_at: datetime | None
|
|
34
|
+
duration_s: float
|
|
35
|
+
size_bytes: int
|
|
36
|
+
|
|
37
|
+
model_config = {"from_attributes": True}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""asciinema cast v2 writer.
|
|
2
|
+
|
|
3
|
+
The cast format is JSON Lines:
|
|
4
|
+
- First line: header object {"version": 2, "width": N, "height": N, "timestamp": <unix>}
|
|
5
|
+
- Each subsequent line: [time_offset_seconds, "o" | "i", "data"]
|
|
6
|
+
|
|
7
|
+
Reference: https://github.com/asciinema/asciinema/blob/develop/doc/asciicast-v2.md
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import contextlib
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import IO
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CastRecorder:
|
|
23
|
+
def __init__(self, path: Path, cols: int, rows: int) -> None:
|
|
24
|
+
self.path = path
|
|
25
|
+
self._fh: IO[str] | None = None
|
|
26
|
+
self._start = time.monotonic()
|
|
27
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
self._fh = path.open("w", encoding="utf-8", buffering=1) # line-buffered
|
|
29
|
+
header = {
|
|
30
|
+
"version": 2,
|
|
31
|
+
"width": cols,
|
|
32
|
+
"height": rows,
|
|
33
|
+
"timestamp": int(time.time()),
|
|
34
|
+
"env": {"TERM": "xterm-256color", "SHELL": "/bin/bash"},
|
|
35
|
+
}
|
|
36
|
+
self._fh.write(json.dumps(header) + "\n")
|
|
37
|
+
|
|
38
|
+
def write_output(self, data: str) -> None:
|
|
39
|
+
fh = self._fh
|
|
40
|
+
if fh is None or fh.closed:
|
|
41
|
+
return
|
|
42
|
+
try:
|
|
43
|
+
offset = time.monotonic() - self._start
|
|
44
|
+
fh.write(json.dumps([round(offset, 6), "o", data]) + "\n")
|
|
45
|
+
except Exception as e:
|
|
46
|
+
logger.warning("CastRecorder write failed for %s: %s", self.path, e)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def duration(self) -> float:
|
|
50
|
+
return time.monotonic() - self._start
|
|
51
|
+
|
|
52
|
+
def close(self) -> int:
|
|
53
|
+
"""Close the recorder and return the final byte size."""
|
|
54
|
+
fh = self._fh
|
|
55
|
+
self._fh = None
|
|
56
|
+
if fh is not None and not fh.closed:
|
|
57
|
+
with contextlib.suppress(Exception):
|
|
58
|
+
fh.flush()
|
|
59
|
+
fh.close()
|
|
60
|
+
try:
|
|
61
|
+
return self.path.stat().st_size
|
|
62
|
+
except OSError:
|
|
63
|
+
return 0
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
5
|
+
from fastapi.responses import FileResponse, HTMLResponse
|
|
6
|
+
from sqlalchemy import select
|
|
7
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
8
|
+
|
|
9
|
+
from webgate.auth.models import UserOut
|
|
10
|
+
from webgate.auth.routes import get_current_user
|
|
11
|
+
from webgate.auth.service import authenticate_api_key, decode_access_token, get_user_by_id
|
|
12
|
+
from webgate.db.engine import get_session
|
|
13
|
+
from webgate.recordings.models import Recording, RecordingOut
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def _user_from_query_token(
|
|
17
|
+
token: str, session: AsyncSession
|
|
18
|
+
) -> UserOut:
|
|
19
|
+
"""Resolve a user from a JWT or API key passed via ?token= query. Used by
|
|
20
|
+
the play/download endpoints that open in a new browser tab where we can't
|
|
21
|
+
set an Authorization header."""
|
|
22
|
+
if token.startswith("wg_"):
|
|
23
|
+
u = await authenticate_api_key(session, token)
|
|
24
|
+
else:
|
|
25
|
+
payload = decode_access_token(token)
|
|
26
|
+
if not payload or not payload.get("sub"):
|
|
27
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
|
28
|
+
u = await get_user_by_id(session, int(str(payload["sub"])))
|
|
29
|
+
if u is None:
|
|
30
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
|
31
|
+
return UserOut.model_validate(u)
|
|
32
|
+
|
|
33
|
+
router = APIRouter(prefix="/api/recordings", tags=["recordings"])
|
|
34
|
+
|
|
35
|
+
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
|
36
|
+
CurrentUserDep = Annotated[UserOut, Depends(get_current_user)]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@router.get("", response_model=list[RecordingOut])
|
|
40
|
+
async def list_recordings(session: SessionDep, user: CurrentUserDep) -> list[Recording]:
|
|
41
|
+
stmt = select(Recording).order_by(Recording.started_at.desc())
|
|
42
|
+
if not user.is_admin:
|
|
43
|
+
stmt = stmt.where(Recording.user_id == user.id)
|
|
44
|
+
result = await session.execute(stmt)
|
|
45
|
+
return list(result.scalars().all())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def _get_recording_for(
|
|
49
|
+
session: AsyncSession, recording_id: int, user: UserOut
|
|
50
|
+
) -> Recording:
|
|
51
|
+
stmt = select(Recording).where(Recording.id == recording_id)
|
|
52
|
+
if not user.is_admin:
|
|
53
|
+
stmt = stmt.where(Recording.user_id == user.id)
|
|
54
|
+
rec = (await session.execute(stmt)).scalar_one_or_none()
|
|
55
|
+
if rec is None:
|
|
56
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recording not found")
|
|
57
|
+
return rec
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@router.get("/{recording_id}/download")
|
|
61
|
+
async def download_recording(
|
|
62
|
+
recording_id: int, session: SessionDep, token: Annotated[str, Query()]
|
|
63
|
+
) -> FileResponse:
|
|
64
|
+
user = await _user_from_query_token(token, session)
|
|
65
|
+
rec = await _get_recording_for(session, recording_id, user)
|
|
66
|
+
p = Path(rec.file_path)
|
|
67
|
+
if not p.exists():
|
|
68
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recording file missing")
|
|
69
|
+
return FileResponse(
|
|
70
|
+
path=str(p), media_type="application/x-asciicast",
|
|
71
|
+
filename=f"webgate-{rec.id}-{rec.server_name}.cast",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@router.get("/{recording_id}/play", response_class=HTMLResponse)
|
|
76
|
+
async def play_recording(
|
|
77
|
+
recording_id: int, session: SessionDep, token: Annotated[str, Query()]
|
|
78
|
+
) -> HTMLResponse:
|
|
79
|
+
user = await _user_from_query_token(token, session)
|
|
80
|
+
rec = await _get_recording_for(session, recording_id, user)
|
|
81
|
+
# Embed asciinema-player from CDN. The cast file is fetched via the
|
|
82
|
+
# download endpoint so JWT auth is honored.
|
|
83
|
+
started = rec.started_at.isoformat()
|
|
84
|
+
html = f"""<!doctype html>
|
|
85
|
+
<html><head>
|
|
86
|
+
<meta charset="utf-8">
|
|
87
|
+
<title>webgate replay #{rec.id} — {rec.server_name}</title>
|
|
88
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/asciinema-player@3.7.0/dist/bundle/asciinema-player.css">
|
|
89
|
+
<style>
|
|
90
|
+
body {{ background:#1a1b26; color:#c0caf5; font-family:system-ui,sans-serif; margin:0; padding:20px; }}
|
|
91
|
+
.meta {{ display:flex; gap:24px; font-size:13px; margin-bottom:16px; color:#9ba3c5; }}
|
|
92
|
+
.meta b {{ color:#fff; }}
|
|
93
|
+
#player {{ max-width:1100px; }}
|
|
94
|
+
</style>
|
|
95
|
+
</head><body>
|
|
96
|
+
<div class=\"meta\">
|
|
97
|
+
<span>📹 <b>{rec.server_name}</b></span>
|
|
98
|
+
<span>👤 {rec.username}</span>
|
|
99
|
+
<span>🕒 {started}</span>
|
|
100
|
+
<span>⏱ {rec.duration_s:.1f}s</span>
|
|
101
|
+
<span>💾 {rec.size_bytes:,} bytes</span>
|
|
102
|
+
</div>
|
|
103
|
+
<div id=\"player\"></div>
|
|
104
|
+
<script src=\"https://cdn.jsdelivr.net/npm/asciinema-player@3.7.0/dist/bundle/asciinema-player.min.js\"></script>
|
|
105
|
+
<script>
|
|
106
|
+
AsciinemaPlayer.create('cast?token={token}', document.getElementById('player'),
|
|
107
|
+
{{ idleTimeLimit: 2, theme: 'tango', fit: 'width' }});
|
|
108
|
+
</script>
|
|
109
|
+
</body></html>"""
|
|
110
|
+
return HTMLResponse(content=html)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@router.get("/{recording_id}/cast")
|
|
114
|
+
async def cast_raw(
|
|
115
|
+
recording_id: int, session: SessionDep, token: Annotated[str, Query()]
|
|
116
|
+
) -> FileResponse:
|
|
117
|
+
"""Same as /download but returns the raw cast (no Content-Disposition) so
|
|
118
|
+
the embedded player can fetch it via XHR."""
|
|
119
|
+
user = await _user_from_query_token(token, session)
|
|
120
|
+
rec = await _get_recording_for(session, recording_id, user)
|
|
121
|
+
p = Path(rec.file_path)
|
|
122
|
+
if not p.exists():
|
|
123
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recording file missing")
|
|
124
|
+
return FileResponse(path=str(p), media_type="application/x-asciicast")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@router.delete("/{recording_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
128
|
+
async def delete_recording(
|
|
129
|
+
recording_id: int, session: SessionDep, user: CurrentUserDep
|
|
130
|
+
) -> None:
|
|
131
|
+
rec = await _get_recording_for(session, recording_id, user)
|
|
132
|
+
p = Path(rec.file_path)
|
|
133
|
+
if p.exists():
|
|
134
|
+
try:
|
|
135
|
+
p.unlink()
|
|
136
|
+
except OSError:
|
|
137
|
+
pass
|
|
138
|
+
await session.delete(rec)
|
|
139
|
+
await session.commit()
|
|
@@ -233,6 +233,7 @@ window._showToastImpl = function(message, type) {
|
|
|
233
233
|
<button x-show="currentUser?.is_admin" @click="openUsersModal()" class="fz-btn" style="font-size:11px">Users</button>
|
|
234
234
|
<button x-show="currentUser?.is_admin" @click="openAuditLog()" class="fz-btn" style="font-size:11px">Audit</button>
|
|
235
235
|
<button x-show="currentUser?.is_admin" @click="openWebhooksModal()" class="fz-btn" style="font-size:11px">Webhooks</button>
|
|
236
|
+
<button @click="openRecordingsModal()" class="fz-btn" style="font-size:11px">📹 Recordings</button>
|
|
236
237
|
<button @click="openApiKeysModal()" class="fz-btn" style="font-size:11px">Keys</button>
|
|
237
238
|
</div>
|
|
238
239
|
<div style="display:flex;align-items:center;gap:8px">
|
|
@@ -358,6 +359,9 @@ window._showToastImpl = function(message, type) {
|
|
|
358
359
|
<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
360
|
</template>
|
|
360
361
|
<button @click="promptNewSnippet()" class="fz-btn" style="font-size:11px;padding:2px 8px" title="Create new snippet">+</button>
|
|
362
|
+
<span style="flex:1"></span>
|
|
363
|
+
<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>
|
|
364
|
+
<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
365
|
</div>
|
|
362
366
|
<div style="flex:1;min-height:0" :id="tab.id"></div>
|
|
363
367
|
</div>
|
|
@@ -795,6 +799,49 @@ window._showToastImpl = function(message, type) {
|
|
|
795
799
|
</div>
|
|
796
800
|
</div>
|
|
797
801
|
|
|
802
|
+
<!-- ======== RECORDINGS MODAL ======== -->
|
|
803
|
+
<div x-show="showRecordingsModal" class="fz-modal-bg" @click.self="showRecordingsModal=false">
|
|
804
|
+
<div class="fz-modal" style="width:760px" @click.stop>
|
|
805
|
+
<div class="fz-modal-title">📹 Session Recordings</div>
|
|
806
|
+
<div class="fz-modal-body" style="padding:0;max-height:55vh;overflow-y:auto">
|
|
807
|
+
<table style="width:100%;font-size:12px;border-collapse:collapse">
|
|
808
|
+
<thead style="position:sticky;top:0;background:var(--btn-bg)">
|
|
809
|
+
<tr>
|
|
810
|
+
<th style="padding:8px 12px;border-bottom:1px solid var(--border);color:var(--text-secondary);font-size:10px;text-transform:uppercase;text-align:left">Server</th>
|
|
811
|
+
<th style="padding:8px 12px;border-bottom:1px solid var(--border);color:var(--text-secondary);font-size:10px;text-transform:uppercase;text-align:left">User</th>
|
|
812
|
+
<th style="padding:8px 12px;border-bottom:1px solid var(--border);color:var(--text-secondary);font-size:10px;text-transform:uppercase;text-align:left">Started</th>
|
|
813
|
+
<th style="padding:8px 12px;border-bottom:1px solid var(--border);color:var(--text-secondary);font-size:10px;text-transform:uppercase;text-align:left">Duration</th>
|
|
814
|
+
<th style="padding:8px 12px;border-bottom:1px solid var(--border);color:var(--text-secondary);font-size:10px;text-transform:uppercase;text-align:left">Size</th>
|
|
815
|
+
<th style="padding:8px 12px;border-bottom:1px solid var(--border);color:var(--text-secondary);font-size:10px;text-transform:uppercase;width:160px"></th>
|
|
816
|
+
</tr>
|
|
817
|
+
</thead>
|
|
818
|
+
<tbody>
|
|
819
|
+
<template x-for="r in recordings" :key="r.id">
|
|
820
|
+
<tr style="border-bottom:1px solid var(--btn-bg)">
|
|
821
|
+
<td style="padding:6px 12px;color:var(--text-primary);font-weight:500" x-text="r.server_name"></td>
|
|
822
|
+
<td style="padding:6px 12px;color:var(--text-secondary)" x-text="r.username"></td>
|
|
823
|
+
<td style="padding:6px 12px;color:var(--text-secondary);font-size:11px" x-text="new Date(r.started_at).toLocaleString()"></td>
|
|
824
|
+
<td style="padding:6px 12px;color:var(--text-secondary);font-family:monospace" x-text="r.duration_s ? r.duration_s.toFixed(1) + 's' : '—'"></td>
|
|
825
|
+
<td style="padding:6px 12px;color:var(--text-secondary);font-family:monospace" x-text="r.size_bytes ? (r.size_bytes < 1024 ? r.size_bytes + ' B' : (r.size_bytes/1024).toFixed(1) + ' KB') : '—'"></td>
|
|
826
|
+
<td style="padding:6px 12px;display:flex;gap:4px">
|
|
827
|
+
<a :href="BASE + '/api/recordings/' + r.id + '/play?token=' + encodeURIComponent(token)" target="_blank" class="fz-btn fz-btn-primary" style="font-size:10px;padding:2px 8px;text-decoration:none">▶ Play</a>
|
|
828
|
+
<a :href="BASE + '/api/recordings/' + r.id + '/download?token=' + encodeURIComponent(token)" class="fz-btn" style="font-size:10px;padding:2px 8px;text-decoration:none">DL</a>
|
|
829
|
+
<button @click="confirmDeleteRecording(r)" class="fz-btn fz-btn-danger" style="font-size:10px;padding:1px 6px">Del</button>
|
|
830
|
+
</td>
|
|
831
|
+
</tr>
|
|
832
|
+
</template>
|
|
833
|
+
<template x-if="recordings.length === 0">
|
|
834
|
+
<tr><td colspan="6" style="padding:24px;text-align:center;color:var(--text-muted);font-size:12px">No recordings. Set <code>WEBGATE_RECORD_SESSIONS=true</code> on the server to start recording.</td></tr>
|
|
835
|
+
</template>
|
|
836
|
+
</tbody>
|
|
837
|
+
</table>
|
|
838
|
+
</div>
|
|
839
|
+
<div class="fz-modal-footer">
|
|
840
|
+
<button @click="showRecordingsModal=false" class="fz-btn">Close</button>
|
|
841
|
+
</div>
|
|
842
|
+
</div>
|
|
843
|
+
</div>
|
|
844
|
+
|
|
798
845
|
<!-- ======== EDIT GROUPS MODAL ======== -->
|
|
799
846
|
<div x-show="showGroupsModal" class="fz-modal-bg" @click.self="showGroupsModal=false">
|
|
800
847
|
<div class="fz-modal" style="width:400px" @click.stop>
|
|
@@ -901,6 +948,7 @@ function app() {
|
|
|
901
948
|
showAuditModal: false, auditEntries: [],
|
|
902
949
|
showApiKeysModal: false, apiKeys: [], newApiKeyName: '', createdApiKey: null,
|
|
903
950
|
showWebhooksModal: false, webhooks: [], newWebhook: { name: '', url: '', events: '*', secret: '' },
|
|
951
|
+
showRecordingsModal: false, recordings: [],
|
|
904
952
|
view: 'dashboard',
|
|
905
953
|
ssh: { host: '', port: 22, username: '', password: '' },
|
|
906
954
|
servers: [], groups: [], selectedGroup: '', searchQuery: '',
|
|
@@ -963,12 +1011,21 @@ function app() {
|
|
|
963
1011
|
async init() {
|
|
964
1012
|
document.documentElement.dataset.theme = this.theme;
|
|
965
1013
|
try { const cfg = await this.apiCall('/api/config'); this.demoMode = !!cfg.demo_mode; } catch {}
|
|
1014
|
+
// Pick up ?join=<token>[&mode=ro|rw] from the URL after auth
|
|
1015
|
+
const params = new URLSearchParams(location.search);
|
|
1016
|
+
this._pendingJoinToken = params.get('join');
|
|
1017
|
+
this._pendingJoinMode = params.get('mode') === 'ro' ? 'ro' : 'rw';
|
|
966
1018
|
if (this.token) {
|
|
967
1019
|
await this.fetchMe();
|
|
968
1020
|
if (this.currentUser?.must_change_password) return;
|
|
969
1021
|
await this.loadServers(); await this.loadGroups(); await this.loadStatuses(); await this.loadSnippets();
|
|
970
1022
|
setInterval(() => this.loadStatuses(), 30000);
|
|
971
1023
|
this._restoreTabs();
|
|
1024
|
+
if (this._pendingJoinToken) {
|
|
1025
|
+
this.joinSharedSession(this._pendingJoinToken, this._pendingJoinMode);
|
|
1026
|
+
history.replaceState({}, '', BASE + '/');
|
|
1027
|
+
this._pendingJoinToken = null;
|
|
1028
|
+
}
|
|
972
1029
|
}
|
|
973
1030
|
this.log('status', 'webgate initialized');
|
|
974
1031
|
},
|
|
@@ -1015,6 +1072,11 @@ function app() {
|
|
|
1015
1072
|
await this.loadServers(); await this.loadGroups(); await this.loadStatuses(); await this.loadSnippets();
|
|
1016
1073
|
this.username = ''; this.password = '';
|
|
1017
1074
|
this.log('status', 'Logged in as ' + this.currentUser?.username);
|
|
1075
|
+
if (this._pendingJoinToken) {
|
|
1076
|
+
this.joinSharedSession(this._pendingJoinToken, this._pendingJoinMode);
|
|
1077
|
+
history.replaceState({}, '', BASE + '/');
|
|
1078
|
+
this._pendingJoinToken = null;
|
|
1079
|
+
}
|
|
1018
1080
|
} catch (e) { this.showToast(e.message, 'error'); }
|
|
1019
1081
|
},
|
|
1020
1082
|
async fetchMe() { try { this.currentUser = await this.apiCall('/api/auth/me'); } catch { this.logout(); } },
|
|
@@ -1083,6 +1145,23 @@ function app() {
|
|
|
1083
1145
|
tab._ws.send(s.command + '\r');
|
|
1084
1146
|
this.log('cmd', `Sent snippet "${s.name}"`);
|
|
1085
1147
|
},
|
|
1148
|
+
|
|
1149
|
+
// Shared sessions
|
|
1150
|
+
async shareSession(tab) {
|
|
1151
|
+
if (!tab._sessionId) { this.showToast('Session not ready yet', 'error'); return; }
|
|
1152
|
+
try {
|
|
1153
|
+
const res = await this.apiCall(`/api/terminal/share/${tab._sessionId}`, { method: 'POST' });
|
|
1154
|
+
tab._shareToken = res.token;
|
|
1155
|
+
const url = `${location.origin}${BASE}/?join=${res.token}`;
|
|
1156
|
+
try { await navigator.clipboard.writeText(url); this.showToast('Share URL copied to clipboard'); }
|
|
1157
|
+
catch { prompt('Copy this share URL:', url); }
|
|
1158
|
+
this.log('status', `Shared session URL: ${url}`);
|
|
1159
|
+
} catch (e) { this.showToast(e.message, 'error'); }
|
|
1160
|
+
},
|
|
1161
|
+
joinSharedSession(token, mode = 'rw') {
|
|
1162
|
+
const tab = this._createTab('terminal', `shared (${mode})`, { _isJoined: true, _joinMode: mode });
|
|
1163
|
+
this.$nextTick(() => this._initTerminal(tab, `/api/ws/terminal/join/${token}?mode=${mode}`, {}));
|
|
1164
|
+
},
|
|
1086
1165
|
async loadStatuses() { try { this.serverStatuses = await this.apiCall('/api/servers/status'); } catch {} },
|
|
1087
1166
|
async loadGroups() { try { this.groups = await this.apiCall('/api/servers/groups'); } catch {} },
|
|
1088
1167
|
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 +1282,22 @@ function app() {
|
|
|
1203
1282
|
requestAnimationFrame(() => {
|
|
1204
1283
|
fitAddon.fit();
|
|
1205
1284
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
1206
|
-
const
|
|
1285
|
+
const sep = wsPath.includes('?') ? '&' : '?';
|
|
1286
|
+
const ws = new WebSocket(`${protocol}//${location.host}${BASE}${wsPath}${sep}token=${encodeURIComponent(this.token)}`);
|
|
1207
1287
|
tab._ws = ws;
|
|
1208
1288
|
ws.onopen = () => { ws.send(JSON.stringify({ ...connInfo, cols: term.cols, rows: term.rows })); this.log('status', `Connected: ${tab.title}`); };
|
|
1209
|
-
ws.onmessage = (e) =>
|
|
1289
|
+
ws.onmessage = (e) => {
|
|
1290
|
+
// Backend may send a control JSON frame (session id on connect, join ack)
|
|
1291
|
+
if (typeof e.data === 'string' && e.data.startsWith('{')) {
|
|
1292
|
+
try {
|
|
1293
|
+
const ctrl = JSON.parse(e.data);
|
|
1294
|
+
if (ctrl.type === 'session') { tab._sessionId = ctrl.session_id; return; }
|
|
1295
|
+
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; }
|
|
1296
|
+
if (ctrl.type === 'error') { this.log('error', ctrl.message); return; }
|
|
1297
|
+
} catch {}
|
|
1298
|
+
}
|
|
1299
|
+
term.write(e.data);
|
|
1300
|
+
};
|
|
1210
1301
|
ws.onerror = () => this.log('error', `Connection error: ${tab.title}`);
|
|
1211
1302
|
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
1303
|
term.onData(data => { if (ws.readyState === WebSocket.OPEN) ws.send(data); });
|
|
@@ -1507,6 +1598,19 @@ function app() {
|
|
|
1507
1598
|
async testWebhook(w) {
|
|
1508
1599
|
try { await this.apiCall(`/api/webhooks/${w.id}/test`, { method: 'POST' }); this.showToast(`Test queued for "${w.name}"`); setTimeout(async () => { this.webhooks = await this.apiCall('/api/webhooks'); }, 1500); } catch (e) { this.showToast(e.message, 'error'); }
|
|
1509
1600
|
},
|
|
1601
|
+
|
|
1602
|
+
// Recordings
|
|
1603
|
+
get BASE() { return BASE; },
|
|
1604
|
+
async openRecordingsModal() {
|
|
1605
|
+
try { this.recordings = await this.apiCall('/api/recordings'); }
|
|
1606
|
+
catch (e) { this.showToast(e.message, 'error'); return; }
|
|
1607
|
+
this.showRecordingsModal = true;
|
|
1608
|
+
},
|
|
1609
|
+
async confirmDeleteRecording(r) {
|
|
1610
|
+
if (!confirm(`Delete recording of "${r.server_name}" from ${new Date(r.started_at).toLocaleString()}?`)) return;
|
|
1611
|
+
try { await this.apiCall(`/api/recordings/${r.id}`, { method: 'DELETE' }); this.recordings = await this.apiCall('/api/recordings'); this.showToast('Recording deleted'); }
|
|
1612
|
+
catch (e) { this.showToast(e.message, 'error'); }
|
|
1613
|
+
},
|
|
1510
1614
|
};
|
|
1511
1615
|
}
|
|
1512
1616
|
</script>
|