webgate 0.3.0__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.0 → webgate-0.3.2}/CHANGELOG.md +31 -0
  2. {webgate-0.3.0 → webgate-0.3.2}/PKG-INFO +19 -5
  3. {webgate-0.3.0 → webgate-0.3.2}/README.md +16 -2
  4. {webgate-0.3.0 → webgate-0.3.2}/pyproject.toml +3 -4
  5. {webgate-0.3.0 → webgate-0.3.2}/src/webgate/app.py +2 -0
  6. {webgate-0.3.0 → webgate-0.3.2}/src/webgate/auth/routes.py +10 -0
  7. {webgate-0.3.0 → webgate-0.3.2}/src/webgate/db/engine.py +11 -7
  8. {webgate-0.3.0 → webgate-0.3.2}/src/webgate/files/routes.py +8 -0
  9. {webgate-0.3.0 → webgate-0.3.2}/src/webgate/servers/routes.py +7 -0
  10. {webgate-0.3.0 → webgate-0.3.2}/src/webgate/static/index.html +78 -0
  11. {webgate-0.3.0 → webgate-0.3.2}/src/webgate/terminal/routes.py +5 -0
  12. webgate-0.3.2/src/webgate/webhooks/dispatcher.py +74 -0
  13. webgate-0.3.2/src/webgate/webhooks/models.py +61 -0
  14. webgate-0.3.2/src/webgate/webhooks/routes.py +129 -0
  15. webgate-0.3.2/tests/__init__.py +0 -0
  16. {webgate-0.3.0 → webgate-0.3.2}/uv.lock +53 -1
  17. {webgate-0.3.0 → webgate-0.3.2}/.dockerignore +0 -0
  18. {webgate-0.3.0 → webgate-0.3.2}/.github/workflows/docs.yml +0 -0
  19. {webgate-0.3.0 → webgate-0.3.2}/.gitignore +0 -0
  20. {webgate-0.3.0 → webgate-0.3.2}/Dockerfile +0 -0
  21. {webgate-0.3.0 → webgate-0.3.2}/Dockerfile.demo +0 -0
  22. {webgate-0.3.0 → webgate-0.3.2}/Dockerfile.ssh-demo +0 -0
  23. {webgate-0.3.0 → webgate-0.3.2}/LICENSE +0 -0
  24. {webgate-0.3.0 → webgate-0.3.2}/ROADMAP.md +0 -0
  25. {webgate-0.3.0 → webgate-0.3.2}/VERSION +0 -0
  26. {webgate-0.3.0 → webgate-0.3.2}/compose.dev.yml +0 -0
  27. {webgate-0.3.0 → webgate-0.3.2}/compose.yml +0 -0
  28. {webgate-0.3.0 → webgate-0.3.2}/docs/api/auth.md +0 -0
  29. {webgate-0.3.0 → webgate-0.3.2}/docs/api/files.md +0 -0
  30. {webgate-0.3.0 → webgate-0.3.2}/docs/api/servers.md +0 -0
  31. {webgate-0.3.0 → webgate-0.3.2}/docs/api/terminal.md +0 -0
  32. {webgate-0.3.0 → webgate-0.3.2}/docs/changelog.md +0 -0
  33. {webgate-0.3.0 → webgate-0.3.2}/docs/getting-started/installation.md +0 -0
  34. {webgate-0.3.0 → webgate-0.3.2}/docs/getting-started/quickstart.md +0 -0
  35. {webgate-0.3.0 → webgate-0.3.2}/docs/guide/files.md +0 -0
  36. {webgate-0.3.0 → webgate-0.3.2}/docs/guide/servers.md +0 -0
  37. {webgate-0.3.0 → webgate-0.3.2}/docs/guide/split.md +0 -0
  38. {webgate-0.3.0 → webgate-0.3.2}/docs/guide/terminal.md +0 -0
  39. {webgate-0.3.0 → webgate-0.3.2}/docs/guide/users.md +0 -0
  40. {webgate-0.3.0 → webgate-0.3.2}/docs/index.md +0 -0
  41. {webgate-0.3.0 → webgate-0.3.2}/docs/screenshots/access-control.png +0 -0
  42. {webgate-0.3.0 → webgate-0.3.2}/docs/screenshots/audit.png +0 -0
  43. {webgate-0.3.0 → webgate-0.3.2}/docs/screenshots/edit-access-control.png +0 -0
  44. {webgate-0.3.0 → webgate-0.3.2}/docs/screenshots/editor.png +0 -0
  45. {webgate-0.3.0 → webgate-0.3.2}/docs/screenshots/light-theme.png +0 -0
  46. {webgate-0.3.0 → webgate-0.3.2}/docs/screenshots/login.png +0 -0
  47. {webgate-0.3.0 → webgate-0.3.2}/docs/screenshots/new-server-form.png +0 -0
  48. {webgate-0.3.0 → webgate-0.3.2}/docs/screenshots/sftp-restricted.png +0 -0
  49. {webgate-0.3.0 → webgate-0.3.2}/docs/screenshots/sftp.png +0 -0
  50. {webgate-0.3.0 → webgate-0.3.2}/docs/screenshots/site-manager.png +0 -0
  51. {webgate-0.3.0 → webgate-0.3.2}/docs/screenshots/split-view.png +0 -0
  52. {webgate-0.3.0 → webgate-0.3.2}/docs/screenshots/ssh-disabled.png +0 -0
  53. {webgate-0.3.0 → webgate-0.3.2}/docs/screenshots/terminal.png +0 -0
  54. {webgate-0.3.0 → webgate-0.3.2}/docs/screenshots/users.png +0 -0
  55. {webgate-0.3.0 → webgate-0.3.2}/fly.toml +0 -0
  56. {webgate-0.3.0 → webgate-0.3.2}/mkdocs.yml +0 -0
  57. {webgate-0.3.0 → webgate-0.3.2}/src/webgate/__init__.py +0 -0
  58. {webgate-0.3.0 → webgate-0.3.2}/src/webgate/__main__.py +0 -0
  59. {webgate-0.3.0 → webgate-0.3.2}/src/webgate/audit/__init__.py +0 -0
  60. {webgate-0.3.0 → webgate-0.3.2}/src/webgate/audit/models.py +0 -0
  61. {webgate-0.3.0 → webgate-0.3.2}/src/webgate/audit/service.py +0 -0
  62. {webgate-0.3.0 → webgate-0.3.2}/src/webgate/auth/__init__.py +0 -0
  63. {webgate-0.3.0 → webgate-0.3.2}/src/webgate/auth/models.py +0 -0
  64. {webgate-0.3.0 → webgate-0.3.2}/src/webgate/auth/service.py +0 -0
  65. {webgate-0.3.0 → webgate-0.3.2}/src/webgate/config.py +0 -0
  66. {webgate-0.3.0 → webgate-0.3.2}/src/webgate/db/__init__.py +0 -0
  67. {webgate-0.3.0 → webgate-0.3.2}/src/webgate/demo.py +0 -0
  68. {webgate-0.3.0 → webgate-0.3.2}/src/webgate/files/__init__.py +0 -0
  69. {webgate-0.3.0 → webgate-0.3.2}/src/webgate/files/models.py +0 -0
  70. {webgate-0.3.0 → webgate-0.3.2}/src/webgate/files/pool.py +0 -0
  71. {webgate-0.3.0 → webgate-0.3.2}/src/webgate/files/sftp_service.py +0 -0
  72. {webgate-0.3.0 → webgate-0.3.2}/src/webgate/servers/__init__.py +0 -0
  73. {webgate-0.3.0 → webgate-0.3.2}/src/webgate/servers/crypto.py +0 -0
  74. {webgate-0.3.0 → webgate-0.3.2}/src/webgate/servers/models.py +0 -0
  75. {webgate-0.3.0 → webgate-0.3.2}/src/webgate/servers/monitor.py +0 -0
  76. {webgate-0.3.0 → webgate-0.3.2}/src/webgate/servers/service.py +0 -0
  77. {webgate-0.3.0 → webgate-0.3.2}/src/webgate/snippets/__init__.py +0 -0
  78. {webgate-0.3.0 → webgate-0.3.2}/src/webgate/snippets/models.py +0 -0
  79. {webgate-0.3.0 → webgate-0.3.2}/src/webgate/snippets/routes.py +0 -0
  80. {webgate-0.3.0 → webgate-0.3.2}/src/webgate/terminal/__init__.py +0 -0
  81. {webgate-0.3.0 → webgate-0.3.2}/src/webgate/terminal/ssh_session.py +0 -0
  82. {webgate-0.3.0 → webgate-0.3.2}/src/webgate/terminal/ws_handler.py +0 -0
  83. {webgate-0.3.0/tests → webgate-0.3.2/src/webgate/webhooks}/__init__.py +0 -0
  84. {webgate-0.3.0 → webgate-0.3.2}/tests/conftest.py +0 -0
  85. {webgate-0.3.0 → webgate-0.3.2}/tests/test_auth.py +0 -0
  86. {webgate-0.3.0 → webgate-0.3.2}/tests/test_files.py +0 -0
  87. {webgate-0.3.0 → webgate-0.3.2}/tests/test_servers.py +0 -0
  88. {webgate-0.3.0 → webgate-0.3.2}/tests/test_terminal.py +0 -0
@@ -1,5 +1,36 @@
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
+
22
+ ## v0.3.1 (2026-04-15)
23
+
24
+ ### Fixed
25
+
26
+ - **PostgreSQL actually works in Docker now.** v0.3.0 advertised PostgreSQL support but two bugs prevented it from working:
27
+ 1. `asyncpg` was an optional extra (`webgate[postgres]`), so the official Docker image (and `pip install webgate`) didn't have the driver. Now bundled by default.
28
+ 2. Lightweight migrations ran inside the same transaction as `create_all`. PostgreSQL aborts the entire transaction on any error (even when caught), so the first "column already exists" error rolled back table creation on subsequent runs. Each migration now uses its own transaction.
29
+
30
+ Verified end-to-end against a real `postgres:16-alpine` container: tables created, login works, server creation and persistence across restarts confirmed.
31
+
32
+ ---
33
+
3
34
  ## v0.3.0 (2026-04-15)
4
35
 
5
36
  ### Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webgate
3
- Version: 0.3.0
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/
@@ -27,10 +27,12 @@ Classifier: Typing :: Typed
27
27
  Requires-Python: >=3.11
28
28
  Requires-Dist: aiofiles>=24.1.0
29
29
  Requires-Dist: aiosqlite>=0.21.0
30
+ Requires-Dist: asyncpg>=0.30.0
30
31
  Requires-Dist: asyncssh>=2.18.0
31
32
  Requires-Dist: bcrypt>=4.2.0
32
33
  Requires-Dist: cryptography>=44.0.0
33
34
  Requires-Dist: fastapi>=0.115.0
35
+ Requires-Dist: httpx>=0.28.0
34
36
  Requires-Dist: passlib[bcrypt]>=1.7.4
35
37
  Requires-Dist: pydantic-settings>=2.7.0
36
38
  Requires-Dist: pydantic>=2.10.0
@@ -42,8 +44,6 @@ Requires-Dist: slowapi>=0.1.9
42
44
  Requires-Dist: sqlalchemy>=2.0.36
43
45
  Requires-Dist: uvicorn[standard]>=0.34.0
44
46
  Requires-Dist: websockets>=14.0
45
- Provides-Extra: postgres
46
- Requires-Dist: asyncpg>=0.30.0; extra == 'postgres'
47
47
  Description-Content-Type: text/markdown
48
48
 
49
49
  # webgate
@@ -457,10 +457,9 @@ All settings are configurable via environment variables with the `WEBGATE_` pref
457
457
 
458
458
  ### PostgreSQL
459
459
 
460
- SQLite is the default. To use PostgreSQL instead:
460
+ SQLite is the default. To use PostgreSQL, just point `WEBGATE_DB_URL` at it (the `asyncpg` driver is bundled as of v0.3.1):
461
461
 
462
462
  ```bash
463
- pip install 'webgate[postgres]' # adds asyncpg
464
463
  export WEBGATE_DB_URL='postgresql+asyncpg://user:pass@host:5432/webgate'
465
464
  ```
466
465
 
@@ -473,6 +472,21 @@ When adding a server, pick another server from the **Jump Via** dropdown to tunn
473
472
  ### SSH command snippets
474
473
 
475
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.
476
490
  | `WEBGATE_LOG_LEVEL` | `info` | Log level |
477
491
  | `WEBGATE_SESSION_TIMEOUT` | `3600` | SSH session timeout (seconds) |
478
492
  | `WEBGATE_MAX_UPLOAD_SIZE` | `104857600` | Max upload size (100 MB) |
@@ -409,10 +409,9 @@ All settings are configurable via environment variables with the `WEBGATE_` pref
409
409
 
410
410
  ### PostgreSQL
411
411
 
412
- SQLite is the default. To use PostgreSQL instead:
412
+ SQLite is the default. To use PostgreSQL, just point `WEBGATE_DB_URL` at it (the `asyncpg` driver is bundled as of v0.3.1):
413
413
 
414
414
  ```bash
415
- pip install 'webgate[postgres]' # adds asyncpg
416
415
  export WEBGATE_DB_URL='postgresql+asyncpg://user:pass@host:5432/webgate'
417
416
  ```
418
417
 
@@ -425,6 +424,21 @@ When adding a server, pick another server from the **Jump Via** dropdown to tunn
425
424
  ### SSH command snippets
426
425
 
427
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.
428
442
  | `WEBGATE_LOG_LEVEL` | `info` | Log level |
429
443
  | `WEBGATE_SESSION_TIMEOUT` | `3600` | SSH session timeout (seconds) |
430
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.0"
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"
@@ -40,11 +40,10 @@ dependencies = [
40
40
  "slowapi>=0.1.9",
41
41
  "pyotp>=2.9.0",
42
42
  "qrcode[pil]>=8.2",
43
+ "asyncpg>=0.30.0",
44
+ "httpx>=0.28.0",
43
45
  ]
44
46
 
45
- [project.optional-dependencies]
46
- postgres = ["asyncpg>=0.30.0"]
47
-
48
47
  [project.urls]
49
48
  Homepage = "https://github.com/kalexnolasco/webgate"
50
49
  Documentation = "https://kalexnolasco.github.io/webgate/"
@@ -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
 
@@ -48,15 +48,19 @@ async def init_db() -> None:
48
48
  dialect = engine.dialect.name # "sqlite", "postgresql", ...
49
49
  async with engine.begin() as conn:
50
50
  await conn.run_sync(Base.metadata.create_all)
51
- # Lightweight migrations for columns added after initial release
52
- for table, column, sqlite_def, pg_def in _MIGRATIONS:
53
- col_def = pg_def if dialect == "postgresql" else sqlite_def
54
- sql = f"ALTER TABLE {table} ADD COLUMN {column} {col_def}"
55
- try:
51
+ # Lightweight migrations: each in its OWN transaction. PostgreSQL aborts
52
+ # the whole transaction on any error (even one we catch), so running
53
+ # multiple ALTERs in a single txn would poison create_all on a fresh DB
54
+ # the moment the first "column already exists" fires.
55
+ for table, column, sqlite_def, pg_def in _MIGRATIONS:
56
+ col_def = pg_def if dialect == "postgresql" else sqlite_def
57
+ sql = f"ALTER TABLE {table} ADD COLUMN {column} {col_def}"
58
+ try:
59
+ async with engine.begin() as conn:
56
60
  await conn.execute(text(sql))
57
61
  logger.info("Migration applied: %s.%s", table, column)
58
- except Exception:
59
- pass # Column already exists
62
+ except Exception:
63
+ pass # Column already exists, or table not yet present on fresh DB
60
64
 
61
65
 
62
66
  async def close_db() -> None:
@@ -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
@@ -51,6 +51,54 @@ wheels = [
51
51
  { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
52
52
  ]
53
53
 
54
+ [[package]]
55
+ name = "asyncpg"
56
+ version = "0.31.0"
57
+ source = { registry = "https://pypi.org/simple" }
58
+ sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" }
59
+ wheels = [
60
+ { url = "https://files.pythonhosted.org/packages/08/17/cc02bc49bc350623d050fa139e34ea512cd6e020562f2a7312a7bcae4bc9/asyncpg-0.31.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eee690960e8ab85063ba93af2ce128c0f52fd655fdff9fdb1a28df01329f031d", size = 643159, upload-time = "2025-11-24T23:25:36.443Z" },
61
+ { url = "https://files.pythonhosted.org/packages/a4/62/4ded7d400a7b651adf06f49ea8f73100cca07c6df012119594d1e3447aa6/asyncpg-0.31.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2657204552b75f8288de08ca60faf4a99a65deef3a71d1467454123205a88fab", size = 638157, upload-time = "2025-11-24T23:25:37.89Z" },
62
+ { url = "https://files.pythonhosted.org/packages/d6/5b/4179538a9a72166a0bf60ad783b1ef16efb7960e4d7b9afe9f77a5551680/asyncpg-0.31.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a429e842a3a4b4ea240ea52d7fe3f82d5149853249306f7ff166cb9948faa46c", size = 2918051, upload-time = "2025-11-24T23:25:39.461Z" },
63
+ { url = "https://files.pythonhosted.org/packages/e6/35/c27719ae0536c5b6e61e4701391ffe435ef59539e9360959240d6e47c8c8/asyncpg-0.31.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0807be46c32c963ae40d329b3a686356e417f674c976c07fa49f1b30303f109", size = 2972640, upload-time = "2025-11-24T23:25:41.512Z" },
64
+ { url = "https://files.pythonhosted.org/packages/43/f4/01ebb9207f29e645a64699b9ce0eefeff8e7a33494e1d29bb53736f7766b/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e5d5098f63beeae93512ee513d4c0c53dc12e9aa2b7a1af5a81cddf93fe4e4da", size = 2851050, upload-time = "2025-11-24T23:25:43.153Z" },
65
+ { url = "https://files.pythonhosted.org/packages/3e/f4/03ff1426acc87be0f4e8d40fa2bff5c3952bef0080062af9efc2212e3be8/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37fc6c00a814e18eef51833545d1891cac9aa69140598bb076b4cd29b3e010b9", size = 2962574, upload-time = "2025-11-24T23:25:44.942Z" },
66
+ { url = "https://files.pythonhosted.org/packages/c7/39/cc788dfca3d4060f9d93e67be396ceec458dfc429e26139059e58c2c244d/asyncpg-0.31.0-cp311-cp311-win32.whl", hash = "sha256:5a4af56edf82a701aece93190cc4e094d2df7d33f6e915c222fb09efbb5afc24", size = 521076, upload-time = "2025-11-24T23:25:46.486Z" },
67
+ { url = "https://files.pythonhosted.org/packages/28/fc/735af5384c029eb7f1ca60ccb8fa95521dbdaeef788edf4cecfc604c3cab/asyncpg-0.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:480c4befbdf079c14c9ca43c8c5e1fe8b6296c96f1f927158d4f1e750aacc047", size = 584980, upload-time = "2025-11-24T23:25:47.938Z" },
68
+ { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" },
69
+ { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" },
70
+ { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" },
71
+ { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" },
72
+ { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" },
73
+ { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" },
74
+ { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" },
75
+ { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" },
76
+ { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" },
77
+ { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" },
78
+ { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" },
79
+ { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" },
80
+ { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" },
81
+ { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" },
82
+ { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" },
83
+ { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" },
84
+ { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" },
85
+ { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" },
86
+ { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" },
87
+ { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" },
88
+ { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" },
89
+ { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" },
90
+ { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" },
91
+ { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" },
92
+ { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" },
93
+ { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" },
94
+ { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" },
95
+ { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" },
96
+ { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" },
97
+ { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" },
98
+ { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" },
99
+ { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
100
+ ]
101
+
54
102
  [[package]]
55
103
  name = "asyncssh"
56
104
  version = "2.22.0"
@@ -1787,15 +1835,17 @@ wheels = [
1787
1835
 
1788
1836
  [[package]]
1789
1837
  name = "webgate"
1790
- version = "0.2.0"
1838
+ version = "0.3.1"
1791
1839
  source = { editable = "." }
1792
1840
  dependencies = [
1793
1841
  { name = "aiofiles" },
1794
1842
  { name = "aiosqlite" },
1843
+ { name = "asyncpg" },
1795
1844
  { name = "asyncssh" },
1796
1845
  { name = "bcrypt" },
1797
1846
  { name = "cryptography" },
1798
1847
  { name = "fastapi" },
1848
+ { name = "httpx" },
1799
1849
  { name = "passlib", extra = ["bcrypt"] },
1800
1850
  { name = "pydantic" },
1801
1851
  { name = "pydantic-settings" },
@@ -1826,10 +1876,12 @@ dev = [
1826
1876
  requires-dist = [
1827
1877
  { name = "aiofiles", specifier = ">=24.1.0" },
1828
1878
  { name = "aiosqlite", specifier = ">=0.21.0" },
1879
+ { name = "asyncpg", specifier = ">=0.30.0" },
1829
1880
  { name = "asyncssh", specifier = ">=2.18.0" },
1830
1881
  { name = "bcrypt", specifier = ">=4.2.0" },
1831
1882
  { name = "cryptography", specifier = ">=44.0.0" },
1832
1883
  { name = "fastapi", specifier = ">=0.115.0" },
1884
+ { name = "httpx", specifier = ">=0.28.0" },
1833
1885
  { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" },
1834
1886
  { name = "pydantic", specifier = ">=2.10.0" },
1835
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