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.
Files changed (101) hide show
  1. {webgate-0.3.2 → webgate-0.4.0}/CHANGELOG.md +34 -0
  2. {webgate-0.3.2 → webgate-0.4.0}/PKG-INFO +48 -1
  3. {webgate-0.3.2 → webgate-0.4.0}/README.md +47 -0
  4. webgate-0.4.0/after-reload.yaml +64 -0
  5. webgate-0.4.0/docs/screenshots/v0.3/01-login-demo-banner.png +0 -0
  6. webgate-0.4.0/docs/screenshots/v0.3/02-dashboard-jump-host.png +0 -0
  7. webgate-0.4.0/docs/screenshots/v0.3/03-terminal-snippets-jump.png +0 -0
  8. webgate-0.4.0/docs/screenshots/v0.3/04-snippet-executed.png +0 -0
  9. webgate-0.4.0/docs/screenshots/v0.3/05-sftp-via-jump.png +0 -0
  10. webgate-0.4.0/docs/screenshots/v0.3/06-webhooks-modal.png +0 -0
  11. webgate-0.4.0/docs/screenshots/v0.3/07-webhook-test-fired.png +0 -0
  12. webgate-0.4.0/docs/screenshots/v0.3/08-add-server-jump-via.png +0 -0
  13. webgate-0.4.0/docs/screenshots/v0.4/01-shared-terminal-owner.png +0 -0
  14. webgate-0.4.0/docs/screenshots/v0.4/02-shared-terminal-joiner.png +0 -0
  15. {webgate-0.3.2 → webgate-0.4.0}/pyproject.toml +1 -1
  16. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/app.py +4 -1
  17. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/static/index.html +50 -4
  18. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/terminal/routes.py +54 -10
  19. webgate-0.4.0/src/webgate/terminal/shared.py +129 -0
  20. webgate-0.4.0/src/webgate/terminal/ws_handler.py +131 -0
  21. webgate-0.3.2/src/webgate/terminal/ws_handler.py +0 -102
  22. {webgate-0.3.2 → webgate-0.4.0}/.dockerignore +0 -0
  23. {webgate-0.3.2 → webgate-0.4.0}/.github/workflows/docs.yml +0 -0
  24. {webgate-0.3.2 → webgate-0.4.0}/.gitignore +0 -0
  25. {webgate-0.3.2 → webgate-0.4.0}/Dockerfile +0 -0
  26. {webgate-0.3.2 → webgate-0.4.0}/Dockerfile.demo +0 -0
  27. {webgate-0.3.2 → webgate-0.4.0}/Dockerfile.ssh-demo +0 -0
  28. {webgate-0.3.2 → webgate-0.4.0}/LICENSE +0 -0
  29. {webgate-0.3.2 → webgate-0.4.0}/ROADMAP.md +0 -0
  30. {webgate-0.3.2 → webgate-0.4.0}/VERSION +0 -0
  31. {webgate-0.3.2 → webgate-0.4.0}/compose.dev.yml +0 -0
  32. {webgate-0.3.2 → webgate-0.4.0}/compose.yml +0 -0
  33. {webgate-0.3.2 → webgate-0.4.0}/docs/api/auth.md +0 -0
  34. {webgate-0.3.2 → webgate-0.4.0}/docs/api/files.md +0 -0
  35. {webgate-0.3.2 → webgate-0.4.0}/docs/api/servers.md +0 -0
  36. {webgate-0.3.2 → webgate-0.4.0}/docs/api/terminal.md +0 -0
  37. {webgate-0.3.2 → webgate-0.4.0}/docs/changelog.md +0 -0
  38. {webgate-0.3.2 → webgate-0.4.0}/docs/getting-started/installation.md +0 -0
  39. {webgate-0.3.2 → webgate-0.4.0}/docs/getting-started/quickstart.md +0 -0
  40. {webgate-0.3.2 → webgate-0.4.0}/docs/guide/files.md +0 -0
  41. {webgate-0.3.2 → webgate-0.4.0}/docs/guide/servers.md +0 -0
  42. {webgate-0.3.2 → webgate-0.4.0}/docs/guide/split.md +0 -0
  43. {webgate-0.3.2 → webgate-0.4.0}/docs/guide/terminal.md +0 -0
  44. {webgate-0.3.2 → webgate-0.4.0}/docs/guide/users.md +0 -0
  45. {webgate-0.3.2 → webgate-0.4.0}/docs/index.md +0 -0
  46. {webgate-0.3.2 → webgate-0.4.0}/docs/screenshots/access-control.png +0 -0
  47. {webgate-0.3.2 → webgate-0.4.0}/docs/screenshots/audit.png +0 -0
  48. {webgate-0.3.2 → webgate-0.4.0}/docs/screenshots/edit-access-control.png +0 -0
  49. {webgate-0.3.2 → webgate-0.4.0}/docs/screenshots/editor.png +0 -0
  50. {webgate-0.3.2 → webgate-0.4.0}/docs/screenshots/light-theme.png +0 -0
  51. {webgate-0.3.2 → webgate-0.4.0}/docs/screenshots/login.png +0 -0
  52. {webgate-0.3.2 → webgate-0.4.0}/docs/screenshots/new-server-form.png +0 -0
  53. {webgate-0.3.2 → webgate-0.4.0}/docs/screenshots/sftp-restricted.png +0 -0
  54. {webgate-0.3.2 → webgate-0.4.0}/docs/screenshots/sftp.png +0 -0
  55. {webgate-0.3.2 → webgate-0.4.0}/docs/screenshots/site-manager.png +0 -0
  56. {webgate-0.3.2 → webgate-0.4.0}/docs/screenshots/split-view.png +0 -0
  57. {webgate-0.3.2 → webgate-0.4.0}/docs/screenshots/ssh-disabled.png +0 -0
  58. {webgate-0.3.2 → webgate-0.4.0}/docs/screenshots/terminal.png +0 -0
  59. {webgate-0.3.2 → webgate-0.4.0}/docs/screenshots/users.png +0 -0
  60. {webgate-0.3.2 → webgate-0.4.0}/fly.toml +0 -0
  61. {webgate-0.3.2 → webgate-0.4.0}/mkdocs.yml +0 -0
  62. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/__init__.py +0 -0
  63. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/__main__.py +0 -0
  64. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/audit/__init__.py +0 -0
  65. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/audit/models.py +0 -0
  66. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/audit/service.py +0 -0
  67. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/auth/__init__.py +0 -0
  68. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/auth/models.py +0 -0
  69. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/auth/routes.py +0 -0
  70. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/auth/service.py +0 -0
  71. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/config.py +0 -0
  72. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/db/__init__.py +0 -0
  73. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/db/engine.py +0 -0
  74. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/demo.py +0 -0
  75. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/files/__init__.py +0 -0
  76. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/files/models.py +0 -0
  77. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/files/pool.py +0 -0
  78. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/files/routes.py +0 -0
  79. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/files/sftp_service.py +0 -0
  80. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/servers/__init__.py +0 -0
  81. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/servers/crypto.py +0 -0
  82. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/servers/models.py +0 -0
  83. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/servers/monitor.py +0 -0
  84. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/servers/routes.py +0 -0
  85. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/servers/service.py +0 -0
  86. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/snippets/__init__.py +0 -0
  87. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/snippets/models.py +0 -0
  88. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/snippets/routes.py +0 -0
  89. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/terminal/__init__.py +0 -0
  90. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/terminal/ssh_session.py +0 -0
  91. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/webhooks/__init__.py +0 -0
  92. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/webhooks/dispatcher.py +0 -0
  93. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/webhooks/models.py +0 -0
  94. {webgate-0.3.2 → webgate-0.4.0}/src/webgate/webhooks/routes.py +0 -0
  95. {webgate-0.3.2 → webgate-0.4.0}/tests/__init__.py +0 -0
  96. {webgate-0.3.2 → webgate-0.4.0}/tests/conftest.py +0 -0
  97. {webgate-0.3.2 → webgate-0.4.0}/tests/test_auth.py +0 -0
  98. {webgate-0.3.2 → webgate-0.4.0}/tests/test_files.py +0 -0
  99. {webgate-0.3.2 → webgate-0.4.0}/tests/test_servers.py +0 -0
  100. {webgate-0.3.2 → webgate-0.4.0}/tests/test_terminal.py +0 -0
  101. {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.2
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
  ![Light Theme](docs/screenshots/light-theme.png)
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
+ | ![Owner view](docs/screenshots/v0.4/01-shared-terminal-owner.png) | ![Joiner view](docs/screenshots/v0.4/02-shared-terminal-joiner.png) |
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
+ ![Demo banner](docs/screenshots/v0.3/01-login-demo-banner.png)
261
+
262
+ #### Site Manager with jump-host indicator
263
+ The `↺` next to `internal-app` means it tunnels through `bastion`.
264
+
265
+ ![Dashboard with jump host](docs/screenshots/v0.3/02-dashboard-jump-host.png)
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
+ ![Terminal snippets](docs/screenshots/v0.3/03-terminal-snippets-jump.png)
271
+
272
+ #### Snippet executed
273
+ Clicking `ls -lah` sent the command and the output appears in the terminal.
274
+
275
+ ![Snippet executed](docs/screenshots/v0.3/04-snippet-executed.png)
276
+
277
+ #### SFTP via jump host
278
+ Browsing the `internal-app` filesystem — the SFTP connection is also tunneled through the bastion.
279
+
280
+ ![SFTP via jump](docs/screenshots/v0.3/05-sftp-via-jump.png)
281
+
282
+ #### Webhooks management (admin)
283
+ Per-event subscription, optional HMAC signing, last delivery status.
284
+
285
+ ![Webhooks modal](docs/screenshots/v0.3/06-webhooks-modal.png)
286
+
287
+ #### Add Server form with Jump Via dropdown
288
+ Pick any other registered server as the bastion.
289
+
290
+ ![Add Server with Jump Via](docs/screenshots/v0.3/08-add-server-jump-via.png)
291
+
245
292
  ## Quick Start
246
293
 
247
294
  ### Docker (recommended)
@@ -194,6 +194,53 @@ flowchart TB
194
194
  ### Light Theme
195
195
  ![Light Theme](docs/screenshots/light-theme.png)
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
+ | ![Owner view](docs/screenshots/v0.4/01-shared-terminal-owner.png) | ![Joiner view](docs/screenshots/v0.4/02-shared-terminal-joiner.png) |
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
+ ![Demo banner](docs/screenshots/v0.3/01-login-demo-banner.png)
213
+
214
+ #### Site Manager with jump-host indicator
215
+ The `↺` next to `internal-app` means it tunnels through `bastion`.
216
+
217
+ ![Dashboard with jump host](docs/screenshots/v0.3/02-dashboard-jump-host.png)
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
+ ![Terminal snippets](docs/screenshots/v0.3/03-terminal-snippets-jump.png)
223
+
224
+ #### Snippet executed
225
+ Clicking `ls -lah` sent the command and the output appears in the terminal.
226
+
227
+ ![Snippet executed](docs/screenshots/v0.3/04-snippet-executed.png)
228
+
229
+ #### SFTP via jump host
230
+ Browsing the `internal-app` filesystem — the SFTP connection is also tunneled through the bastion.
231
+
232
+ ![SFTP via jump](docs/screenshots/v0.3/05-sftp-via-jump.png)
233
+
234
+ #### Webhooks management (admin)
235
+ Per-event subscription, optional HMAC signing, last delivery status.
236
+
237
+ ![Webhooks modal](docs/screenshots/v0.3/06-webhooks-modal.png)
238
+
239
+ #### Add Server form with Jump Via dropdown
240
+ Pick any other registered server as the bastion.
241
+
242
+ ![Add Server with Jump Via](docs/screenshots/v0.3/08-add-server-jump-via.png)
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"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "webgate"
3
- version = "0.3.2"
3
+ version = "0.4.0"
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"
@@ -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
- if request.method in WRITE_METHODS and path.startswith("/api/") and path not in WRITE_ALLOWLIST:
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 ws = new WebSocket(`${protocol}//${location.host}${BASE}${wsPath}?token=${encodeURIComponent(this.token)}`);
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) => term.write(e.data);
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 fastapi import APIRouter, WebSocket, WebSocketDisconnect
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.ws_handler import authenticate_websocket, handle_terminal_ws
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
- host=host,
51
- port=port,
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