webgate 0.2.0__tar.gz → 0.2.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 (89) hide show
  1. {webgate-0.2.0 → webgate-0.2.2}/CHANGELOG.md +32 -0
  2. webgate-0.2.2/Dockerfile.demo +63 -0
  3. {webgate-0.2.0 → webgate-0.2.2}/PKG-INFO +109 -1
  4. {webgate-0.2.0 → webgate-0.2.2}/README.md +106 -0
  5. webgate-0.2.2/docs/screenshots/access-control.png +0 -0
  6. webgate-0.2.2/docs/screenshots/audit.png +0 -0
  7. webgate-0.2.2/docs/screenshots/editor.png +0 -0
  8. webgate-0.2.2/docs/screenshots/light-theme.png +0 -0
  9. webgate-0.2.2/docs/screenshots/sftp.png +0 -0
  10. webgate-0.2.2/docs/screenshots/site-manager.png +0 -0
  11. webgate-0.2.2/docs/screenshots/split-view.png +0 -0
  12. webgate-0.2.2/docs/screenshots/terminal.png +0 -0
  13. webgate-0.2.2/docs/screenshots/users.png +0 -0
  14. webgate-0.2.2/fly.toml +48 -0
  15. {webgate-0.2.0 → webgate-0.2.2}/pyproject.toml +3 -1
  16. {webgate-0.2.0 → webgate-0.2.2}/src/webgate/app.py +37 -2
  17. {webgate-0.2.0 → webgate-0.2.2}/src/webgate/auth/models.py +59 -1
  18. {webgate-0.2.0 → webgate-0.2.2}/src/webgate/auth/routes.py +153 -6
  19. {webgate-0.2.0 → webgate-0.2.2}/src/webgate/auth/service.py +80 -1
  20. {webgate-0.2.0 → webgate-0.2.2}/src/webgate/config.py +2 -0
  21. {webgate-0.2.0 → webgate-0.2.2}/src/webgate/db/engine.py +2 -0
  22. webgate-0.2.2/src/webgate/demo.py +55 -0
  23. {webgate-0.2.0 → webgate-0.2.2}/src/webgate/static/index.html +218 -12
  24. {webgate-0.2.0 → webgate-0.2.2}/src/webgate/terminal/ws_handler.py +11 -1
  25. {webgate-0.2.0 → webgate-0.2.2}/uv.lock +117 -0
  26. webgate-0.2.0/docs/screenshots/access-control.png +0 -0
  27. webgate-0.2.0/docs/screenshots/audit.png +0 -0
  28. webgate-0.2.0/docs/screenshots/editor.png +0 -0
  29. webgate-0.2.0/docs/screenshots/sftp.png +0 -0
  30. webgate-0.2.0/docs/screenshots/site-manager.png +0 -0
  31. webgate-0.2.0/docs/screenshots/split-view.png +0 -0
  32. webgate-0.2.0/docs/screenshots/terminal.png +0 -0
  33. webgate-0.2.0/docs/screenshots/users.png +0 -0
  34. {webgate-0.2.0 → webgate-0.2.2}/.dockerignore +0 -0
  35. {webgate-0.2.0 → webgate-0.2.2}/.github/workflows/docs.yml +0 -0
  36. {webgate-0.2.0 → webgate-0.2.2}/.gitignore +0 -0
  37. {webgate-0.2.0 → webgate-0.2.2}/Dockerfile +0 -0
  38. {webgate-0.2.0 → webgate-0.2.2}/Dockerfile.ssh-demo +0 -0
  39. {webgate-0.2.0 → webgate-0.2.2}/LICENSE +0 -0
  40. {webgate-0.2.0 → webgate-0.2.2}/ROADMAP.md +0 -0
  41. {webgate-0.2.0 → webgate-0.2.2}/VERSION +0 -0
  42. {webgate-0.2.0 → webgate-0.2.2}/compose.dev.yml +0 -0
  43. {webgate-0.2.0 → webgate-0.2.2}/compose.yml +0 -0
  44. {webgate-0.2.0 → webgate-0.2.2}/docs/api/auth.md +0 -0
  45. {webgate-0.2.0 → webgate-0.2.2}/docs/api/files.md +0 -0
  46. {webgate-0.2.0 → webgate-0.2.2}/docs/api/servers.md +0 -0
  47. {webgate-0.2.0 → webgate-0.2.2}/docs/api/terminal.md +0 -0
  48. {webgate-0.2.0 → webgate-0.2.2}/docs/changelog.md +0 -0
  49. {webgate-0.2.0 → webgate-0.2.2}/docs/getting-started/installation.md +0 -0
  50. {webgate-0.2.0 → webgate-0.2.2}/docs/getting-started/quickstart.md +0 -0
  51. {webgate-0.2.0 → webgate-0.2.2}/docs/guide/files.md +0 -0
  52. {webgate-0.2.0 → webgate-0.2.2}/docs/guide/servers.md +0 -0
  53. {webgate-0.2.0 → webgate-0.2.2}/docs/guide/split.md +0 -0
  54. {webgate-0.2.0 → webgate-0.2.2}/docs/guide/terminal.md +0 -0
  55. {webgate-0.2.0 → webgate-0.2.2}/docs/guide/users.md +0 -0
  56. {webgate-0.2.0 → webgate-0.2.2}/docs/index.md +0 -0
  57. {webgate-0.2.0 → webgate-0.2.2}/docs/screenshots/edit-access-control.png +0 -0
  58. {webgate-0.2.0 → webgate-0.2.2}/docs/screenshots/login.png +0 -0
  59. {webgate-0.2.0 → webgate-0.2.2}/docs/screenshots/new-server-form.png +0 -0
  60. {webgate-0.2.0 → webgate-0.2.2}/docs/screenshots/sftp-restricted.png +0 -0
  61. {webgate-0.2.0 → webgate-0.2.2}/docs/screenshots/ssh-disabled.png +0 -0
  62. {webgate-0.2.0 → webgate-0.2.2}/mkdocs.yml +0 -0
  63. {webgate-0.2.0 → webgate-0.2.2}/src/webgate/__init__.py +0 -0
  64. {webgate-0.2.0 → webgate-0.2.2}/src/webgate/__main__.py +0 -0
  65. {webgate-0.2.0 → webgate-0.2.2}/src/webgate/audit/__init__.py +0 -0
  66. {webgate-0.2.0 → webgate-0.2.2}/src/webgate/audit/models.py +0 -0
  67. {webgate-0.2.0 → webgate-0.2.2}/src/webgate/audit/service.py +0 -0
  68. {webgate-0.2.0 → webgate-0.2.2}/src/webgate/auth/__init__.py +0 -0
  69. {webgate-0.2.0 → webgate-0.2.2}/src/webgate/db/__init__.py +0 -0
  70. {webgate-0.2.0 → webgate-0.2.2}/src/webgate/files/__init__.py +0 -0
  71. {webgate-0.2.0 → webgate-0.2.2}/src/webgate/files/models.py +0 -0
  72. {webgate-0.2.0 → webgate-0.2.2}/src/webgate/files/pool.py +0 -0
  73. {webgate-0.2.0 → webgate-0.2.2}/src/webgate/files/routes.py +0 -0
  74. {webgate-0.2.0 → webgate-0.2.2}/src/webgate/files/sftp_service.py +0 -0
  75. {webgate-0.2.0 → webgate-0.2.2}/src/webgate/servers/__init__.py +0 -0
  76. {webgate-0.2.0 → webgate-0.2.2}/src/webgate/servers/crypto.py +0 -0
  77. {webgate-0.2.0 → webgate-0.2.2}/src/webgate/servers/models.py +0 -0
  78. {webgate-0.2.0 → webgate-0.2.2}/src/webgate/servers/monitor.py +0 -0
  79. {webgate-0.2.0 → webgate-0.2.2}/src/webgate/servers/routes.py +0 -0
  80. {webgate-0.2.0 → webgate-0.2.2}/src/webgate/servers/service.py +0 -0
  81. {webgate-0.2.0 → webgate-0.2.2}/src/webgate/terminal/__init__.py +0 -0
  82. {webgate-0.2.0 → webgate-0.2.2}/src/webgate/terminal/routes.py +0 -0
  83. {webgate-0.2.0 → webgate-0.2.2}/src/webgate/terminal/ssh_session.py +0 -0
  84. {webgate-0.2.0 → webgate-0.2.2}/tests/__init__.py +0 -0
  85. {webgate-0.2.0 → webgate-0.2.2}/tests/conftest.py +0 -0
  86. {webgate-0.2.0 → webgate-0.2.2}/tests/test_auth.py +0 -0
  87. {webgate-0.2.0 → webgate-0.2.2}/tests/test_files.py +0 -0
  88. {webgate-0.2.0 → webgate-0.2.2}/tests/test_servers.py +0 -0
  89. {webgate-0.2.0 → webgate-0.2.2}/tests/test_terminal.py +0 -0
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.2.2 (2026-04-15)
4
+
5
+ ### Features
6
+
7
+ - **Demo mode** (`WEBGATE_DEMO_MODE=true`) -- read-only public demo deployments. Blocks every write request on `/api/*` (except login), disables the WebSocket quick-connect endpoint, seeds a `demo`/`demo` user with a sample server, and shows a top banner in the UI.
8
+ - **`Dockerfile.demo`** -- single-container image bundling webgate + a sandboxed `sshd` target via supervisord. Ready for free hosting tiers.
9
+ - **`fly.toml`** -- Fly.io configuration for one-command demo deployments (`flyctl deploy`).
10
+
11
+ ### Details
12
+
13
+ - New public endpoint `GET /api/config` exposes `{"demo_mode": bool}` for the frontend to render the banner before login
14
+ - `WEBGATE_DEMO_MODE=true` adds an HTTP middleware that returns `403` for any `POST/PUT/PATCH/DELETE` on `/api/*` (allowlist: `/api/auth/login`, `/api/auth/totp/verify`)
15
+ - Demo seed (`webgate.demo`) is idempotent and only runs when the flag is on
16
+ - Hourly state reset for the public demo can be done with a cron pinging the container restart, so DB returns to seed state
17
+
18
+ ---
19
+
20
+ ## v0.2.1 (2026-04-15)
21
+
22
+ ### Features
23
+
24
+ - **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.
25
+
26
+ ### Details
27
+
28
+ - New config setting: `WEBGATE_ROOT_PATH` (default `""`), passed to FastAPI's `root_path` for correct OpenAPI URLs behind proxies
29
+ - Frontend derives the path prefix at runtime from `window.location.pathname` and prepends it to all REST calls and the terminal WebSocket URL
30
+ - README documents nginx, Apache, and Traefik reverse-proxy configurations for sub-path deployments
31
+ - The proxy must forward the prefix unchanged (do not strip it) -- webgate handles the prefix natively
32
+
33
+ ---
34
+
3
35
  ## v0.2.0 (2026-04-09)
4
36
 
5
37
  ### Features
@@ -0,0 +1,63 @@
1
+ # Single-container demo image: webgate + a sandboxed SSH/SFTP target
2
+ # managed by supervisord. Suitable for free tiers (Fly.io, Render) where
3
+ # spinning a sidecar VM isn't ideal.
4
+ #
5
+ # Build: docker build -f Dockerfile.demo -t webgate-demo .
6
+ # Run: docker run -p 8443:8443 -e WEBGATE_SECRET_KEY=demo webgate-demo
7
+
8
+ # --- Stage 1: build webgate ---
9
+ FROM python:3.13-slim AS builder
10
+ WORKDIR /app
11
+ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
12
+ COPY pyproject.toml uv.lock README.md ./
13
+ RUN uv sync --frozen --no-dev --no-editable
14
+ COPY src/ src/
15
+
16
+ # --- Stage 2: runtime with webgate + sshd + supervisord ---
17
+ FROM python:3.13-slim
18
+
19
+ RUN apt-get update && \
20
+ apt-get install -y --no-install-recommends \
21
+ openssh-server supervisor procps curl nano htop tree && \
22
+ rm -rf /var/lib/apt/lists/*
23
+
24
+ # sshd config: only the demo user, password auth allowed for the demo
25
+ RUN mkdir -p /run/sshd && \
26
+ sed -i 's/^#PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config && \
27
+ sed -i 's/^#PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config && \
28
+ useradd -m -s /bin/bash demo && \
29
+ echo 'demo:demo' | chpasswd
30
+
31
+ # Sample tree so SFTP browsing has something to show
32
+ RUN mkdir -p /home/demo/projects/webgate /home/demo/projects/api-server \
33
+ /home/demo/documents /home/demo/logs /home/demo/scripts && \
34
+ printf '# webgate\nSelf-hosted SSH + SFTP manager\n' > /home/demo/projects/webgate/README.md && \
35
+ printf '{"name":"api-server","version":"2.1.0"}\n' > /home/demo/projects/api-server/package.json && \
36
+ printf 'Hello from the webgate demo!\n' > /home/demo/documents/welcome.txt && \
37
+ printf '2026-04-08 10:00:00 INFO Server started on port 8080\n' > /home/demo/logs/app.log && \
38
+ printf '#!/bin/bash\necho "Deploy complete"\n' > /home/demo/scripts/deploy.sh && \
39
+ chmod +x /home/demo/scripts/deploy.sh && \
40
+ chown -R demo:demo /home/demo
41
+
42
+ # webgate
43
+ WORKDIR /app
44
+ COPY --from=builder /app/.venv /app/.venv
45
+ COPY --from=builder /app/src /app/src
46
+ RUN mkdir -p /data
47
+ ENV PATH="/app/.venv/bin:$PATH" \
48
+ PYTHONPATH="/app/src" \
49
+ WEBGATE_HOST=0.0.0.0 \
50
+ WEBGATE_PORT=8443 \
51
+ WEBGATE_DB_URL=sqlite+aiosqlite:////data/webgate.db \
52
+ WEBGATE_LOG_LEVEL=info \
53
+ WEBGATE_DEMO_MODE=true
54
+
55
+ # supervisord config
56
+ RUN printf '[supervisord]\nnodaemon=true\nuser=root\nlogfile=/dev/stdout\nlogfile_maxbytes=0\n\n[program:sshd]\ncommand=/usr/sbin/sshd -D -e\nautorestart=true\nstdout_logfile=/dev/stdout\nstdout_logfile_maxbytes=0\nstderr_logfile=/dev/stderr\nstderr_logfile_maxbytes=0\n\n[program:webgate]\ncommand=python -m webgate\nautorestart=true\nstdout_logfile=/dev/stdout\nstdout_logfile_maxbytes=0\nstderr_logfile=/dev/stderr\nstderr_logfile_maxbytes=0\n' > /etc/supervisor/conf.d/webgate.conf
57
+
58
+ EXPOSE 8443
59
+
60
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
61
+ CMD curl -fsS http://localhost:8443/api/health || exit 1
62
+
63
+ CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/webgate.conf"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webgate
3
- Version: 0.2.0
3
+ Version: 0.2.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/
@@ -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
@@ -56,6 +58,8 @@ Description-Content-Type: text/markdown
56
58
  Self-hosted web application for remote server management via **SSH terminal** and **SFTP file browser**. A modern Python replacement combining the best of [webssh](https://github.com/huashengdun/webssh) and [filebrowser](https://github.com/filebrowser/filebrowser) into a single unified tool with a FileZilla-inspired interface.
57
59
 
58
60
  > **Documentation: [kalexnolasco.github.io/webgate](https://kalexnolasco.github.io/webgate/)**
61
+ >
62
+ > **🎮 Live demo: [webgate-demo.fly.dev](https://webgate-demo.fly.dev/)** — login `demo` / `demo` (read-only, resets hourly)
59
63
 
60
64
  ---
61
65
 
@@ -233,6 +237,9 @@ flowchart TB
233
237
  ### Audit Log
234
238
  ![Audit](docs/screenshots/audit.png)
235
239
 
240
+ ### Light Theme
241
+ ![Light Theme](docs/screenshots/light-theme.png)
242
+
236
243
  ## Quick Start
237
244
 
238
245
  ### Docker (recommended)
@@ -443,6 +450,8 @@ All settings are configurable via environment variables with the `WEBGATE_` pref
443
450
  | `WEBGATE_SECRET_KEY` | `change-me-in-production` | JWT signing + Fernet encryption key |
444
451
  | `WEBGATE_DB_URL` | `sqlite+aiosqlite:///./webgate.db` | Database URL |
445
452
  | `WEBGATE_ALLOWED_ORIGINS` | `*` | CORS origins (comma-separated) |
453
+ | `WEBGATE_ROOT_PATH` | `` (empty) | URL prefix when served behind a reverse proxy at a sub-path (e.g. `/webgate`) |
454
+ | `WEBGATE_DEMO_MODE` | `false` | Read-only public demo: blocks all writes, seeds a `demo`/`demo` user and shows a banner |
446
455
  | `WEBGATE_LOG_LEVEL` | `info` | Log level |
447
456
  | `WEBGATE_SESSION_TIMEOUT` | `3600` | SSH session timeout (seconds) |
448
457
  | `WEBGATE_MAX_UPLOAD_SIZE` | `104857600` | Max upload size (100 MB) |
@@ -499,6 +508,105 @@ webgate.example.com {
499
508
  }
500
509
  ```
501
510
 
511
+ ### Reverse proxy at a sub-path (e.g. `https://example.com/webgate/`)
512
+
513
+ 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.
514
+
515
+ **Docker Compose:**
516
+
517
+ ```yaml
518
+ services:
519
+ webgate:
520
+ image: kalexnolasco/webgate:latest
521
+ environment:
522
+ WEBGATE_SECRET_KEY: "${WEBGATE_SECRET_KEY}"
523
+ WEBGATE_ROOT_PATH: "/webgate"
524
+ ```
525
+
526
+ > **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/...`.
527
+
528
+ #### nginx
529
+
530
+ ```nginx
531
+ server {
532
+ listen 443 ssl http2;
533
+ server_name example.com;
534
+
535
+ ssl_certificate /etc/ssl/certs/example.com.crt;
536
+ ssl_certificate_key /etc/ssl/private/example.com.key;
537
+
538
+ # WebSocket (xterm.js terminal) -- must come before the generic location
539
+ location /webgate/api/ws/ {
540
+ proxy_pass http://127.0.0.1:8443;
541
+ proxy_http_version 1.1;
542
+ proxy_set_header Upgrade $http_upgrade;
543
+ proxy_set_header Connection "upgrade";
544
+ proxy_set_header Host $host;
545
+ proxy_read_timeout 86400s; # keep long-lived SSH sessions alive
546
+ proxy_send_timeout 86400s;
547
+ }
548
+
549
+ location /webgate/ {
550
+ proxy_pass http://127.0.0.1:8443;
551
+ proxy_set_header Host $host;
552
+ proxy_set_header X-Real-IP $remote_addr;
553
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
554
+ proxy_set_header X-Forwarded-Proto https;
555
+ proxy_set_header X-Forwarded-Prefix /webgate;
556
+ client_max_body_size 100m; # match WEBGATE_MAX_UPLOAD_SIZE
557
+ }
558
+ }
559
+ ```
560
+
561
+ #### Apache 2.4
562
+
563
+ ```apache
564
+ # Required modules: proxy proxy_http proxy_wstunnel headers rewrite ssl
565
+ # sudo a2enmod proxy proxy_http proxy_wstunnel headers rewrite
566
+
567
+ <VirtualHost *:443>
568
+ ServerName example.com
569
+
570
+ SSLEngine on
571
+ SSLCertificateFile /etc/ssl/certs/example.com.crt
572
+ SSLCertificateKeyFile /etc/ssl/private/example.com.key
573
+
574
+ ProxyPreserveHost On
575
+ RequestHeader set X-Forwarded-Proto "https"
576
+ RequestHeader set X-Forwarded-Prefix "/webgate"
577
+
578
+ # Redirect /webgate -> /webgate/ (trailing slash)
579
+ RewriteEngine On
580
+ RewriteRule ^/webgate$ /webgate/ [R=301,L]
581
+
582
+ # WebSocket (xterm.js terminal) -- MUST come before the HTTP ProxyPass
583
+ ProxyPass /webgate/api/ws/ ws://127.0.0.1:8443/webgate/api/ws/
584
+ ProxyPassReverse /webgate/api/ws/ ws://127.0.0.1:8443/webgate/api/ws/
585
+
586
+ # HTTP
587
+ ProxyPass /webgate/ http://127.0.0.1:8443/webgate/
588
+ ProxyPassReverse /webgate/ http://127.0.0.1:8443/webgate/
589
+ </VirtualHost>
590
+ ```
591
+
592
+ #### Traefik (labels)
593
+
594
+ ```yaml
595
+ services:
596
+ webgate:
597
+ image: kalexnolasco/webgate:latest
598
+ environment:
599
+ WEBGATE_ROOT_PATH: "/webgate"
600
+ labels:
601
+ - "traefik.enable=true"
602
+ - "traefik.http.routers.webgate.rule=Host(`example.com`) && PathPrefix(`/webgate`)"
603
+ - "traefik.http.routers.webgate.entrypoints=websecure"
604
+ - "traefik.http.routers.webgate.tls=true"
605
+ - "traefik.http.services.webgate.loadbalancer.server.port=8443"
606
+ ```
607
+
608
+ 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.
609
+
502
610
  ### Useful Commands
503
611
 
504
612
  ```bash
@@ -12,6 +12,8 @@
12
12
  Self-hosted web application for remote server management via **SSH terminal** and **SFTP file browser**. A modern Python replacement combining the best of [webssh](https://github.com/huashengdun/webssh) and [filebrowser](https://github.com/filebrowser/filebrowser) into a single unified tool with a FileZilla-inspired interface.
13
13
 
14
14
  > **Documentation: [kalexnolasco.github.io/webgate](https://kalexnolasco.github.io/webgate/)**
15
+ >
16
+ > **🎮 Live demo: [webgate-demo.fly.dev](https://webgate-demo.fly.dev/)** — login `demo` / `demo` (read-only, resets hourly)
15
17
 
16
18
  ---
17
19
 
@@ -189,6 +191,9 @@ flowchart TB
189
191
  ### Audit Log
190
192
  ![Audit](docs/screenshots/audit.png)
191
193
 
194
+ ### Light Theme
195
+ ![Light Theme](docs/screenshots/light-theme.png)
196
+
192
197
  ## Quick Start
193
198
 
194
199
  ### Docker (recommended)
@@ -399,6 +404,8 @@ All settings are configurable via environment variables with the `WEBGATE_` pref
399
404
  | `WEBGATE_SECRET_KEY` | `change-me-in-production` | JWT signing + Fernet encryption key |
400
405
  | `WEBGATE_DB_URL` | `sqlite+aiosqlite:///./webgate.db` | Database URL |
401
406
  | `WEBGATE_ALLOWED_ORIGINS` | `*` | CORS origins (comma-separated) |
407
+ | `WEBGATE_ROOT_PATH` | `` (empty) | URL prefix when served behind a reverse proxy at a sub-path (e.g. `/webgate`) |
408
+ | `WEBGATE_DEMO_MODE` | `false` | Read-only public demo: blocks all writes, seeds a `demo`/`demo` user and shows a banner |
402
409
  | `WEBGATE_LOG_LEVEL` | `info` | Log level |
403
410
  | `WEBGATE_SESSION_TIMEOUT` | `3600` | SSH session timeout (seconds) |
404
411
  | `WEBGATE_MAX_UPLOAD_SIZE` | `104857600` | Max upload size (100 MB) |
@@ -455,6 +462,105 @@ webgate.example.com {
455
462
  }
456
463
  ```
457
464
 
465
+ ### Reverse proxy at a sub-path (e.g. `https://example.com/webgate/`)
466
+
467
+ 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.
468
+
469
+ **Docker Compose:**
470
+
471
+ ```yaml
472
+ services:
473
+ webgate:
474
+ image: kalexnolasco/webgate:latest
475
+ environment:
476
+ WEBGATE_SECRET_KEY: "${WEBGATE_SECRET_KEY}"
477
+ WEBGATE_ROOT_PATH: "/webgate"
478
+ ```
479
+
480
+ > **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/...`.
481
+
482
+ #### nginx
483
+
484
+ ```nginx
485
+ server {
486
+ listen 443 ssl http2;
487
+ server_name example.com;
488
+
489
+ ssl_certificate /etc/ssl/certs/example.com.crt;
490
+ ssl_certificate_key /etc/ssl/private/example.com.key;
491
+
492
+ # WebSocket (xterm.js terminal) -- must come before the generic location
493
+ location /webgate/api/ws/ {
494
+ proxy_pass http://127.0.0.1:8443;
495
+ proxy_http_version 1.1;
496
+ proxy_set_header Upgrade $http_upgrade;
497
+ proxy_set_header Connection "upgrade";
498
+ proxy_set_header Host $host;
499
+ proxy_read_timeout 86400s; # keep long-lived SSH sessions alive
500
+ proxy_send_timeout 86400s;
501
+ }
502
+
503
+ location /webgate/ {
504
+ proxy_pass http://127.0.0.1:8443;
505
+ proxy_set_header Host $host;
506
+ proxy_set_header X-Real-IP $remote_addr;
507
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
508
+ proxy_set_header X-Forwarded-Proto https;
509
+ proxy_set_header X-Forwarded-Prefix /webgate;
510
+ client_max_body_size 100m; # match WEBGATE_MAX_UPLOAD_SIZE
511
+ }
512
+ }
513
+ ```
514
+
515
+ #### Apache 2.4
516
+
517
+ ```apache
518
+ # Required modules: proxy proxy_http proxy_wstunnel headers rewrite ssl
519
+ # sudo a2enmod proxy proxy_http proxy_wstunnel headers rewrite
520
+
521
+ <VirtualHost *:443>
522
+ ServerName example.com
523
+
524
+ SSLEngine on
525
+ SSLCertificateFile /etc/ssl/certs/example.com.crt
526
+ SSLCertificateKeyFile /etc/ssl/private/example.com.key
527
+
528
+ ProxyPreserveHost On
529
+ RequestHeader set X-Forwarded-Proto "https"
530
+ RequestHeader set X-Forwarded-Prefix "/webgate"
531
+
532
+ # Redirect /webgate -> /webgate/ (trailing slash)
533
+ RewriteEngine On
534
+ RewriteRule ^/webgate$ /webgate/ [R=301,L]
535
+
536
+ # WebSocket (xterm.js terminal) -- MUST come before the HTTP ProxyPass
537
+ ProxyPass /webgate/api/ws/ ws://127.0.0.1:8443/webgate/api/ws/
538
+ ProxyPassReverse /webgate/api/ws/ ws://127.0.0.1:8443/webgate/api/ws/
539
+
540
+ # HTTP
541
+ ProxyPass /webgate/ http://127.0.0.1:8443/webgate/
542
+ ProxyPassReverse /webgate/ http://127.0.0.1:8443/webgate/
543
+ </VirtualHost>
544
+ ```
545
+
546
+ #### Traefik (labels)
547
+
548
+ ```yaml
549
+ services:
550
+ webgate:
551
+ image: kalexnolasco/webgate:latest
552
+ environment:
553
+ WEBGATE_ROOT_PATH: "/webgate"
554
+ labels:
555
+ - "traefik.enable=true"
556
+ - "traefik.http.routers.webgate.rule=Host(`example.com`) && PathPrefix(`/webgate`)"
557
+ - "traefik.http.routers.webgate.entrypoints=websecure"
558
+ - "traefik.http.routers.webgate.tls=true"
559
+ - "traefik.http.services.webgate.loadbalancer.server.port=8443"
560
+ ```
561
+
562
+ 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.
563
+
458
564
  ### Useful Commands
459
565
 
460
566
  ```bash
Binary file
Binary file
Binary file
webgate-0.2.2/fly.toml ADDED
@@ -0,0 +1,48 @@
1
+ # Fly.io deployment for the public webgate demo.
2
+ # Edit `app` to your fly app name, then:
3
+ # flyctl launch --no-deploy --copy-config
4
+ # flyctl secrets set WEBGATE_SECRET_KEY=$(openssl rand -hex 32)
5
+ # flyctl deploy
6
+ #
7
+ # Free tier (1x shared-cpu, 256-512 MB) is enough for light demo traffic.
8
+
9
+ app = "webgate-demo"
10
+ primary_region = "cdg" # Paris; closest EU region to Spain
11
+
12
+ [build]
13
+ dockerfile = "Dockerfile.demo"
14
+
15
+ [env]
16
+ WEBGATE_DEMO_MODE = "true"
17
+ WEBGATE_LOG_LEVEL = "info"
18
+
19
+ [http_service]
20
+ internal_port = 8443
21
+ force_https = true
22
+ auto_stop_machines = "stop" # save free-tier hours when idle
23
+ auto_start_machines = true
24
+ min_machines_running = 0
25
+
26
+ [http_service.concurrency]
27
+ type = "requests"
28
+ soft_limit = 50
29
+ hard_limit = 100
30
+
31
+ [[http_service.checks]]
32
+ interval = "30s"
33
+ timeout = "5s"
34
+ grace_period = "15s"
35
+ method = "get"
36
+ path = "/api/health"
37
+
38
+ [[vm]]
39
+ cpu_kind = "shared"
40
+ cpus = 1
41
+ memory_mb = 512
42
+
43
+ # Persistent volume so the demo seed survives restarts. The hourly reset
44
+ # (see scripts/demo-reset.sh) wipes the DB inside the container, not the
45
+ # whole volume, so this stays cheap.
46
+ [mounts]
47
+ source = "webgate_demo_data"
48
+ destination = "/data"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "webgate"
3
- version = "0.2.0"
3
+ version = "0.2.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"
@@ -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]
@@ -1,8 +1,9 @@
1
1
  from collections.abc import AsyncGenerator
2
2
  from contextlib import asynccontextmanager
3
3
 
4
- from fastapi import FastAPI
4
+ from fastapi import FastAPI, Request
5
5
  from fastapi.middleware.cors import CORSMiddleware
6
+ from fastapi.responses import JSONResponse
6
7
  from fastapi.staticfiles import StaticFiles
7
8
  from slowapi import _rate_limit_exceeded_handler
8
9
  from slowapi.errors import RateLimitExceeded
@@ -12,6 +13,7 @@ from webgate.auth.routes import router as auth_router
12
13
  from webgate.auth.service import seed_admin
13
14
  from webgate.config import settings
14
15
  from webgate.db.engine import async_session_factory, close_db, init_db
16
+ from webgate.demo import seed_demo
15
17
  from webgate.files.pool import sftp_pool
16
18
  from webgate.files.routes import router as files_router
17
19
  from webgate.servers.monitor import server_monitor
@@ -24,6 +26,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
24
26
  await init_db()
25
27
  async with async_session_factory() as session:
26
28
  await seed_admin(session)
29
+ if settings.demo_mode:
30
+ await seed_demo(session)
27
31
  await sftp_pool.start()
28
32
  await server_monitor.start()
29
33
  yield
@@ -33,7 +37,12 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
33
37
 
34
38
 
35
39
  def create_app() -> FastAPI:
36
- app = FastAPI(title="webgate", version="0.1.0", lifespan=lifespan)
40
+ app = FastAPI(
41
+ title="webgate",
42
+ version="0.1.0",
43
+ lifespan=lifespan,
44
+ root_path=settings.root_path,
45
+ )
37
46
  app.state.limiter = limiter
38
47
  app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
39
48
 
@@ -50,6 +59,32 @@ def create_app() -> FastAPI:
50
59
  async def health() -> dict[str, str]: # pyright: ignore[reportUnusedFunction]
51
60
  return {"status": "ok"}
52
61
 
62
+ @app.get("/api/config")
63
+ async def public_config() -> dict[str, object]: # pyright: ignore[reportUnusedFunction]
64
+ # Public flags consumed by the frontend before login.
65
+ return {"demo_mode": settings.demo_mode}
66
+
67
+ if settings.demo_mode:
68
+ # In demo mode block any state-changing request on /api/* except login
69
+ # and the WS quick-connect endpoint (no arbitrary SSH targets allowed).
70
+ WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
71
+ WRITE_ALLOWLIST = {"/api/auth/login", "/api/auth/totp/verify"}
72
+
73
+ @app.middleware("http")
74
+ async def _demo_readonly(request: Request, call_next): # pyright: ignore[reportUnusedFunction]
75
+ path = request.url.path
76
+ if request.method in WRITE_METHODS and path.startswith("/api/") and path not in WRITE_ALLOWLIST:
77
+ return JSONResponse(
78
+ status_code=403,
79
+ content={"detail": "Demo mode: write operations are disabled"},
80
+ )
81
+ if path.endswith("/api/ws/terminal/quick"):
82
+ return JSONResponse(
83
+ status_code=403,
84
+ content={"detail": "Demo mode: quick-connect is disabled"},
85
+ )
86
+ return await call_next(request)
87
+
53
88
  app.include_router(auth_router)
54
89
  app.include_router(servers_router)
55
90
  app.include_router(terminal_router)
@@ -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