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.
- {webgate-0.3.1 → webgate-0.3.2}/CHANGELOG.md +19 -0
- {webgate-0.3.1 → webgate-0.3.2}/PKG-INFO +17 -1
- {webgate-0.3.1 → webgate-0.3.2}/README.md +15 -0
- {webgate-0.3.1 → webgate-0.3.2}/pyproject.toml +2 -1
- {webgate-0.3.1 → webgate-0.3.2}/src/webgate/app.py +2 -0
- {webgate-0.3.1 → webgate-0.3.2}/src/webgate/auth/routes.py +10 -0
- {webgate-0.3.1 → webgate-0.3.2}/src/webgate/files/routes.py +8 -0
- {webgate-0.3.1 → webgate-0.3.2}/src/webgate/servers/routes.py +7 -0
- {webgate-0.3.1 → webgate-0.3.2}/src/webgate/static/index.html +78 -0
- {webgate-0.3.1 → webgate-0.3.2}/src/webgate/terminal/routes.py +5 -0
- webgate-0.3.2/src/webgate/webhooks/dispatcher.py +74 -0
- webgate-0.3.2/src/webgate/webhooks/models.py +61 -0
- webgate-0.3.2/src/webgate/webhooks/routes.py +129 -0
- webgate-0.3.2/tests/__init__.py +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/uv.lock +2 -0
- {webgate-0.3.1 → webgate-0.3.2}/.dockerignore +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/.github/workflows/docs.yml +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/.gitignore +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/Dockerfile +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/Dockerfile.demo +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/Dockerfile.ssh-demo +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/LICENSE +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/ROADMAP.md +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/VERSION +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/compose.dev.yml +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/compose.yml +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/docs/api/auth.md +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/docs/api/files.md +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/docs/api/servers.md +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/docs/api/terminal.md +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/docs/changelog.md +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/docs/getting-started/installation.md +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/docs/getting-started/quickstart.md +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/docs/guide/files.md +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/docs/guide/servers.md +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/docs/guide/split.md +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/docs/guide/terminal.md +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/docs/guide/users.md +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/docs/index.md +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/docs/screenshots/access-control.png +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/docs/screenshots/audit.png +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/docs/screenshots/edit-access-control.png +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/docs/screenshots/editor.png +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/docs/screenshots/light-theme.png +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/docs/screenshots/login.png +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/docs/screenshots/new-server-form.png +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/docs/screenshots/sftp-restricted.png +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/docs/screenshots/sftp.png +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/docs/screenshots/site-manager.png +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/docs/screenshots/split-view.png +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/docs/screenshots/ssh-disabled.png +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/docs/screenshots/terminal.png +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/docs/screenshots/users.png +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/fly.toml +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/mkdocs.yml +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/src/webgate/__init__.py +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/src/webgate/__main__.py +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/src/webgate/audit/__init__.py +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/src/webgate/audit/models.py +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/src/webgate/audit/service.py +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/src/webgate/auth/__init__.py +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/src/webgate/auth/models.py +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/src/webgate/auth/service.py +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/src/webgate/config.py +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/src/webgate/db/__init__.py +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/src/webgate/db/engine.py +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/src/webgate/demo.py +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/src/webgate/files/__init__.py +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/src/webgate/files/models.py +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/src/webgate/files/pool.py +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/src/webgate/files/sftp_service.py +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/src/webgate/servers/__init__.py +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/src/webgate/servers/crypto.py +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/src/webgate/servers/models.py +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/src/webgate/servers/monitor.py +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/src/webgate/servers/service.py +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/src/webgate/snippets/__init__.py +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/src/webgate/snippets/models.py +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/src/webgate/snippets/routes.py +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/src/webgate/terminal/__init__.py +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/src/webgate/terminal/ssh_session.py +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/src/webgate/terminal/ws_handler.py +0 -0
- {webgate-0.3.1/tests → webgate-0.3.2/src/webgate/webhooks}/__init__.py +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/tests/conftest.py +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/tests/test_auth.py +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/tests/test_files.py +0 -0
- {webgate-0.3.1 → webgate-0.3.2}/tests/test_servers.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|