webgate 0.3.1__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. {webgate-0.3.1 → webgate-0.3.2}/CHANGELOG.md +19 -0
  2. {webgate-0.3.1 → webgate-0.3.2}/PKG-INFO +17 -1
  3. {webgate-0.3.1 → webgate-0.3.2}/README.md +15 -0
  4. {webgate-0.3.1 → webgate-0.3.2}/pyproject.toml +2 -1
  5. {webgate-0.3.1 → webgate-0.3.2}/src/webgate/app.py +2 -0
  6. {webgate-0.3.1 → webgate-0.3.2}/src/webgate/auth/routes.py +10 -0
  7. {webgate-0.3.1 → webgate-0.3.2}/src/webgate/files/routes.py +8 -0
  8. {webgate-0.3.1 → webgate-0.3.2}/src/webgate/servers/routes.py +7 -0
  9. {webgate-0.3.1 → webgate-0.3.2}/src/webgate/static/index.html +78 -0
  10. {webgate-0.3.1 → webgate-0.3.2}/src/webgate/terminal/routes.py +5 -0
  11. webgate-0.3.2/src/webgate/webhooks/dispatcher.py +74 -0
  12. webgate-0.3.2/src/webgate/webhooks/models.py +61 -0
  13. webgate-0.3.2/src/webgate/webhooks/routes.py +129 -0
  14. webgate-0.3.2/tests/__init__.py +0 -0
  15. {webgate-0.3.1 → webgate-0.3.2}/uv.lock +2 -0
  16. {webgate-0.3.1 → webgate-0.3.2}/.dockerignore +0 -0
  17. {webgate-0.3.1 → webgate-0.3.2}/.github/workflows/docs.yml +0 -0
  18. {webgate-0.3.1 → webgate-0.3.2}/.gitignore +0 -0
  19. {webgate-0.3.1 → webgate-0.3.2}/Dockerfile +0 -0
  20. {webgate-0.3.1 → webgate-0.3.2}/Dockerfile.demo +0 -0
  21. {webgate-0.3.1 → webgate-0.3.2}/Dockerfile.ssh-demo +0 -0
  22. {webgate-0.3.1 → webgate-0.3.2}/LICENSE +0 -0
  23. {webgate-0.3.1 → webgate-0.3.2}/ROADMAP.md +0 -0
  24. {webgate-0.3.1 → webgate-0.3.2}/VERSION +0 -0
  25. {webgate-0.3.1 → webgate-0.3.2}/compose.dev.yml +0 -0
  26. {webgate-0.3.1 → webgate-0.3.2}/compose.yml +0 -0
  27. {webgate-0.3.1 → webgate-0.3.2}/docs/api/auth.md +0 -0
  28. {webgate-0.3.1 → webgate-0.3.2}/docs/api/files.md +0 -0
  29. {webgate-0.3.1 → webgate-0.3.2}/docs/api/servers.md +0 -0
  30. {webgate-0.3.1 → webgate-0.3.2}/docs/api/terminal.md +0 -0
  31. {webgate-0.3.1 → webgate-0.3.2}/docs/changelog.md +0 -0
  32. {webgate-0.3.1 → webgate-0.3.2}/docs/getting-started/installation.md +0 -0
  33. {webgate-0.3.1 → webgate-0.3.2}/docs/getting-started/quickstart.md +0 -0
  34. {webgate-0.3.1 → webgate-0.3.2}/docs/guide/files.md +0 -0
  35. {webgate-0.3.1 → webgate-0.3.2}/docs/guide/servers.md +0 -0
  36. {webgate-0.3.1 → webgate-0.3.2}/docs/guide/split.md +0 -0
  37. {webgate-0.3.1 → webgate-0.3.2}/docs/guide/terminal.md +0 -0
  38. {webgate-0.3.1 → webgate-0.3.2}/docs/guide/users.md +0 -0
  39. {webgate-0.3.1 → webgate-0.3.2}/docs/index.md +0 -0
  40. {webgate-0.3.1 → webgate-0.3.2}/docs/screenshots/access-control.png +0 -0
  41. {webgate-0.3.1 → webgate-0.3.2}/docs/screenshots/audit.png +0 -0
  42. {webgate-0.3.1 → webgate-0.3.2}/docs/screenshots/edit-access-control.png +0 -0
  43. {webgate-0.3.1 → webgate-0.3.2}/docs/screenshots/editor.png +0 -0
  44. {webgate-0.3.1 → webgate-0.3.2}/docs/screenshots/light-theme.png +0 -0
  45. {webgate-0.3.1 → webgate-0.3.2}/docs/screenshots/login.png +0 -0
  46. {webgate-0.3.1 → webgate-0.3.2}/docs/screenshots/new-server-form.png +0 -0
  47. {webgate-0.3.1 → webgate-0.3.2}/docs/screenshots/sftp-restricted.png +0 -0
  48. {webgate-0.3.1 → webgate-0.3.2}/docs/screenshots/sftp.png +0 -0
  49. {webgate-0.3.1 → webgate-0.3.2}/docs/screenshots/site-manager.png +0 -0
  50. {webgate-0.3.1 → webgate-0.3.2}/docs/screenshots/split-view.png +0 -0
  51. {webgate-0.3.1 → webgate-0.3.2}/docs/screenshots/ssh-disabled.png +0 -0
  52. {webgate-0.3.1 → webgate-0.3.2}/docs/screenshots/terminal.png +0 -0
  53. {webgate-0.3.1 → webgate-0.3.2}/docs/screenshots/users.png +0 -0
  54. {webgate-0.3.1 → webgate-0.3.2}/fly.toml +0 -0
  55. {webgate-0.3.1 → webgate-0.3.2}/mkdocs.yml +0 -0
  56. {webgate-0.3.1 → webgate-0.3.2}/src/webgate/__init__.py +0 -0
  57. {webgate-0.3.1 → webgate-0.3.2}/src/webgate/__main__.py +0 -0
  58. {webgate-0.3.1 → webgate-0.3.2}/src/webgate/audit/__init__.py +0 -0
  59. {webgate-0.3.1 → webgate-0.3.2}/src/webgate/audit/models.py +0 -0
  60. {webgate-0.3.1 → webgate-0.3.2}/src/webgate/audit/service.py +0 -0
  61. {webgate-0.3.1 → webgate-0.3.2}/src/webgate/auth/__init__.py +0 -0
  62. {webgate-0.3.1 → webgate-0.3.2}/src/webgate/auth/models.py +0 -0
  63. {webgate-0.3.1 → webgate-0.3.2}/src/webgate/auth/service.py +0 -0
  64. {webgate-0.3.1 → webgate-0.3.2}/src/webgate/config.py +0 -0
  65. {webgate-0.3.1 → webgate-0.3.2}/src/webgate/db/__init__.py +0 -0
  66. {webgate-0.3.1 → webgate-0.3.2}/src/webgate/db/engine.py +0 -0
  67. {webgate-0.3.1 → webgate-0.3.2}/src/webgate/demo.py +0 -0
  68. {webgate-0.3.1 → webgate-0.3.2}/src/webgate/files/__init__.py +0 -0
  69. {webgate-0.3.1 → webgate-0.3.2}/src/webgate/files/models.py +0 -0
  70. {webgate-0.3.1 → webgate-0.3.2}/src/webgate/files/pool.py +0 -0
  71. {webgate-0.3.1 → webgate-0.3.2}/src/webgate/files/sftp_service.py +0 -0
  72. {webgate-0.3.1 → webgate-0.3.2}/src/webgate/servers/__init__.py +0 -0
  73. {webgate-0.3.1 → webgate-0.3.2}/src/webgate/servers/crypto.py +0 -0
  74. {webgate-0.3.1 → webgate-0.3.2}/src/webgate/servers/models.py +0 -0
  75. {webgate-0.3.1 → webgate-0.3.2}/src/webgate/servers/monitor.py +0 -0
  76. {webgate-0.3.1 → webgate-0.3.2}/src/webgate/servers/service.py +0 -0
  77. {webgate-0.3.1 → webgate-0.3.2}/src/webgate/snippets/__init__.py +0 -0
  78. {webgate-0.3.1 → webgate-0.3.2}/src/webgate/snippets/models.py +0 -0
  79. {webgate-0.3.1 → webgate-0.3.2}/src/webgate/snippets/routes.py +0 -0
  80. {webgate-0.3.1 → webgate-0.3.2}/src/webgate/terminal/__init__.py +0 -0
  81. {webgate-0.3.1 → webgate-0.3.2}/src/webgate/terminal/ssh_session.py +0 -0
  82. {webgate-0.3.1 → webgate-0.3.2}/src/webgate/terminal/ws_handler.py +0 -0
  83. {webgate-0.3.1/tests → webgate-0.3.2/src/webgate/webhooks}/__init__.py +0 -0
  84. {webgate-0.3.1 → webgate-0.3.2}/tests/conftest.py +0 -0
  85. {webgate-0.3.1 → webgate-0.3.2}/tests/test_auth.py +0 -0
  86. {webgate-0.3.1 → webgate-0.3.2}/tests/test_files.py +0 -0
  87. {webgate-0.3.1 → webgate-0.3.2}/tests/test_servers.py +0 -0
  88. {webgate-0.3.1 → webgate-0.3.2}/tests/test_terminal.py +0 -0
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.3.2 (2026-04-15)
4
+
5
+ ### Features
6
+
7
+ - **Webhook notifications** -- admin can register HTTPS endpoints that receive a JSON POST when significant events fire. Supported events: `user_login`, `user_login_failed`, `ssh_connect`, `sftp_upload`, `sftp_delete`, `server_added`, `server_deleted`. Each webhook can subscribe to all events (`*`) or a specific subset.
8
+ - **HMAC-SHA256 signing** -- optional shared secret per webhook; the dispatcher signs the body and sends it as `X-Webgate-Signature: sha256=<hex>` so the receiver can verify authenticity.
9
+ - **Test button + delivery telemetry** -- one-click test fire from the admin UI; each webhook row shows the last HTTP status and timestamp.
10
+
11
+ ### Details
12
+
13
+ - New `Webhook` model + REST CRUD at `/api/webhooks` (admin-only)
14
+ - New `webgate.webhooks.dispatcher.fire(event, data)` -- fire-and-forget, schedules HTTP delivery via `httpx`, never blocks the caller
15
+ - Frontend: "Webhooks" button in the top toolbar (admin only) with full management modal
16
+ - New base dependency: `httpx>=0.28.0` (used by the dispatcher)
17
+
18
+ Verified end-to-end against a real HTTP echo receiver: `user_login`, `server_added` and a manual test all delivered with HTTP 200 and signature header.
19
+
20
+ ---
21
+
3
22
  ## v0.3.1 (2026-04-15)
4
23
 
5
24
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webgate
3
- Version: 0.3.1
3
+ Version: 0.3.2
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/
@@ -32,6 +32,7 @@ Requires-Dist: asyncssh>=2.18.0
32
32
  Requires-Dist: bcrypt>=4.2.0
33
33
  Requires-Dist: cryptography>=44.0.0
34
34
  Requires-Dist: fastapi>=0.115.0
35
+ Requires-Dist: httpx>=0.28.0
35
36
  Requires-Dist: passlib[bcrypt]>=1.7.4
36
37
  Requires-Dist: pydantic-settings>=2.7.0
37
38
  Requires-Dist: pydantic>=2.10.0
@@ -471,6 +472,21 @@ When adding a server, pick another server from the **Jump Via** dropdown to tunn
471
472
  ### SSH command snippets
472
473
 
473
474
  Each user has a personal library of named commands. They appear as buttons in the terminal toolbar -- click to send `command + Enter`. Right-click to delete, `+` to create. Stored in the `snippets` table.
475
+
476
+ ### Webhooks
477
+
478
+ Admins can register HTTPS endpoints that receive a JSON POST when events fire (`user_login`, `ssh_connect`, `server_added`, etc). Each webhook can subscribe to all events (`*`) or a specific subset.
479
+
480
+ ```json
481
+ POST https://your-receiver.example.com/wh
482
+ Content-Type: application/json
483
+ X-Webgate-Signature: sha256=<hmac of body using your webhook's secret>
484
+
485
+ {"event": "ssh_connect", "timestamp": "2026-04-15T18:03:18Z",
486
+ "data": {"user": "alice", "server": "prod-web-01", "host": "10.0.1.50:22"}}
487
+ ```
488
+
489
+ Manage from the **Webhooks** button in the top toolbar (admin only). Last delivery status is shown next to each row.
474
490
  | `WEBGATE_LOG_LEVEL` | `info` | Log level |
475
491
  | `WEBGATE_SESSION_TIMEOUT` | `3600` | SSH session timeout (seconds) |
476
492
  | `WEBGATE_MAX_UPLOAD_SIZE` | `104857600` | Max upload size (100 MB) |
@@ -424,6 +424,21 @@ When adding a server, pick another server from the **Jump Via** dropdown to tunn
424
424
  ### SSH command snippets
425
425
 
426
426
  Each user has a personal library of named commands. They appear as buttons in the terminal toolbar -- click to send `command + Enter`. Right-click to delete, `+` to create. Stored in the `snippets` table.
427
+
428
+ ### Webhooks
429
+
430
+ Admins can register HTTPS endpoints that receive a JSON POST when events fire (`user_login`, `ssh_connect`, `server_added`, etc). Each webhook can subscribe to all events (`*`) or a specific subset.
431
+
432
+ ```json
433
+ POST https://your-receiver.example.com/wh
434
+ Content-Type: application/json
435
+ X-Webgate-Signature: sha256=<hmac of body using your webhook's secret>
436
+
437
+ {"event": "ssh_connect", "timestamp": "2026-04-15T18:03:18Z",
438
+ "data": {"user": "alice", "server": "prod-web-01", "host": "10.0.1.50:22"}}
439
+ ```
440
+
441
+ Manage from the **Webhooks** button in the top toolbar (admin only). Last delivery status is shown next to each row.
427
442
  | `WEBGATE_LOG_LEVEL` | `info` | Log level |
428
443
  | `WEBGATE_SESSION_TIMEOUT` | `3600` | SSH session timeout (seconds) |
429
444
  | `WEBGATE_MAX_UPLOAD_SIZE` | `104857600` | Max upload size (100 MB) |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "webgate"
3
- version = "0.3.1"
3
+ version = "0.3.2"
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"
@@ -41,6 +41,7 @@ dependencies = [
41
41
  "pyotp>=2.9.0",
42
42
  "qrcode[pil]>=8.2",
43
43
  "asyncpg>=0.30.0",
44
+ "httpx>=0.28.0",
44
45
  ]
45
46
 
46
47
  [project.urls]
@@ -20,6 +20,7 @@ from webgate.servers.monitor import server_monitor
20
20
  from webgate.servers.routes import router as servers_router
21
21
  from webgate.snippets.routes import router as snippets_router
22
22
  from webgate.terminal.routes import router as terminal_router
23
+ from webgate.webhooks.routes import router as webhooks_router
23
24
 
24
25
 
25
26
  @asynccontextmanager
@@ -91,6 +92,7 @@ def create_app() -> FastAPI:
91
92
  app.include_router(terminal_router)
92
93
  app.include_router(files_router)
93
94
  app.include_router(snippets_router)
95
+ app.include_router(webhooks_router)
94
96
 
95
97
  app.mount("/", StaticFiles(directory=str(settings.static_dir), html=True), name="static")
96
98
 
@@ -10,6 +10,7 @@ limiter = Limiter(key_func=get_remote_address)
10
10
 
11
11
  from webgate.audit.models import AuditOut
12
12
  from webgate.audit.service import get_audit_log, log_action
13
+ from webgate.webhooks.dispatcher import fire as fire_webhook
13
14
  from webgate.auth.models import (
14
15
  ApiKeyCreate,
15
16
  ApiKeyCreated,
@@ -88,6 +89,10 @@ def _require_admin(user: UserOut) -> None:
88
89
  async def login(request: Request, body: UserLogin, session: SessionDep) -> LoginOut:
89
90
  user = await get_user_by_username(session, body.username)
90
91
  if not user or not verify_password(body.password, user.hashed_password):
92
+ await fire_webhook("user_login_failed", {
93
+ "username": body.username,
94
+ "ip": request.client.host if request.client else "",
95
+ })
91
96
  raise HTTPException(
92
97
  status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials"
93
98
  )
@@ -111,6 +116,11 @@ async def login(request: Request, body: UserLogin, session: SessionDep) -> Login
111
116
  "login",
112
117
  ip_address=request.client.host if request.client else "",
113
118
  )
119
+ await fire_webhook("user_login", {
120
+ "username": user.username,
121
+ "user_id": user.id,
122
+ "ip": request.client.host if request.client else "",
123
+ })
114
124
  return LoginOut(access_token=token)
115
125
 
116
126
 
@@ -26,6 +26,7 @@ from webgate.files.pool import sftp_pool
26
26
  from webgate.files.sftp_service import SFTPClient, validate_path
27
27
  from webgate.servers.models import Server
28
28
  from webgate.servers.service import get_server, get_server_credentials, resolve_jump_creds
29
+ from webgate.webhooks.dispatcher import fire as fire_webhook
29
30
 
30
31
  router = APIRouter(prefix="/api/files", tags=["files"])
31
32
 
@@ -199,6 +200,10 @@ async def upload_files(
199
200
  data = await f.read()
200
201
  await client.upload(dest, data)
201
202
  uploaded.append(dest)
203
+ await fire_webhook("sftp_upload", {
204
+ "user": current_user.username, "server_id": server_id,
205
+ "paths": uploaded, "count": len(uploaded),
206
+ })
202
207
  return {"uploaded": uploaded, "count": len(uploaded)}
203
208
  except ValueError as e:
204
209
  raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
@@ -256,6 +261,9 @@ async def delete_item(
256
261
  check_read_only(read_only)
257
262
  check_path_allowed(path, allowed_paths)
258
263
  await client.delete(path)
264
+ await fire_webhook("sftp_delete", {
265
+ "user": current_user.username, "server_id": server_id, "path": path,
266
+ })
259
267
  return {"path": validate_path(path), "status": "deleted"}
260
268
  except ValueError as e:
261
269
  raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
@@ -7,6 +7,7 @@ from webgate.auth.models import UserOut
7
7
  from webgate.auth.routes import get_current_user
8
8
  from webgate.db.engine import get_session
9
9
  from webgate.servers.models import ServerCreate, ServerImport, ServerOut, ServerUpdate
10
+ from webgate.webhooks.dispatcher import fire as fire_webhook
10
11
  from webgate.servers.service import (
11
12
  create_server,
12
13
  delete_server,
@@ -57,6 +58,10 @@ async def create(
57
58
  ) -> ServerOut:
58
59
  _require_admin(current_user)
59
60
  server = await create_server(session, body, current_user.id)
61
+ await fire_webhook("server_added", {
62
+ "id": server.id, "name": server.name, "hostname": server.hostname,
63
+ "by": current_user.username,
64
+ })
60
65
  return server_to_out(server)
61
66
 
62
67
 
@@ -144,7 +149,9 @@ async def delete(
144
149
  server = await get_server(session, server_id, current_user.id, is_admin=True)
145
150
  if not server:
146
151
  raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Server not found")
152
+ server_info = {"id": server.id, "name": server.name, "hostname": server.hostname}
147
153
  await delete_server(session, server)
154
+ await fire_webhook("server_deleted", {**server_info, "by": current_user.username})
148
155
 
149
156
 
150
157
  @router.post("/{server_id}/test")
@@ -232,6 +232,7 @@ window._showToastImpl = function(message, type) {
232
232
  <button x-show="currentUser?.is_admin" @click="showServerModal=true;resetServerForm()" class="fz-btn" style="font-size:11px" title="New Server (Ctrl+N)">New Server</button>
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
+ <button x-show="currentUser?.is_admin" @click="openWebhooksModal()" class="fz-btn" style="font-size:11px">Webhooks</button>
235
236
  <button @click="openApiKeysModal()" class="fz-btn" style="font-size:11px">Keys</button>
236
237
  </div>
237
238
  <div style="display:flex;align-items:center;gap:8px">
@@ -741,6 +742,59 @@ window._showToastImpl = function(message, type) {
741
742
  </div>
742
743
  </div>
743
744
 
745
+ <!-- ======== WEBHOOKS MODAL ======== -->
746
+ <div x-show="showWebhooksModal" class="fz-modal-bg" @click.self="showWebhooksModal=false">
747
+ <div class="fz-modal" style="width:720px" @click.stop>
748
+ <div class="fz-modal-title">Webhooks</div>
749
+ <div class="fz-modal-body" style="padding:0;max-height:50vh;overflow-y:auto">
750
+ <table style="width:100%;font-size:12px;border-collapse:collapse">
751
+ <thead style="position:sticky;top:0;background:var(--btn-bg)">
752
+ <tr>
753
+ <th style="padding:8px 12px;border-bottom:1px solid var(--border);color:var(--text-secondary);font-size:10px;text-transform:uppercase;text-align:left">Name</th>
754
+ <th style="padding:8px 12px;border-bottom:1px solid var(--border);color:var(--text-secondary);font-size:10px;text-transform:uppercase;text-align:left">URL</th>
755
+ <th style="padding:8px 12px;border-bottom:1px solid var(--border);color:var(--text-secondary);font-size:10px;text-transform:uppercase;text-align:left">Events</th>
756
+ <th style="padding:8px 12px;border-bottom:1px solid var(--border);color:var(--text-secondary);font-size:10px;text-transform:uppercase;text-align:left">Last</th>
757
+ <th style="padding:8px 12px;border-bottom:1px solid var(--border);color:var(--text-secondary);font-size:10px;text-transform:uppercase;width:120px"></th>
758
+ </tr>
759
+ </thead>
760
+ <tbody>
761
+ <template x-for="w in webhooks" :key="w.id">
762
+ <tr style="border-bottom:1px solid var(--btn-bg)">
763
+ <td style="padding:6px 12px;color:var(--text-primary);font-weight:500" x-text="w.name"></td>
764
+ <td style="padding:6px 12px;font-family:'JetBrains Mono',monospace;color:var(--text-secondary);font-size:11px;max-width:200px;overflow:hidden;text-overflow:ellipsis" :title="w.url" x-text="w.url"></td>
765
+ <td style="padding:6px 12px;color:var(--text-secondary);font-size:11px" x-text="w.events.join(', ')"></td>
766
+ <td style="padding:6px 12px;color:var(--text-secondary);font-size:11px">
767
+ <span x-show="w.last_status" :style="(w.last_status >= 200 && w.last_status < 300) ? 'color:var(--success)' : 'color:var(--danger)'" x-text="w.last_status + ' · ' + (w.last_fired_at ? new Date(w.last_fired_at).toLocaleString() : '')"></span>
768
+ <span x-show="!w.last_status" style="color:var(--text-muted)">never fired</span>
769
+ </td>
770
+ <td style="padding:6px 12px;display:flex;gap:4px">
771
+ <button @click="testWebhook(w)" class="fz-btn" style="font-size:10px;padding:1px 6px">Test</button>
772
+ <button @click="confirmDeleteWebhook(w)" class="fz-btn fz-btn-danger" style="font-size:10px;padding:1px 6px">Del</button>
773
+ </td>
774
+ </tr>
775
+ </template>
776
+ <template x-if="webhooks.length === 0">
777
+ <tr><td colspan="5" style="padding:16px;text-align:center;color:var(--text-muted);font-size:12px">No webhooks. Add one below.</td></tr>
778
+ </template>
779
+ </tbody>
780
+ </table>
781
+ </div>
782
+ <div style="border-top:1px solid var(--border);padding:12px 16px;background:var(--bg-tertiary)">
783
+ <div style="font-size:12px;font-weight:600;margin-bottom:8px;color:var(--text-primary)">Add Webhook</div>
784
+ <form @submit.prevent="createWebhook()" style="display:grid;grid-template-columns:1fr 2fr 1fr;gap:6px;align-items:end">
785
+ <div><label style="font-size:10px;color:var(--text-secondary);font-weight:500">Name</label><input type="text" x-model="newWebhook.name" required placeholder="Slack" style="width:100%;font-size:12px;padding:5px 8px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary)"></div>
786
+ <div><label style="font-size:10px;color:var(--text-secondary);font-weight:500">URL</label><input type="url" x-model="newWebhook.url" required placeholder="https://hooks.slack.com/..." style="width:100%;font-size:12px;padding:5px 8px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary)"></div>
787
+ <div><label style="font-size:10px;color:var(--text-secondary);font-weight:500">Events</label><input type="text" x-model="newWebhook.events" placeholder="* (or ssh_connect,user_login)" style="width:100%;font-size:12px;padding:5px 8px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary)"></div>
788
+ <div style="grid-column:span 2"><label style="font-size:10px;color:var(--text-secondary);font-weight:500">Secret (optional, for HMAC signing)</label><input type="text" x-model="newWebhook.secret" placeholder="leave empty to skip" style="width:100%;font-size:12px;padding:5px 8px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary)"></div>
789
+ <button type="submit" class="fz-btn fz-btn-primary" style="font-size:12px">Add</button>
790
+ </form>
791
+ </div>
792
+ <div class="fz-modal-footer">
793
+ <button @click="showWebhooksModal=false" class="fz-btn">Close</button>
794
+ </div>
795
+ </div>
796
+ </div>
797
+
744
798
  <!-- ======== EDIT GROUPS MODAL ======== -->
745
799
  <div x-show="showGroupsModal" class="fz-modal-bg" @click.self="showGroupsModal=false">
746
800
  <div class="fz-modal" style="width:400px" @click.stop>
@@ -846,6 +900,7 @@ function app() {
846
900
  editingUserObj: null, editingGroupsStr: '',
847
901
  showAuditModal: false, auditEntries: [],
848
902
  showApiKeysModal: false, apiKeys: [], newApiKeyName: '', createdApiKey: null,
903
+ showWebhooksModal: false, webhooks: [], newWebhook: { name: '', url: '', events: '*', secret: '' },
849
904
  view: 'dashboard',
850
905
  ssh: { host: '', port: 22, username: '', password: '' },
851
906
  servers: [], groups: [], selectedGroup: '', searchQuery: '',
@@ -1429,6 +1484,29 @@ function app() {
1429
1484
  if (!confirm(`Revoke API key "${k.name}"?`)) return;
1430
1485
  try { await this.apiCall(`/api/auth/api-keys/${k.id}`, { method: 'DELETE' }); this.showToast('API key revoked'); this.apiKeys = await this.apiCall('/api/auth/api-keys'); } catch (e) { this.showToast(e.message, 'error'); }
1431
1486
  },
1487
+
1488
+ // Webhooks
1489
+ async openWebhooksModal() {
1490
+ try { this.webhooks = await this.apiCall('/api/webhooks'); } catch (e) { this.showToast(e.message, 'error'); return; }
1491
+ this.showWebhooksModal = true;
1492
+ },
1493
+ async createWebhook() {
1494
+ const events = (this.newWebhook.events || '*').split(',').map(e => e.trim()).filter(Boolean);
1495
+ const body = { name: this.newWebhook.name.trim(), url: this.newWebhook.url.trim(), events, secret: this.newWebhook.secret || '' };
1496
+ try {
1497
+ await this.apiCall('/api/webhooks', { method: 'POST', body: JSON.stringify(body) });
1498
+ this.newWebhook = { name: '', url: '', events: '*', secret: '' };
1499
+ this.webhooks = await this.apiCall('/api/webhooks');
1500
+ this.showToast('Webhook added');
1501
+ } catch (e) { this.showToast(e.message, 'error'); }
1502
+ },
1503
+ async confirmDeleteWebhook(w) {
1504
+ if (!confirm(`Delete webhook "${w.name}"?`)) return;
1505
+ try { await this.apiCall(`/api/webhooks/${w.id}`, { method: 'DELETE' }); this.webhooks = await this.apiCall('/api/webhooks'); this.showToast('Webhook deleted'); } catch (e) { this.showToast(e.message, 'error'); }
1506
+ },
1507
+ async testWebhook(w) {
1508
+ 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
+ },
1432
1510
  };
1433
1511
  }
1434
1512
  </script>
@@ -13,6 +13,7 @@ from webgate.servers.service import (
13
13
  update_last_connected,
14
14
  )
15
15
  from webgate.terminal.ws_handler import authenticate_websocket, handle_terminal_ws
16
+ from webgate.webhooks.dispatcher import fire as fire_webhook
16
17
 
17
18
  router = APIRouter(tags=["terminal"])
18
19
 
@@ -107,6 +108,10 @@ async def ws_terminal_server(ws: WebSocket, server_id: int) -> None:
107
108
 
108
109
  await update_last_connected(session, server)
109
110
  await log_action(user_out.id, user_out.username, "ssh_connect", f"{server.hostname}:{server.port}")
111
+ await fire_webhook("ssh_connect", {
112
+ "user": user_out.username, "server_id": server.id, "server": server.name,
113
+ "host": f"{server.hostname}:{server.port}", "via_jump": server.jump_via_id is not None,
114
+ })
110
115
 
111
116
  await handle_terminal_ws(
112
117
  ws,
@@ -0,0 +1,74 @@
1
+ """Fire-and-forget webhook dispatcher.
2
+
3
+ Webhooks are sent asynchronously without blocking the request that triggered
4
+ them. Failures are logged and stored on the webhook row (last_status) but
5
+ never propagate back to the caller.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import hashlib
12
+ import hmac
13
+ import json
14
+ import logging
15
+ from datetime import UTC, datetime
16
+ from typing import Any
17
+
18
+ import httpx
19
+ from sqlalchemy import select
20
+
21
+ from webgate.db.engine import async_session_factory
22
+ from webgate.webhooks.models import Webhook
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ _TIMEOUT = 5.0 # seconds
27
+
28
+
29
+ def _sign(secret: str, body: bytes) -> str:
30
+ return hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
31
+
32
+
33
+ async def _deliver(webhook_id: int, url: str, secret: str, payload: dict[str, Any]) -> None:
34
+ body = json.dumps(payload).encode()
35
+ headers = {"Content-Type": "application/json", "User-Agent": "webgate-webhook/1.0"}
36
+ if secret:
37
+ headers["X-Webgate-Signature"] = "sha256=" + _sign(secret, body)
38
+ status: int | None = None
39
+ try:
40
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
41
+ resp = await client.post(url, content=body, headers=headers)
42
+ status = resp.status_code
43
+ except Exception as e:
44
+ logger.warning("Webhook %s -> %s failed: %s", webhook_id, url, e)
45
+ finally:
46
+ async with async_session_factory() as session:
47
+ wh = (
48
+ await session.execute(select(Webhook).where(Webhook.id == webhook_id))
49
+ ).scalar_one_or_none()
50
+ if wh is not None:
51
+ wh.last_fired_at = datetime.now(UTC)
52
+ wh.last_status = status
53
+ await session.commit()
54
+
55
+
56
+ async def fire(event: str, data: dict[str, Any]) -> None:
57
+ """Fire-and-forget: schedule webhook deliveries for this event."""
58
+ payload = {
59
+ "event": event,
60
+ "timestamp": datetime.now(UTC).isoformat(),
61
+ "data": data,
62
+ }
63
+ async with async_session_factory() as session:
64
+ result = await session.execute(select(Webhook).where(Webhook.enabled.is_(True)))
65
+ hooks = list(result.scalars().all())
66
+ for wh in hooks:
67
+ try:
68
+ events = json.loads(wh.events)
69
+ except json.JSONDecodeError:
70
+ events = ["*"]
71
+ if "*" not in events and event not in events:
72
+ continue
73
+ # Detached background task; never block caller.
74
+ asyncio.create_task(_deliver(wh.id, wh.url, wh.secret, payload))
@@ -0,0 +1,61 @@
1
+ from datetime import datetime
2
+
3
+ from pydantic import BaseModel, Field
4
+ from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
5
+ from sqlalchemy.orm import Mapped, mapped_column
6
+
7
+ from webgate.db.engine import Base
8
+
9
+ # Known event names. Stored in `events` as a JSON array of strings; "*" means all.
10
+ EVENT_NAMES = [
11
+ "user_login",
12
+ "user_login_failed",
13
+ "ssh_connect",
14
+ "sftp_upload",
15
+ "sftp_delete",
16
+ "server_added",
17
+ "server_deleted",
18
+ ]
19
+
20
+
21
+ class Webhook(Base):
22
+ __tablename__ = "webhooks"
23
+
24
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
25
+ name: Mapped[str] = mapped_column(String(255))
26
+ url: Mapped[str] = mapped_column(Text)
27
+ events: Mapped[str] = mapped_column(Text, default='["*"]') # JSON array
28
+ enabled: Mapped[bool] = mapped_column(Boolean, default=True)
29
+ secret: Mapped[str] = mapped_column(String(255), default="") # HMAC signing key
30
+ last_fired_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
31
+ last_status: Mapped[int | None] = mapped_column(Integer, nullable=True)
32
+ user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"))
33
+ created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
34
+
35
+
36
+ class WebhookCreate(BaseModel):
37
+ name: str
38
+ url: str
39
+ events: list[str] = Field(default_factory=lambda: ["*"])
40
+ enabled: bool = True
41
+ secret: str = ""
42
+
43
+
44
+ class WebhookUpdate(BaseModel):
45
+ name: str | None = None
46
+ url: str | None = None
47
+ events: list[str] | None = None
48
+ enabled: bool | None = None
49
+ secret: str | None = None
50
+
51
+
52
+ class WebhookOut(BaseModel):
53
+ id: int
54
+ name: str
55
+ url: str
56
+ events: list[str]
57
+ enabled: bool
58
+ has_secret: bool
59
+ last_fired_at: datetime | None
60
+ last_status: int | None
61
+ created_at: datetime
@@ -0,0 +1,129 @@
1
+ import json
2
+ from typing import Annotated
3
+
4
+ from fastapi import APIRouter, Depends, HTTPException, status
5
+ from sqlalchemy import select
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+
8
+ from webgate.auth.models import UserOut
9
+ from webgate.auth.routes import get_current_user
10
+ from webgate.db.engine import get_session
11
+ from webgate.webhooks.dispatcher import fire
12
+ from webgate.webhooks.models import (
13
+ EVENT_NAMES,
14
+ Webhook,
15
+ WebhookCreate,
16
+ WebhookOut,
17
+ WebhookUpdate,
18
+ )
19
+
20
+ router = APIRouter(prefix="/api/webhooks", tags=["webhooks"])
21
+
22
+ SessionDep = Annotated[AsyncSession, Depends(get_session)]
23
+ CurrentUserDep = Annotated[UserOut, Depends(get_current_user)]
24
+
25
+
26
+ def _to_out(wh: Webhook) -> WebhookOut:
27
+ try:
28
+ events = json.loads(wh.events)
29
+ except json.JSONDecodeError:
30
+ events = ["*"]
31
+ return WebhookOut(
32
+ id=wh.id,
33
+ name=wh.name,
34
+ url=wh.url,
35
+ events=events,
36
+ enabled=wh.enabled,
37
+ has_secret=bool(wh.secret),
38
+ last_fired_at=wh.last_fired_at,
39
+ last_status=wh.last_status,
40
+ created_at=wh.created_at,
41
+ )
42
+
43
+
44
+ def _require_admin(user: UserOut) -> None:
45
+ if not user.is_admin:
46
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin only")
47
+
48
+
49
+ @router.get("", response_model=list[WebhookOut])
50
+ async def list_webhooks(session: SessionDep, user: CurrentUserDep) -> list[WebhookOut]:
51
+ _require_admin(user)
52
+ result = await session.execute(select(Webhook).order_by(Webhook.name))
53
+ return [_to_out(w) for w in result.scalars().all()]
54
+
55
+
56
+ @router.get("/events", response_model=list[str])
57
+ async def list_events(user: CurrentUserDep) -> list[str]:
58
+ _require_admin(user)
59
+ return EVENT_NAMES
60
+
61
+
62
+ @router.post("", response_model=WebhookOut, status_code=status.HTTP_201_CREATED)
63
+ async def create_webhook(
64
+ body: WebhookCreate, session: SessionDep, user: CurrentUserDep
65
+ ) -> WebhookOut:
66
+ _require_admin(user)
67
+ wh = Webhook(
68
+ name=body.name,
69
+ url=body.url,
70
+ events=json.dumps(body.events or ["*"]),
71
+ enabled=body.enabled,
72
+ secret=body.secret,
73
+ user_id=user.id,
74
+ )
75
+ session.add(wh)
76
+ await session.commit()
77
+ await session.refresh(wh)
78
+ return _to_out(wh)
79
+
80
+
81
+ @router.put("/{webhook_id}", response_model=WebhookOut)
82
+ async def update_webhook(
83
+ webhook_id: int, body: WebhookUpdate, session: SessionDep, user: CurrentUserDep
84
+ ) -> WebhookOut:
85
+ _require_admin(user)
86
+ wh = (
87
+ await session.execute(select(Webhook).where(Webhook.id == webhook_id))
88
+ ).scalar_one_or_none()
89
+ if wh is None:
90
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Webhook not found")
91
+ if body.name is not None:
92
+ wh.name = body.name
93
+ if body.url is not None:
94
+ wh.url = body.url
95
+ if body.events is not None:
96
+ wh.events = json.dumps(body.events)
97
+ if body.enabled is not None:
98
+ wh.enabled = body.enabled
99
+ if body.secret is not None:
100
+ wh.secret = body.secret
101
+ await session.commit()
102
+ await session.refresh(wh)
103
+ return _to_out(wh)
104
+
105
+
106
+ @router.delete("/{webhook_id}", status_code=status.HTTP_204_NO_CONTENT)
107
+ async def delete_webhook(webhook_id: int, session: SessionDep, user: CurrentUserDep) -> None:
108
+ _require_admin(user)
109
+ wh = (
110
+ await session.execute(select(Webhook).where(Webhook.id == webhook_id))
111
+ ).scalar_one_or_none()
112
+ if wh is None:
113
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Webhook not found")
114
+ await session.delete(wh)
115
+ await session.commit()
116
+
117
+
118
+ @router.post("/{webhook_id}/test")
119
+ async def test_webhook(
120
+ webhook_id: int, session: SessionDep, user: CurrentUserDep
121
+ ) -> dict[str, object]:
122
+ _require_admin(user)
123
+ wh = (
124
+ await session.execute(select(Webhook).where(Webhook.id == webhook_id))
125
+ ).scalar_one_or_none()
126
+ if wh is None:
127
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Webhook not found")
128
+ await fire("test", {"webhook_id": wh.id, "name": wh.name, "triggered_by": user.username})
129
+ return {"queued": True}
File without changes
@@ -1845,6 +1845,7 @@ dependencies = [
1845
1845
  { name = "bcrypt" },
1846
1846
  { name = "cryptography" },
1847
1847
  { name = "fastapi" },
1848
+ { name = "httpx" },
1848
1849
  { name = "passlib", extra = ["bcrypt"] },
1849
1850
  { name = "pydantic" },
1850
1851
  { name = "pydantic-settings" },
@@ -1880,6 +1881,7 @@ requires-dist = [
1880
1881
  { name = "bcrypt", specifier = ">=4.2.0" },
1881
1882
  { name = "cryptography", specifier = ">=44.0.0" },
1882
1883
  { name = "fastapi", specifier = ">=0.115.0" },
1884
+ { name = "httpx", specifier = ">=0.28.0" },
1883
1885
  { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" },
1884
1886
  { name = "pydantic", specifier = ">=2.10.0" },
1885
1887
  { name = "pydantic-settings", specifier = ">=2.7.0" },
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