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.
Files changed (108) hide show
  1. {webgate-0.3.3 → webgate-0.4.1}/CHANGELOG.md +48 -0
  2. {webgate-0.3.3 → webgate-0.4.1}/PKG-INFO +28 -1
  3. {webgate-0.3.3 → webgate-0.4.1}/README.md +27 -0
  4. webgate-0.4.1/docs/screenshots/v0.4/01-shared-terminal-owner.png +0 -0
  5. webgate-0.4.1/docs/screenshots/v0.4/02-shared-terminal-joiner.png +0 -0
  6. webgate-0.4.1/docs/screenshots/v0.4/03-recording-replay.png +0 -0
  7. webgate-0.4.1/docs/screenshots/v0.4/04-recordings-modal.png +0 -0
  8. {webgate-0.3.3 → webgate-0.4.1}/pyproject.toml +1 -1
  9. {webgate-0.3.3 → webgate-0.4.1}/src/webgate/app.py +6 -1
  10. {webgate-0.3.3 → webgate-0.4.1}/src/webgate/config.py +2 -0
  11. webgate-0.4.1/src/webgate/recordings/models.py +37 -0
  12. webgate-0.4.1/src/webgate/recordings/recorder.py +63 -0
  13. webgate-0.4.1/src/webgate/recordings/routes.py +139 -0
  14. {webgate-0.3.3 → webgate-0.4.1}/src/webgate/static/index.html +106 -2
  15. webgate-0.4.1/src/webgate/terminal/routes.py +212 -0
  16. webgate-0.4.1/src/webgate/terminal/shared.py +137 -0
  17. webgate-0.4.1/src/webgate/terminal/ws_handler.py +136 -0
  18. webgate-0.4.1/tests/__init__.py +0 -0
  19. webgate-0.3.3/src/webgate/terminal/routes.py +0 -126
  20. webgate-0.3.3/src/webgate/terminal/ws_handler.py +0 -102
  21. {webgate-0.3.3 → webgate-0.4.1}/.dockerignore +0 -0
  22. {webgate-0.3.3 → webgate-0.4.1}/.github/workflows/docs.yml +0 -0
  23. {webgate-0.3.3 → webgate-0.4.1}/.gitignore +0 -0
  24. {webgate-0.3.3 → webgate-0.4.1}/Dockerfile +0 -0
  25. {webgate-0.3.3 → webgate-0.4.1}/Dockerfile.demo +0 -0
  26. {webgate-0.3.3 → webgate-0.4.1}/Dockerfile.ssh-demo +0 -0
  27. {webgate-0.3.3 → webgate-0.4.1}/LICENSE +0 -0
  28. {webgate-0.3.3 → webgate-0.4.1}/ROADMAP.md +0 -0
  29. {webgate-0.3.3 → webgate-0.4.1}/VERSION +0 -0
  30. {webgate-0.3.3 → webgate-0.4.1}/after-reload.yaml +0 -0
  31. {webgate-0.3.3 → webgate-0.4.1}/compose.dev.yml +0 -0
  32. {webgate-0.3.3 → webgate-0.4.1}/compose.yml +0 -0
  33. {webgate-0.3.3 → webgate-0.4.1}/docs/api/auth.md +0 -0
  34. {webgate-0.3.3 → webgate-0.4.1}/docs/api/files.md +0 -0
  35. {webgate-0.3.3 → webgate-0.4.1}/docs/api/servers.md +0 -0
  36. {webgate-0.3.3 → webgate-0.4.1}/docs/api/terminal.md +0 -0
  37. {webgate-0.3.3 → webgate-0.4.1}/docs/changelog.md +0 -0
  38. {webgate-0.3.3 → webgate-0.4.1}/docs/getting-started/installation.md +0 -0
  39. {webgate-0.3.3 → webgate-0.4.1}/docs/getting-started/quickstart.md +0 -0
  40. {webgate-0.3.3 → webgate-0.4.1}/docs/guide/files.md +0 -0
  41. {webgate-0.3.3 → webgate-0.4.1}/docs/guide/servers.md +0 -0
  42. {webgate-0.3.3 → webgate-0.4.1}/docs/guide/split.md +0 -0
  43. {webgate-0.3.3 → webgate-0.4.1}/docs/guide/terminal.md +0 -0
  44. {webgate-0.3.3 → webgate-0.4.1}/docs/guide/users.md +0 -0
  45. {webgate-0.3.3 → webgate-0.4.1}/docs/index.md +0 -0
  46. {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/access-control.png +0 -0
  47. {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/audit.png +0 -0
  48. {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/edit-access-control.png +0 -0
  49. {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/editor.png +0 -0
  50. {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/light-theme.png +0 -0
  51. {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/login.png +0 -0
  52. {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/new-server-form.png +0 -0
  53. {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/sftp-restricted.png +0 -0
  54. {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/sftp.png +0 -0
  55. {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/site-manager.png +0 -0
  56. {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/split-view.png +0 -0
  57. {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/ssh-disabled.png +0 -0
  58. {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/terminal.png +0 -0
  59. {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/users.png +0 -0
  60. {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/v0.3/01-login-demo-banner.png +0 -0
  61. {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/v0.3/02-dashboard-jump-host.png +0 -0
  62. {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/v0.3/03-terminal-snippets-jump.png +0 -0
  63. {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/v0.3/04-snippet-executed.png +0 -0
  64. {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/v0.3/05-sftp-via-jump.png +0 -0
  65. {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/v0.3/06-webhooks-modal.png +0 -0
  66. {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/v0.3/07-webhook-test-fired.png +0 -0
  67. {webgate-0.3.3 → webgate-0.4.1}/docs/screenshots/v0.3/08-add-server-jump-via.png +0 -0
  68. {webgate-0.3.3 → webgate-0.4.1}/fly.toml +0 -0
  69. {webgate-0.3.3 → webgate-0.4.1}/mkdocs.yml +0 -0
  70. {webgate-0.3.3 → webgate-0.4.1}/src/webgate/__init__.py +0 -0
  71. {webgate-0.3.3 → webgate-0.4.1}/src/webgate/__main__.py +0 -0
  72. {webgate-0.3.3 → webgate-0.4.1}/src/webgate/audit/__init__.py +0 -0
  73. {webgate-0.3.3 → webgate-0.4.1}/src/webgate/audit/models.py +0 -0
  74. {webgate-0.3.3 → webgate-0.4.1}/src/webgate/audit/service.py +0 -0
  75. {webgate-0.3.3 → webgate-0.4.1}/src/webgate/auth/__init__.py +0 -0
  76. {webgate-0.3.3 → webgate-0.4.1}/src/webgate/auth/models.py +0 -0
  77. {webgate-0.3.3 → webgate-0.4.1}/src/webgate/auth/routes.py +0 -0
  78. {webgate-0.3.3 → webgate-0.4.1}/src/webgate/auth/service.py +0 -0
  79. {webgate-0.3.3 → webgate-0.4.1}/src/webgate/db/__init__.py +0 -0
  80. {webgate-0.3.3 → webgate-0.4.1}/src/webgate/db/engine.py +0 -0
  81. {webgate-0.3.3 → webgate-0.4.1}/src/webgate/demo.py +0 -0
  82. {webgate-0.3.3 → webgate-0.4.1}/src/webgate/files/__init__.py +0 -0
  83. {webgate-0.3.3 → webgate-0.4.1}/src/webgate/files/models.py +0 -0
  84. {webgate-0.3.3 → webgate-0.4.1}/src/webgate/files/pool.py +0 -0
  85. {webgate-0.3.3 → webgate-0.4.1}/src/webgate/files/routes.py +0 -0
  86. {webgate-0.3.3 → webgate-0.4.1}/src/webgate/files/sftp_service.py +0 -0
  87. {webgate-0.3.3/src/webgate/servers → webgate-0.4.1/src/webgate/recordings}/__init__.py +0 -0
  88. {webgate-0.3.3/src/webgate/snippets → webgate-0.4.1/src/webgate/servers}/__init__.py +0 -0
  89. {webgate-0.3.3 → webgate-0.4.1}/src/webgate/servers/crypto.py +0 -0
  90. {webgate-0.3.3 → webgate-0.4.1}/src/webgate/servers/models.py +0 -0
  91. {webgate-0.3.3 → webgate-0.4.1}/src/webgate/servers/monitor.py +0 -0
  92. {webgate-0.3.3 → webgate-0.4.1}/src/webgate/servers/routes.py +0 -0
  93. {webgate-0.3.3 → webgate-0.4.1}/src/webgate/servers/service.py +0 -0
  94. {webgate-0.3.3/src/webgate/terminal → webgate-0.4.1/src/webgate/snippets}/__init__.py +0 -0
  95. {webgate-0.3.3 → webgate-0.4.1}/src/webgate/snippets/models.py +0 -0
  96. {webgate-0.3.3 → webgate-0.4.1}/src/webgate/snippets/routes.py +0 -0
  97. {webgate-0.3.3/src/webgate/webhooks → webgate-0.4.1/src/webgate/terminal}/__init__.py +0 -0
  98. {webgate-0.3.3 → webgate-0.4.1}/src/webgate/terminal/ssh_session.py +0 -0
  99. {webgate-0.3.3/tests → webgate-0.4.1/src/webgate/webhooks}/__init__.py +0 -0
  100. {webgate-0.3.3 → webgate-0.4.1}/src/webgate/webhooks/dispatcher.py +0 -0
  101. {webgate-0.3.3 → webgate-0.4.1}/src/webgate/webhooks/models.py +0 -0
  102. {webgate-0.3.3 → webgate-0.4.1}/src/webgate/webhooks/routes.py +0 -0
  103. {webgate-0.3.3 → webgate-0.4.1}/tests/conftest.py +0 -0
  104. {webgate-0.3.3 → webgate-0.4.1}/tests/test_auth.py +0 -0
  105. {webgate-0.3.3 → webgate-0.4.1}/tests/test_files.py +0 -0
  106. {webgate-0.3.3 → webgate-0.4.1}/tests/test_servers.py +0 -0
  107. {webgate-0.3.3 → webgate-0.4.1}/tests/test_terminal.py +0 -0
  108. {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.3
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
  ![Light Theme](docs/screenshots/light-theme.png)
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
+ | ![Recordings](docs/screenshots/v0.4/04-recordings-modal.png) | ![Replay](docs/screenshots/v0.4/03-recording-replay.png) |
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
+ | ![Owner view](docs/screenshots/v0.4/01-shared-terminal-owner.png) | ![Joiner view](docs/screenshots/v0.4/02-shared-terminal-joiner.png) |
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
  ![Light Theme](docs/screenshots/light-theme.png)
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
+ | ![Recordings](docs/screenshots/v0.4/04-recordings-modal.png) | ![Replay](docs/screenshots/v0.4/03-recording-replay.png) |
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
+ | ![Owner view](docs/screenshots/v0.4/01-shared-terminal-owner.png) | ![Joiner view](docs/screenshots/v0.4/02-shared-terminal-joiner.png) |
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`).
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "webgate"
3
- version = "0.3.3"
3
+ version = "0.4.1"
4
4
  description = "Self-hosted web application for remote server management via SSH terminal and SFTP file browser"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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
- if request.method in WRITE_METHODS and path.startswith("/api/") and path not in WRITE_ALLOWLIST:
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 ws = new WebSocket(`${protocol}//${location.host}${BASE}${wsPath}?token=${encodeURIComponent(this.token)}`);
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) => term.write(e.data);
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>