webgate 0.2.0__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {webgate-0.2.0 → webgate-0.2.1}/CHANGELOG.md +15 -0
- {webgate-0.2.0 → webgate-0.2.1}/PKG-INFO +106 -1
- {webgate-0.2.0 → webgate-0.2.1}/README.md +103 -0
- webgate-0.2.1/docs/screenshots/access-control.png +0 -0
- webgate-0.2.1/docs/screenshots/audit.png +0 -0
- webgate-0.2.1/docs/screenshots/editor.png +0 -0
- webgate-0.2.1/docs/screenshots/light-theme.png +0 -0
- webgate-0.2.1/docs/screenshots/sftp.png +0 -0
- webgate-0.2.1/docs/screenshots/site-manager.png +0 -0
- webgate-0.2.1/docs/screenshots/split-view.png +0 -0
- webgate-0.2.1/docs/screenshots/terminal.png +0 -0
- webgate-0.2.1/docs/screenshots/users.png +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/pyproject.toml +3 -1
- {webgate-0.2.0 → webgate-0.2.1}/src/webgate/app.py +6 -1
- {webgate-0.2.0 → webgate-0.2.1}/src/webgate/auth/models.py +59 -1
- {webgate-0.2.0 → webgate-0.2.1}/src/webgate/auth/routes.py +153 -6
- {webgate-0.2.0 → webgate-0.2.1}/src/webgate/auth/service.py +80 -1
- {webgate-0.2.0 → webgate-0.2.1}/src/webgate/config.py +1 -0
- {webgate-0.2.0 → webgate-0.2.1}/src/webgate/db/engine.py +2 -0
- {webgate-0.2.0 → webgate-0.2.1}/src/webgate/static/index.html +207 -12
- {webgate-0.2.0 → webgate-0.2.1}/src/webgate/terminal/ws_handler.py +11 -1
- {webgate-0.2.0 → webgate-0.2.1}/uv.lock +117 -0
- webgate-0.2.0/docs/screenshots/access-control.png +0 -0
- webgate-0.2.0/docs/screenshots/audit.png +0 -0
- webgate-0.2.0/docs/screenshots/editor.png +0 -0
- webgate-0.2.0/docs/screenshots/sftp.png +0 -0
- webgate-0.2.0/docs/screenshots/site-manager.png +0 -0
- webgate-0.2.0/docs/screenshots/split-view.png +0 -0
- webgate-0.2.0/docs/screenshots/terminal.png +0 -0
- webgate-0.2.0/docs/screenshots/users.png +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/.dockerignore +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/.github/workflows/docs.yml +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/.gitignore +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/Dockerfile +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/Dockerfile.ssh-demo +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/LICENSE +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/ROADMAP.md +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/VERSION +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/compose.dev.yml +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/compose.yml +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/docs/api/auth.md +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/docs/api/files.md +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/docs/api/servers.md +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/docs/api/terminal.md +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/docs/changelog.md +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/docs/getting-started/installation.md +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/docs/getting-started/quickstart.md +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/docs/guide/files.md +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/docs/guide/servers.md +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/docs/guide/split.md +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/docs/guide/terminal.md +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/docs/guide/users.md +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/docs/index.md +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/docs/screenshots/edit-access-control.png +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/docs/screenshots/login.png +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/docs/screenshots/new-server-form.png +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/docs/screenshots/sftp-restricted.png +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/docs/screenshots/ssh-disabled.png +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/mkdocs.yml +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/src/webgate/__init__.py +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/src/webgate/__main__.py +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/src/webgate/audit/__init__.py +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/src/webgate/audit/models.py +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/src/webgate/audit/service.py +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/src/webgate/auth/__init__.py +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/src/webgate/db/__init__.py +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/src/webgate/files/__init__.py +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/src/webgate/files/models.py +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/src/webgate/files/pool.py +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/src/webgate/files/routes.py +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/src/webgate/files/sftp_service.py +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/src/webgate/servers/__init__.py +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/src/webgate/servers/crypto.py +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/src/webgate/servers/models.py +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/src/webgate/servers/monitor.py +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/src/webgate/servers/routes.py +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/src/webgate/servers/service.py +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/src/webgate/terminal/__init__.py +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/src/webgate/terminal/routes.py +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/src/webgate/terminal/ssh_session.py +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/tests/__init__.py +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/tests/conftest.py +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/tests/test_auth.py +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/tests/test_files.py +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/tests/test_servers.py +0 -0
- {webgate-0.2.0 → webgate-0.2.1}/tests/test_terminal.py +0 -0
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.2.1 (2026-04-15)
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- **Reverse proxy sub-path support** -- webgate can now be served behind a reverse proxy at a URL prefix (e.g. `https://example.com/webgate/`). Previously the frontend used absolute `/api/...` paths that broke under any prefix.
|
|
8
|
+
|
|
9
|
+
### Details
|
|
10
|
+
|
|
11
|
+
- New config setting: `WEBGATE_ROOT_PATH` (default `""`), passed to FastAPI's `root_path` for correct OpenAPI URLs behind proxies
|
|
12
|
+
- Frontend derives the path prefix at runtime from `window.location.pathname` and prepends it to all REST calls and the terminal WebSocket URL
|
|
13
|
+
- README documents nginx, Apache, and Traefik reverse-proxy configurations for sub-path deployments
|
|
14
|
+
- The proxy must forward the prefix unchanged (do not strip it) -- webgate handles the prefix natively
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
3
18
|
## v0.2.0 (2026-04-09)
|
|
4
19
|
|
|
5
20
|
### Features
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: webgate
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Self-hosted web application for remote server management via SSH terminal and SFTP file browser
|
|
5
5
|
Project-URL: Homepage, https://github.com/kalexnolasco/webgate
|
|
6
6
|
Project-URL: Documentation, https://kalexnolasco.github.io/webgate/
|
|
@@ -34,8 +34,10 @@ Requires-Dist: fastapi>=0.115.0
|
|
|
34
34
|
Requires-Dist: passlib[bcrypt]>=1.7.4
|
|
35
35
|
Requires-Dist: pydantic-settings>=2.7.0
|
|
36
36
|
Requires-Dist: pydantic>=2.10.0
|
|
37
|
+
Requires-Dist: pyotp>=2.9.0
|
|
37
38
|
Requires-Dist: python-jose[cryptography]>=3.3.0
|
|
38
39
|
Requires-Dist: python-multipart>=0.0.18
|
|
40
|
+
Requires-Dist: qrcode[pil]>=8.2
|
|
39
41
|
Requires-Dist: slowapi>=0.1.9
|
|
40
42
|
Requires-Dist: sqlalchemy>=2.0.36
|
|
41
43
|
Requires-Dist: uvicorn[standard]>=0.34.0
|
|
@@ -233,6 +235,9 @@ flowchart TB
|
|
|
233
235
|
### Audit Log
|
|
234
236
|

|
|
235
237
|
|
|
238
|
+
### Light Theme
|
|
239
|
+

|
|
240
|
+
|
|
236
241
|
## Quick Start
|
|
237
242
|
|
|
238
243
|
### Docker (recommended)
|
|
@@ -443,6 +448,7 @@ All settings are configurable via environment variables with the `WEBGATE_` pref
|
|
|
443
448
|
| `WEBGATE_SECRET_KEY` | `change-me-in-production` | JWT signing + Fernet encryption key |
|
|
444
449
|
| `WEBGATE_DB_URL` | `sqlite+aiosqlite:///./webgate.db` | Database URL |
|
|
445
450
|
| `WEBGATE_ALLOWED_ORIGINS` | `*` | CORS origins (comma-separated) |
|
|
451
|
+
| `WEBGATE_ROOT_PATH` | `` (empty) | URL prefix when served behind a reverse proxy at a sub-path (e.g. `/webgate`) |
|
|
446
452
|
| `WEBGATE_LOG_LEVEL` | `info` | Log level |
|
|
447
453
|
| `WEBGATE_SESSION_TIMEOUT` | `3600` | SSH session timeout (seconds) |
|
|
448
454
|
| `WEBGATE_MAX_UPLOAD_SIZE` | `104857600` | Max upload size (100 MB) |
|
|
@@ -499,6 +505,105 @@ webgate.example.com {
|
|
|
499
505
|
}
|
|
500
506
|
```
|
|
501
507
|
|
|
508
|
+
### Reverse proxy at a sub-path (e.g. `https://example.com/webgate/`)
|
|
509
|
+
|
|
510
|
+
When webgate is exposed behind an existing site under a URL prefix, set `WEBGATE_ROOT_PATH` so FastAPI and the frontend both build URLs with the prefix. The frontend derives the prefix automatically from `window.location.pathname`, but `WEBGATE_ROOT_PATH` is still needed on the backend for OpenAPI URLs.
|
|
511
|
+
|
|
512
|
+
**Docker Compose:**
|
|
513
|
+
|
|
514
|
+
```yaml
|
|
515
|
+
services:
|
|
516
|
+
webgate:
|
|
517
|
+
image: kalexnolasco/webgate:latest
|
|
518
|
+
environment:
|
|
519
|
+
WEBGATE_SECRET_KEY: "${WEBGATE_SECRET_KEY}"
|
|
520
|
+
WEBGATE_ROOT_PATH: "/webgate"
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
> **Important:** the reverse proxy must **forward the prefix unchanged** (not strip it). Webgate receives requests as `/webgate/api/...` and handles them natively — do not rewrite them to `/api/...`.
|
|
524
|
+
|
|
525
|
+
#### nginx
|
|
526
|
+
|
|
527
|
+
```nginx
|
|
528
|
+
server {
|
|
529
|
+
listen 443 ssl http2;
|
|
530
|
+
server_name example.com;
|
|
531
|
+
|
|
532
|
+
ssl_certificate /etc/ssl/certs/example.com.crt;
|
|
533
|
+
ssl_certificate_key /etc/ssl/private/example.com.key;
|
|
534
|
+
|
|
535
|
+
# WebSocket (xterm.js terminal) -- must come before the generic location
|
|
536
|
+
location /webgate/api/ws/ {
|
|
537
|
+
proxy_pass http://127.0.0.1:8443;
|
|
538
|
+
proxy_http_version 1.1;
|
|
539
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
540
|
+
proxy_set_header Connection "upgrade";
|
|
541
|
+
proxy_set_header Host $host;
|
|
542
|
+
proxy_read_timeout 86400s; # keep long-lived SSH sessions alive
|
|
543
|
+
proxy_send_timeout 86400s;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
location /webgate/ {
|
|
547
|
+
proxy_pass http://127.0.0.1:8443;
|
|
548
|
+
proxy_set_header Host $host;
|
|
549
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
550
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
551
|
+
proxy_set_header X-Forwarded-Proto https;
|
|
552
|
+
proxy_set_header X-Forwarded-Prefix /webgate;
|
|
553
|
+
client_max_body_size 100m; # match WEBGATE_MAX_UPLOAD_SIZE
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
#### Apache 2.4
|
|
559
|
+
|
|
560
|
+
```apache
|
|
561
|
+
# Required modules: proxy proxy_http proxy_wstunnel headers rewrite ssl
|
|
562
|
+
# sudo a2enmod proxy proxy_http proxy_wstunnel headers rewrite
|
|
563
|
+
|
|
564
|
+
<VirtualHost *:443>
|
|
565
|
+
ServerName example.com
|
|
566
|
+
|
|
567
|
+
SSLEngine on
|
|
568
|
+
SSLCertificateFile /etc/ssl/certs/example.com.crt
|
|
569
|
+
SSLCertificateKeyFile /etc/ssl/private/example.com.key
|
|
570
|
+
|
|
571
|
+
ProxyPreserveHost On
|
|
572
|
+
RequestHeader set X-Forwarded-Proto "https"
|
|
573
|
+
RequestHeader set X-Forwarded-Prefix "/webgate"
|
|
574
|
+
|
|
575
|
+
# Redirect /webgate -> /webgate/ (trailing slash)
|
|
576
|
+
RewriteEngine On
|
|
577
|
+
RewriteRule ^/webgate$ /webgate/ [R=301,L]
|
|
578
|
+
|
|
579
|
+
# WebSocket (xterm.js terminal) -- MUST come before the HTTP ProxyPass
|
|
580
|
+
ProxyPass /webgate/api/ws/ ws://127.0.0.1:8443/webgate/api/ws/
|
|
581
|
+
ProxyPassReverse /webgate/api/ws/ ws://127.0.0.1:8443/webgate/api/ws/
|
|
582
|
+
|
|
583
|
+
# HTTP
|
|
584
|
+
ProxyPass /webgate/ http://127.0.0.1:8443/webgate/
|
|
585
|
+
ProxyPassReverse /webgate/ http://127.0.0.1:8443/webgate/
|
|
586
|
+
</VirtualHost>
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
#### Traefik (labels)
|
|
590
|
+
|
|
591
|
+
```yaml
|
|
592
|
+
services:
|
|
593
|
+
webgate:
|
|
594
|
+
image: kalexnolasco/webgate:latest
|
|
595
|
+
environment:
|
|
596
|
+
WEBGATE_ROOT_PATH: "/webgate"
|
|
597
|
+
labels:
|
|
598
|
+
- "traefik.enable=true"
|
|
599
|
+
- "traefik.http.routers.webgate.rule=Host(`example.com`) && PathPrefix(`/webgate`)"
|
|
600
|
+
- "traefik.http.routers.webgate.entrypoints=websecure"
|
|
601
|
+
- "traefik.http.routers.webgate.tls=true"
|
|
602
|
+
- "traefik.http.services.webgate.loadbalancer.server.port=8443"
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
Traefik forwards the full path by default, so no extra middleware is needed. Long WebSocket timeouts can be tuned via `forwardingTimeouts` in the Traefik static config.
|
|
606
|
+
|
|
502
607
|
### Useful Commands
|
|
503
608
|
|
|
504
609
|
```bash
|
|
@@ -189,6 +189,9 @@ flowchart TB
|
|
|
189
189
|
### Audit Log
|
|
190
190
|

|
|
191
191
|
|
|
192
|
+
### Light Theme
|
|
193
|
+

|
|
194
|
+
|
|
192
195
|
## Quick Start
|
|
193
196
|
|
|
194
197
|
### Docker (recommended)
|
|
@@ -399,6 +402,7 @@ All settings are configurable via environment variables with the `WEBGATE_` pref
|
|
|
399
402
|
| `WEBGATE_SECRET_KEY` | `change-me-in-production` | JWT signing + Fernet encryption key |
|
|
400
403
|
| `WEBGATE_DB_URL` | `sqlite+aiosqlite:///./webgate.db` | Database URL |
|
|
401
404
|
| `WEBGATE_ALLOWED_ORIGINS` | `*` | CORS origins (comma-separated) |
|
|
405
|
+
| `WEBGATE_ROOT_PATH` | `` (empty) | URL prefix when served behind a reverse proxy at a sub-path (e.g. `/webgate`) |
|
|
402
406
|
| `WEBGATE_LOG_LEVEL` | `info` | Log level |
|
|
403
407
|
| `WEBGATE_SESSION_TIMEOUT` | `3600` | SSH session timeout (seconds) |
|
|
404
408
|
| `WEBGATE_MAX_UPLOAD_SIZE` | `104857600` | Max upload size (100 MB) |
|
|
@@ -455,6 +459,105 @@ webgate.example.com {
|
|
|
455
459
|
}
|
|
456
460
|
```
|
|
457
461
|
|
|
462
|
+
### Reverse proxy at a sub-path (e.g. `https://example.com/webgate/`)
|
|
463
|
+
|
|
464
|
+
When webgate is exposed behind an existing site under a URL prefix, set `WEBGATE_ROOT_PATH` so FastAPI and the frontend both build URLs with the prefix. The frontend derives the prefix automatically from `window.location.pathname`, but `WEBGATE_ROOT_PATH` is still needed on the backend for OpenAPI URLs.
|
|
465
|
+
|
|
466
|
+
**Docker Compose:**
|
|
467
|
+
|
|
468
|
+
```yaml
|
|
469
|
+
services:
|
|
470
|
+
webgate:
|
|
471
|
+
image: kalexnolasco/webgate:latest
|
|
472
|
+
environment:
|
|
473
|
+
WEBGATE_SECRET_KEY: "${WEBGATE_SECRET_KEY}"
|
|
474
|
+
WEBGATE_ROOT_PATH: "/webgate"
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
> **Important:** the reverse proxy must **forward the prefix unchanged** (not strip it). Webgate receives requests as `/webgate/api/...` and handles them natively — do not rewrite them to `/api/...`.
|
|
478
|
+
|
|
479
|
+
#### nginx
|
|
480
|
+
|
|
481
|
+
```nginx
|
|
482
|
+
server {
|
|
483
|
+
listen 443 ssl http2;
|
|
484
|
+
server_name example.com;
|
|
485
|
+
|
|
486
|
+
ssl_certificate /etc/ssl/certs/example.com.crt;
|
|
487
|
+
ssl_certificate_key /etc/ssl/private/example.com.key;
|
|
488
|
+
|
|
489
|
+
# WebSocket (xterm.js terminal) -- must come before the generic location
|
|
490
|
+
location /webgate/api/ws/ {
|
|
491
|
+
proxy_pass http://127.0.0.1:8443;
|
|
492
|
+
proxy_http_version 1.1;
|
|
493
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
494
|
+
proxy_set_header Connection "upgrade";
|
|
495
|
+
proxy_set_header Host $host;
|
|
496
|
+
proxy_read_timeout 86400s; # keep long-lived SSH sessions alive
|
|
497
|
+
proxy_send_timeout 86400s;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
location /webgate/ {
|
|
501
|
+
proxy_pass http://127.0.0.1:8443;
|
|
502
|
+
proxy_set_header Host $host;
|
|
503
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
504
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
505
|
+
proxy_set_header X-Forwarded-Proto https;
|
|
506
|
+
proxy_set_header X-Forwarded-Prefix /webgate;
|
|
507
|
+
client_max_body_size 100m; # match WEBGATE_MAX_UPLOAD_SIZE
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
#### Apache 2.4
|
|
513
|
+
|
|
514
|
+
```apache
|
|
515
|
+
# Required modules: proxy proxy_http proxy_wstunnel headers rewrite ssl
|
|
516
|
+
# sudo a2enmod proxy proxy_http proxy_wstunnel headers rewrite
|
|
517
|
+
|
|
518
|
+
<VirtualHost *:443>
|
|
519
|
+
ServerName example.com
|
|
520
|
+
|
|
521
|
+
SSLEngine on
|
|
522
|
+
SSLCertificateFile /etc/ssl/certs/example.com.crt
|
|
523
|
+
SSLCertificateKeyFile /etc/ssl/private/example.com.key
|
|
524
|
+
|
|
525
|
+
ProxyPreserveHost On
|
|
526
|
+
RequestHeader set X-Forwarded-Proto "https"
|
|
527
|
+
RequestHeader set X-Forwarded-Prefix "/webgate"
|
|
528
|
+
|
|
529
|
+
# Redirect /webgate -> /webgate/ (trailing slash)
|
|
530
|
+
RewriteEngine On
|
|
531
|
+
RewriteRule ^/webgate$ /webgate/ [R=301,L]
|
|
532
|
+
|
|
533
|
+
# WebSocket (xterm.js terminal) -- MUST come before the HTTP ProxyPass
|
|
534
|
+
ProxyPass /webgate/api/ws/ ws://127.0.0.1:8443/webgate/api/ws/
|
|
535
|
+
ProxyPassReverse /webgate/api/ws/ ws://127.0.0.1:8443/webgate/api/ws/
|
|
536
|
+
|
|
537
|
+
# HTTP
|
|
538
|
+
ProxyPass /webgate/ http://127.0.0.1:8443/webgate/
|
|
539
|
+
ProxyPassReverse /webgate/ http://127.0.0.1:8443/webgate/
|
|
540
|
+
</VirtualHost>
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
#### Traefik (labels)
|
|
544
|
+
|
|
545
|
+
```yaml
|
|
546
|
+
services:
|
|
547
|
+
webgate:
|
|
548
|
+
image: kalexnolasco/webgate:latest
|
|
549
|
+
environment:
|
|
550
|
+
WEBGATE_ROOT_PATH: "/webgate"
|
|
551
|
+
labels:
|
|
552
|
+
- "traefik.enable=true"
|
|
553
|
+
- "traefik.http.routers.webgate.rule=Host(`example.com`) && PathPrefix(`/webgate`)"
|
|
554
|
+
- "traefik.http.routers.webgate.entrypoints=websecure"
|
|
555
|
+
- "traefik.http.routers.webgate.tls=true"
|
|
556
|
+
- "traefik.http.services.webgate.loadbalancer.server.port=8443"
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
Traefik forwards the full path by default, so no extra middleware is needed. Long WebSocket timeouts can be tuned via `forwardingTimeouts` in the Traefik static config.
|
|
560
|
+
|
|
458
561
|
### Useful Commands
|
|
459
562
|
|
|
460
563
|
```bash
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "webgate"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.1"
|
|
4
4
|
description = "Self-hosted web application for remote server management via SSH terminal and SFTP file browser"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -38,6 +38,8 @@ dependencies = [
|
|
|
38
38
|
"cryptography>=44.0.0",
|
|
39
39
|
"websockets>=14.0",
|
|
40
40
|
"slowapi>=0.1.9",
|
|
41
|
+
"pyotp>=2.9.0",
|
|
42
|
+
"qrcode[pil]>=8.2",
|
|
41
43
|
]
|
|
42
44
|
|
|
43
45
|
[project.urls]
|
|
@@ -33,7 +33,12 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
|
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
def create_app() -> FastAPI:
|
|
36
|
-
app = FastAPI(
|
|
36
|
+
app = FastAPI(
|
|
37
|
+
title="webgate",
|
|
38
|
+
version="0.1.0",
|
|
39
|
+
lifespan=lifespan,
|
|
40
|
+
root_path=settings.root_path,
|
|
41
|
+
)
|
|
37
42
|
app.state.limiter = limiter
|
|
38
43
|
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
|
39
44
|
|
|
@@ -2,7 +2,7 @@ import json
|
|
|
2
2
|
from datetime import datetime
|
|
3
3
|
|
|
4
4
|
from pydantic import BaseModel, field_validator
|
|
5
|
-
from sqlalchemy import Boolean, DateTime, String, Text, func
|
|
5
|
+
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
|
|
6
6
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
7
7
|
|
|
8
8
|
from webgate.db.engine import Base
|
|
@@ -17,6 +17,20 @@ class User(Base):
|
|
|
17
17
|
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
18
18
|
must_change_password: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
19
19
|
allowed_groups: Mapped[str] = mapped_column(Text, default="[]") # JSON array of group names
|
|
20
|
+
totp_secret: Mapped[str] = mapped_column(String(255), default="")
|
|
21
|
+
totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
22
|
+
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ApiKey(Base):
|
|
26
|
+
__tablename__ = "api_keys"
|
|
27
|
+
|
|
28
|
+
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
|
29
|
+
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"))
|
|
30
|
+
name: Mapped[str] = mapped_column(String(255))
|
|
31
|
+
key_hash: Mapped[str] = mapped_column(String(255))
|
|
32
|
+
key_prefix: Mapped[str] = mapped_column(String(10))
|
|
33
|
+
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
20
34
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
|
21
35
|
|
|
22
36
|
|
|
@@ -28,6 +42,7 @@ class UserCreate(BaseModel):
|
|
|
28
42
|
class UserLogin(BaseModel):
|
|
29
43
|
username: str
|
|
30
44
|
password: str
|
|
45
|
+
totp_code: str = ""
|
|
31
46
|
|
|
32
47
|
|
|
33
48
|
class UserOut(BaseModel):
|
|
@@ -35,6 +50,7 @@ class UserOut(BaseModel):
|
|
|
35
50
|
username: str
|
|
36
51
|
is_admin: bool
|
|
37
52
|
must_change_password: bool = False
|
|
53
|
+
totp_enabled: bool = False
|
|
38
54
|
allowed_groups: list[str] = []
|
|
39
55
|
|
|
40
56
|
model_config = {"from_attributes": True}
|
|
@@ -65,6 +81,48 @@ class ChangePassword(BaseModel):
|
|
|
65
81
|
new_password: str
|
|
66
82
|
|
|
67
83
|
|
|
84
|
+
class TotpSetupOut(BaseModel):
|
|
85
|
+
secret: str
|
|
86
|
+
qr_uri: str
|
|
87
|
+
qr_base64: str
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class TotpVerifyIn(BaseModel):
|
|
91
|
+
code: str
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TotpStatusOut(BaseModel):
|
|
95
|
+
enabled: bool
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class LoginOut(BaseModel):
|
|
99
|
+
access_token: str = ""
|
|
100
|
+
token_type: str = "bearer"
|
|
101
|
+
requires_2fa: bool = False
|
|
102
|
+
temp_token: str = ""
|
|
103
|
+
|
|
104
|
+
|
|
68
105
|
class TokenOut(BaseModel):
|
|
69
106
|
access_token: str
|
|
70
107
|
token_type: str = "bearer"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ApiKeyCreate(BaseModel):
|
|
111
|
+
name: str
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ApiKeyOut(BaseModel):
|
|
115
|
+
id: int
|
|
116
|
+
name: str
|
|
117
|
+
key_prefix: str
|
|
118
|
+
last_used_at: datetime | None
|
|
119
|
+
created_at: datetime
|
|
120
|
+
|
|
121
|
+
model_config = {"from_attributes": True}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class ApiKeyCreated(BaseModel):
|
|
125
|
+
id: int
|
|
126
|
+
name: str
|
|
127
|
+
key: str
|
|
128
|
+
key_prefix: str
|
|
@@ -11,24 +11,37 @@ limiter = Limiter(key_func=get_remote_address)
|
|
|
11
11
|
from webgate.audit.models import AuditOut
|
|
12
12
|
from webgate.audit.service import get_audit_log, log_action
|
|
13
13
|
from webgate.auth.models import (
|
|
14
|
+
ApiKeyCreate,
|
|
15
|
+
ApiKeyCreated,
|
|
16
|
+
ApiKeyOut,
|
|
14
17
|
ChangePassword,
|
|
15
|
-
|
|
18
|
+
LoginOut,
|
|
19
|
+
TotpSetupOut,
|
|
20
|
+
TotpStatusOut,
|
|
21
|
+
TotpVerifyIn,
|
|
16
22
|
UserLogin,
|
|
17
23
|
UserManage,
|
|
18
24
|
UserOut,
|
|
19
25
|
UserUpdateGroups,
|
|
20
26
|
)
|
|
21
27
|
from webgate.auth.service import (
|
|
28
|
+
authenticate_api_key,
|
|
22
29
|
create_access_token,
|
|
30
|
+
create_api_key,
|
|
23
31
|
create_user,
|
|
24
32
|
decode_access_token,
|
|
33
|
+
delete_api_key,
|
|
25
34
|
delete_user,
|
|
35
|
+
generate_totp_secret,
|
|
36
|
+
get_api_keys,
|
|
37
|
+
get_totp_uri,
|
|
26
38
|
get_user_by_id,
|
|
27
39
|
get_user_by_username,
|
|
28
40
|
list_users,
|
|
29
41
|
update_user_groups,
|
|
30
42
|
update_user_password,
|
|
31
43
|
verify_password,
|
|
44
|
+
verify_totp,
|
|
32
45
|
)
|
|
33
46
|
from webgate.db.engine import get_session
|
|
34
47
|
|
|
@@ -40,7 +53,17 @@ AuthDep = Annotated[HTTPAuthorizationCredentials, Depends(security)]
|
|
|
40
53
|
|
|
41
54
|
|
|
42
55
|
async def get_current_user(credentials: AuthDep, session: SessionDep) -> UserOut:
|
|
43
|
-
|
|
56
|
+
token = credentials.credentials
|
|
57
|
+
|
|
58
|
+
# Check if it's an API key (starts with "wg_")
|
|
59
|
+
if token.startswith("wg_"):
|
|
60
|
+
user = await authenticate_api_key(session, token)
|
|
61
|
+
if not user:
|
|
62
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
|
|
63
|
+
return UserOut.model_validate(user)
|
|
64
|
+
|
|
65
|
+
# Otherwise treat as JWT
|
|
66
|
+
payload = decode_access_token(token)
|
|
44
67
|
if payload is None:
|
|
45
68
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
|
46
69
|
user_id = payload.get("sub")
|
|
@@ -60,17 +83,35 @@ def _require_admin(user: UserOut) -> None:
|
|
|
60
83
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin only")
|
|
61
84
|
|
|
62
85
|
|
|
63
|
-
@router.post("/login", response_model=
|
|
86
|
+
@router.post("/login", response_model=LoginOut)
|
|
64
87
|
@limiter.limit("10/minute")
|
|
65
|
-
async def login(request: Request, body: UserLogin, session: SessionDep) ->
|
|
88
|
+
async def login(request: Request, body: UserLogin, session: SessionDep) -> LoginOut:
|
|
66
89
|
user = await get_user_by_username(session, body.username)
|
|
67
90
|
if not user or not verify_password(body.password, user.hashed_password):
|
|
68
91
|
raise HTTPException(
|
|
69
92
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials"
|
|
70
93
|
)
|
|
94
|
+
# Check if 2FA is enabled
|
|
95
|
+
if user.totp_enabled and user.totp_secret:
|
|
96
|
+
if not body.totp_code:
|
|
97
|
+
# Issue a short-lived temp token for 2FA verification
|
|
98
|
+
temp_token = create_access_token(
|
|
99
|
+
{"sub": str(user.id), "pending_2fa": True, "exp_minutes": 2}
|
|
100
|
+
)
|
|
101
|
+
return LoginOut(requires_2fa=True, temp_token=temp_token)
|
|
102
|
+
# Verify the TOTP code
|
|
103
|
+
if not verify_totp(user.totp_secret, body.totp_code):
|
|
104
|
+
raise HTTPException(
|
|
105
|
+
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid 2FA code"
|
|
106
|
+
)
|
|
71
107
|
token = create_access_token({"sub": str(user.id), "username": user.username})
|
|
72
|
-
await log_action(
|
|
73
|
-
|
|
108
|
+
await log_action(
|
|
109
|
+
user.id,
|
|
110
|
+
user.username,
|
|
111
|
+
"login",
|
|
112
|
+
ip_address=request.client.host if request.client else "",
|
|
113
|
+
)
|
|
114
|
+
return LoginOut(access_token=token)
|
|
74
115
|
|
|
75
116
|
|
|
76
117
|
@router.get("/me", response_model=UserOut)
|
|
@@ -162,6 +203,112 @@ async def remove_user(
|
|
|
162
203
|
await delete_user(session, user)
|
|
163
204
|
|
|
164
205
|
|
|
206
|
+
@router.post("/totp/setup", response_model=TotpSetupOut)
|
|
207
|
+
async def totp_setup(session: SessionDep, current_user: CurrentUserDep) -> TotpSetupOut:
|
|
208
|
+
import base64
|
|
209
|
+
import io
|
|
210
|
+
|
|
211
|
+
import qrcode
|
|
212
|
+
|
|
213
|
+
user = await get_user_by_id(session, current_user.id)
|
|
214
|
+
if not user:
|
|
215
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
|
216
|
+
secret = generate_totp_secret()
|
|
217
|
+
user.totp_secret = secret
|
|
218
|
+
await session.commit()
|
|
219
|
+
uri = get_totp_uri(secret, user.username)
|
|
220
|
+
qr = qrcode.make(uri)
|
|
221
|
+
buf = io.BytesIO()
|
|
222
|
+
qr.save(buf, format="PNG")
|
|
223
|
+
qr_b64 = base64.b64encode(buf.getvalue()).decode()
|
|
224
|
+
return TotpSetupOut(secret=secret, qr_uri=uri, qr_base64=f"data:image/png;base64,{qr_b64}")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@router.post("/totp/verify", response_model=TotpStatusOut)
|
|
228
|
+
async def totp_verify(
|
|
229
|
+
body: TotpVerifyIn, session: SessionDep, current_user: CurrentUserDep
|
|
230
|
+
) -> TotpStatusOut:
|
|
231
|
+
user = await get_user_by_id(session, current_user.id)
|
|
232
|
+
if not user:
|
|
233
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
|
234
|
+
if not user.totp_secret:
|
|
235
|
+
raise HTTPException(
|
|
236
|
+
status_code=status.HTTP_400_BAD_REQUEST, detail="Run TOTP setup first"
|
|
237
|
+
)
|
|
238
|
+
if not verify_totp(user.totp_secret, body.code):
|
|
239
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid TOTP code")
|
|
240
|
+
user.totp_enabled = True
|
|
241
|
+
await session.commit()
|
|
242
|
+
return TotpStatusOut(enabled=True)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@router.post("/totp/disable", response_model=TotpStatusOut)
|
|
246
|
+
async def totp_disable(
|
|
247
|
+
body: TotpVerifyIn, session: SessionDep, current_user: CurrentUserDep
|
|
248
|
+
) -> TotpStatusOut:
|
|
249
|
+
user = await get_user_by_id(session, current_user.id)
|
|
250
|
+
if not user:
|
|
251
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
|
252
|
+
if not user.totp_enabled:
|
|
253
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA is not enabled")
|
|
254
|
+
if not verify_totp(user.totp_secret, body.code):
|
|
255
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid TOTP code")
|
|
256
|
+
user.totp_secret = ""
|
|
257
|
+
user.totp_enabled = False
|
|
258
|
+
await session.commit()
|
|
259
|
+
return TotpStatusOut(enabled=False)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@router.put("/users/{user_id}/totp-reset", response_model=UserOut)
|
|
263
|
+
async def reset_user_totp(
|
|
264
|
+
user_id: int, session: SessionDep, current_user: CurrentUserDep
|
|
265
|
+
) -> UserOut:
|
|
266
|
+
_require_admin(current_user)
|
|
267
|
+
user = await get_user_by_id(session, user_id)
|
|
268
|
+
if not user:
|
|
269
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
|
270
|
+
user.totp_secret = ""
|
|
271
|
+
user.totp_enabled = False
|
|
272
|
+
await session.commit()
|
|
273
|
+
await session.refresh(user)
|
|
274
|
+
return UserOut.model_validate(user)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ---- API Keys ----
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@router.get("/api-keys", response_model=list[ApiKeyOut])
|
|
281
|
+
async def list_api_keys(session: SessionDep, current_user: CurrentUserDep) -> list[ApiKeyOut]:
|
|
282
|
+
keys = await get_api_keys(session, current_user.id)
|
|
283
|
+
return [ApiKeyOut.model_validate(k) for k in keys]
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@router.post("/api-keys", response_model=ApiKeyCreated, status_code=status.HTTP_201_CREATED)
|
|
287
|
+
async def create_api_key_endpoint(
|
|
288
|
+
body: ApiKeyCreate, session: SessionDep, current_user: CurrentUserDep
|
|
289
|
+
) -> ApiKeyCreated:
|
|
290
|
+
if not body.name.strip():
|
|
291
|
+
raise HTTPException(
|
|
292
|
+
status_code=status.HTTP_400_BAD_REQUEST, detail="Name is required"
|
|
293
|
+
)
|
|
294
|
+
key_obj, plaintext_key = await create_api_key(session, current_user.id, body.name.strip())
|
|
295
|
+
return ApiKeyCreated(
|
|
296
|
+
id=key_obj.id,
|
|
297
|
+
name=key_obj.name,
|
|
298
|
+
key=plaintext_key,
|
|
299
|
+
key_prefix=key_obj.key_prefix,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@router.delete("/api-keys/{key_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
304
|
+
async def revoke_api_key(
|
|
305
|
+
key_id: int, session: SessionDep, current_user: CurrentUserDep
|
|
306
|
+
) -> None:
|
|
307
|
+
deleted = await delete_api_key(session, key_id, current_user.id)
|
|
308
|
+
if not deleted:
|
|
309
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API key not found")
|
|
310
|
+
|
|
311
|
+
|
|
165
312
|
@router.get("/audit", response_model=list[AuditOut])
|
|
166
313
|
async def audit_log_endpoint(
|
|
167
314
|
session: SessionDep,
|