webgate 0.2.1__tar.gz → 0.3.0__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 (84) hide show
  1. {webgate-0.2.1 → webgate-0.3.0}/CHANGELOG.md +35 -0
  2. webgate-0.3.0/Dockerfile.demo +63 -0
  3. {webgate-0.2.1 → webgate-0.3.0}/PKG-INFO +25 -1
  4. {webgate-0.2.1 → webgate-0.3.0}/README.md +22 -0
  5. {webgate-0.2.1 → webgate-0.3.0}/ROADMAP.md +20 -6
  6. webgate-0.3.0/fly.toml +48 -0
  7. {webgate-0.2.1 → webgate-0.3.0}/pyproject.toml +4 -1
  8. {webgate-0.2.1 → webgate-0.3.0}/src/webgate/app.py +33 -1
  9. {webgate-0.2.1 → webgate-0.3.0}/src/webgate/config.py +1 -0
  10. {webgate-0.2.1 → webgate-0.3.0}/src/webgate/db/engine.py +16 -6
  11. webgate-0.3.0/src/webgate/demo.py +90 -0
  12. {webgate-0.2.1 → webgate-0.3.0}/src/webgate/files/pool.py +13 -1
  13. {webgate-0.2.1 → webgate-0.3.0}/src/webgate/files/routes.py +3 -1
  14. {webgate-0.2.1 → webgate-0.3.0}/src/webgate/servers/models.py +6 -0
  15. {webgate-0.2.1 → webgate-0.3.0}/src/webgate/servers/routes.py +1 -1
  16. {webgate-0.2.1 → webgate-0.3.0}/src/webgate/servers/service.py +50 -2
  17. webgate-0.3.0/src/webgate/snippets/models.py +34 -0
  18. webgate-0.3.0/src/webgate/snippets/routes.py +45 -0
  19. {webgate-0.2.1 → webgate-0.3.0}/src/webgate/static/index.html +51 -5
  20. {webgate-0.2.1 → webgate-0.3.0}/src/webgate/terminal/routes.py +8 -1
  21. {webgate-0.2.1 → webgate-0.3.0}/src/webgate/terminal/ssh_session.py +10 -0
  22. {webgate-0.2.1 → webgate-0.3.0}/src/webgate/terminal/ws_handler.py +2 -0
  23. webgate-0.3.0/tests/__init__.py +0 -0
  24. {webgate-0.2.1 → webgate-0.3.0}/.dockerignore +0 -0
  25. {webgate-0.2.1 → webgate-0.3.0}/.github/workflows/docs.yml +0 -0
  26. {webgate-0.2.1 → webgate-0.3.0}/.gitignore +0 -0
  27. {webgate-0.2.1 → webgate-0.3.0}/Dockerfile +0 -0
  28. {webgate-0.2.1 → webgate-0.3.0}/Dockerfile.ssh-demo +0 -0
  29. {webgate-0.2.1 → webgate-0.3.0}/LICENSE +0 -0
  30. {webgate-0.2.1 → webgate-0.3.0}/VERSION +0 -0
  31. {webgate-0.2.1 → webgate-0.3.0}/compose.dev.yml +0 -0
  32. {webgate-0.2.1 → webgate-0.3.0}/compose.yml +0 -0
  33. {webgate-0.2.1 → webgate-0.3.0}/docs/api/auth.md +0 -0
  34. {webgate-0.2.1 → webgate-0.3.0}/docs/api/files.md +0 -0
  35. {webgate-0.2.1 → webgate-0.3.0}/docs/api/servers.md +0 -0
  36. {webgate-0.2.1 → webgate-0.3.0}/docs/api/terminal.md +0 -0
  37. {webgate-0.2.1 → webgate-0.3.0}/docs/changelog.md +0 -0
  38. {webgate-0.2.1 → webgate-0.3.0}/docs/getting-started/installation.md +0 -0
  39. {webgate-0.2.1 → webgate-0.3.0}/docs/getting-started/quickstart.md +0 -0
  40. {webgate-0.2.1 → webgate-0.3.0}/docs/guide/files.md +0 -0
  41. {webgate-0.2.1 → webgate-0.3.0}/docs/guide/servers.md +0 -0
  42. {webgate-0.2.1 → webgate-0.3.0}/docs/guide/split.md +0 -0
  43. {webgate-0.2.1 → webgate-0.3.0}/docs/guide/terminal.md +0 -0
  44. {webgate-0.2.1 → webgate-0.3.0}/docs/guide/users.md +0 -0
  45. {webgate-0.2.1 → webgate-0.3.0}/docs/index.md +0 -0
  46. {webgate-0.2.1 → webgate-0.3.0}/docs/screenshots/access-control.png +0 -0
  47. {webgate-0.2.1 → webgate-0.3.0}/docs/screenshots/audit.png +0 -0
  48. {webgate-0.2.1 → webgate-0.3.0}/docs/screenshots/edit-access-control.png +0 -0
  49. {webgate-0.2.1 → webgate-0.3.0}/docs/screenshots/editor.png +0 -0
  50. {webgate-0.2.1 → webgate-0.3.0}/docs/screenshots/light-theme.png +0 -0
  51. {webgate-0.2.1 → webgate-0.3.0}/docs/screenshots/login.png +0 -0
  52. {webgate-0.2.1 → webgate-0.3.0}/docs/screenshots/new-server-form.png +0 -0
  53. {webgate-0.2.1 → webgate-0.3.0}/docs/screenshots/sftp-restricted.png +0 -0
  54. {webgate-0.2.1 → webgate-0.3.0}/docs/screenshots/sftp.png +0 -0
  55. {webgate-0.2.1 → webgate-0.3.0}/docs/screenshots/site-manager.png +0 -0
  56. {webgate-0.2.1 → webgate-0.3.0}/docs/screenshots/split-view.png +0 -0
  57. {webgate-0.2.1 → webgate-0.3.0}/docs/screenshots/ssh-disabled.png +0 -0
  58. {webgate-0.2.1 → webgate-0.3.0}/docs/screenshots/terminal.png +0 -0
  59. {webgate-0.2.1 → webgate-0.3.0}/docs/screenshots/users.png +0 -0
  60. {webgate-0.2.1 → webgate-0.3.0}/mkdocs.yml +0 -0
  61. {webgate-0.2.1 → webgate-0.3.0}/src/webgate/__init__.py +0 -0
  62. {webgate-0.2.1 → webgate-0.3.0}/src/webgate/__main__.py +0 -0
  63. {webgate-0.2.1 → webgate-0.3.0}/src/webgate/audit/__init__.py +0 -0
  64. {webgate-0.2.1 → webgate-0.3.0}/src/webgate/audit/models.py +0 -0
  65. {webgate-0.2.1 → webgate-0.3.0}/src/webgate/audit/service.py +0 -0
  66. {webgate-0.2.1 → webgate-0.3.0}/src/webgate/auth/__init__.py +0 -0
  67. {webgate-0.2.1 → webgate-0.3.0}/src/webgate/auth/models.py +0 -0
  68. {webgate-0.2.1 → webgate-0.3.0}/src/webgate/auth/routes.py +0 -0
  69. {webgate-0.2.1 → webgate-0.3.0}/src/webgate/auth/service.py +0 -0
  70. {webgate-0.2.1 → webgate-0.3.0}/src/webgate/db/__init__.py +0 -0
  71. {webgate-0.2.1 → webgate-0.3.0}/src/webgate/files/__init__.py +0 -0
  72. {webgate-0.2.1 → webgate-0.3.0}/src/webgate/files/models.py +0 -0
  73. {webgate-0.2.1 → webgate-0.3.0}/src/webgate/files/sftp_service.py +0 -0
  74. {webgate-0.2.1 → webgate-0.3.0}/src/webgate/servers/__init__.py +0 -0
  75. {webgate-0.2.1 → webgate-0.3.0}/src/webgate/servers/crypto.py +0 -0
  76. {webgate-0.2.1 → webgate-0.3.0}/src/webgate/servers/monitor.py +0 -0
  77. {webgate-0.2.1/src/webgate/terminal → webgate-0.3.0/src/webgate/snippets}/__init__.py +0 -0
  78. {webgate-0.2.1/tests → webgate-0.3.0/src/webgate/terminal}/__init__.py +0 -0
  79. {webgate-0.2.1 → webgate-0.3.0}/tests/conftest.py +0 -0
  80. {webgate-0.2.1 → webgate-0.3.0}/tests/test_auth.py +0 -0
  81. {webgate-0.2.1 → webgate-0.3.0}/tests/test_files.py +0 -0
  82. {webgate-0.2.1 → webgate-0.3.0}/tests/test_servers.py +0 -0
  83. {webgate-0.2.1 → webgate-0.3.0}/tests/test_terminal.py +0 -0
  84. {webgate-0.2.1 → webgate-0.3.0}/uv.lock +0 -0
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.3.0 (2026-04-15)
4
+
5
+ ### Features
6
+
7
+ - **SSH jump host / bastion** -- per-server `jump_via_id` field. Webgate opens a tunneled SSH connection through the bastion using `asyncssh`'s `tunnel=` parameter. Works for both the terminal WebSocket and the SFTP browser. Solves the common "internal servers reachable only through one public bastion" scenario.
8
+ - **SSH command snippets** -- per-user library of named commands. Click a snippet button in the terminal toolbar to send the command (with Enter) to the active session. Right-click to delete, `+` to create.
9
+ - **PostgreSQL support** -- install with `pip install 'webgate[postgres]'` and set `WEBGATE_DB_URL=postgresql+asyncpg://...`. SQLite remains the default. Lightweight migrations are now dialect-aware.
10
+
11
+ ### Details
12
+
13
+ - New `Server.jump_via_id` (FK to `servers.id`, nullable) + `resolve_jump_creds()` helper
14
+ - New `Snippet` model + REST CRUD at `/api/snippets`
15
+ - Frontend: dropdown to pick a jump host in the Add/Edit Server modal; `↺` badge in the server card when a jump is configured
16
+ - Frontend: snippet toolbar in the terminal tab (hidden when there are no snippets)
17
+ - Demo seed pre-populates `bastion` + `internal-app` (jump-host pair) and 4 example snippets
18
+
19
+ ---
20
+
21
+ ## v0.2.2 (2026-04-15)
22
+
23
+ ### Features
24
+
25
+ - **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.
26
+ - **`Dockerfile.demo`** -- single-container image bundling webgate + a sandboxed `sshd` target via supervisord. Ready for free hosting tiers.
27
+ - **`fly.toml`** -- Fly.io configuration for one-command demo deployments (`flyctl deploy`).
28
+
29
+ ### Details
30
+
31
+ - New public endpoint `GET /api/config` exposes `{"demo_mode": bool}` for the frontend to render the banner before login
32
+ - `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`)
33
+ - Demo seed (`webgate.demo`) is idempotent and only runs when the flag is on
34
+ - Hourly state reset for the public demo can be done with a cron pinging the container restart, so DB returns to seed state
35
+
36
+ ---
37
+
3
38
  ## v0.2.1 (2026-04-15)
4
39
 
5
40
  ### 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.1
3
+ Version: 0.3.0
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/
@@ -42,6 +42,8 @@ Requires-Dist: slowapi>=0.1.9
42
42
  Requires-Dist: sqlalchemy>=2.0.36
43
43
  Requires-Dist: uvicorn[standard]>=0.34.0
44
44
  Requires-Dist: websockets>=14.0
45
+ Provides-Extra: postgres
46
+ Requires-Dist: asyncpg>=0.30.0; extra == 'postgres'
45
47
  Description-Content-Type: text/markdown
46
48
 
47
49
  # webgate
@@ -58,6 +60,8 @@ Description-Content-Type: text/markdown
58
60
  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.
59
61
 
60
62
  > **Documentation: [kalexnolasco.github.io/webgate](https://kalexnolasco.github.io/webgate/)**
63
+ >
64
+ > **🎮 Live demo: [webgate-demo.fly.dev](https://webgate-demo.fly.dev/)** — login `demo` / `demo` (read-only, resets hourly)
61
65
 
62
66
  ---
63
67
 
@@ -449,6 +453,26 @@ All settings are configurable via environment variables with the `WEBGATE_` pref
449
453
  | `WEBGATE_DB_URL` | `sqlite+aiosqlite:///./webgate.db` | Database URL |
450
454
  | `WEBGATE_ALLOWED_ORIGINS` | `*` | CORS origins (comma-separated) |
451
455
  | `WEBGATE_ROOT_PATH` | `` (empty) | URL prefix when served behind a reverse proxy at a sub-path (e.g. `/webgate`) |
456
+ | `WEBGATE_DEMO_MODE` | `false` | Read-only public demo: blocks all writes, seeds a `demo`/`demo` user and shows a banner |
457
+
458
+ ### PostgreSQL
459
+
460
+ SQLite is the default. To use PostgreSQL instead:
461
+
462
+ ```bash
463
+ pip install 'webgate[postgres]' # adds asyncpg
464
+ export WEBGATE_DB_URL='postgresql+asyncpg://user:pass@host:5432/webgate'
465
+ ```
466
+
467
+ Lightweight migrations are dialect-aware. Schema is created automatically on first start.
468
+
469
+ ### Jump host / bastion
470
+
471
+ When adding a server, pick another server from the **Jump Via** dropdown to tunnel the SSH and SFTP connections through it. Useful when the target server is only reachable from a specific bastion. Stored as `jump_via_id` (FK to another `Server` row); `asyncssh`'s native `tunnel=` parameter does the actual chaining.
472
+
473
+ ### SSH command snippets
474
+
475
+ 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.
452
476
  | `WEBGATE_LOG_LEVEL` | `info` | Log level |
453
477
  | `WEBGATE_SESSION_TIMEOUT` | `3600` | SSH session timeout (seconds) |
454
478
  | `WEBGATE_MAX_UPLOAD_SIZE` | `104857600` | Max upload size (100 MB) |
@@ -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
 
@@ -403,6 +405,26 @@ All settings are configurable via environment variables with the `WEBGATE_` pref
403
405
  | `WEBGATE_DB_URL` | `sqlite+aiosqlite:///./webgate.db` | Database URL |
404
406
  | `WEBGATE_ALLOWED_ORIGINS` | `*` | CORS origins (comma-separated) |
405
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 |
409
+
410
+ ### PostgreSQL
411
+
412
+ SQLite is the default. To use PostgreSQL instead:
413
+
414
+ ```bash
415
+ pip install 'webgate[postgres]' # adds asyncpg
416
+ export WEBGATE_DB_URL='postgresql+asyncpg://user:pass@host:5432/webgate'
417
+ ```
418
+
419
+ Lightweight migrations are dialect-aware. Schema is created automatically on first start.
420
+
421
+ ### Jump host / bastion
422
+
423
+ When adding a server, pick another server from the **Jump Via** dropdown to tunnel the SSH and SFTP connections through it. Useful when the target server is only reachable from a specific bastion. Stored as `jump_via_id` (FK to another `Server` row); `asyncssh`'s native `tunnel=` parameter does the actual chaining.
424
+
425
+ ### SSH command snippets
426
+
427
+ 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.
406
428
  | `WEBGATE_LOG_LEVEL` | `info` | Log level |
407
429
  | `WEBGATE_SESSION_TIMEOUT` | `3600` | SSH session timeout (seconds) |
408
430
  | `WEBGATE_MAX_UPLOAD_SIZE` | `104857600` | Max upload size (100 MB) |
@@ -45,25 +45,39 @@ This document tracks the development plan for **webgate**. Items are organized b
45
45
  - [x] Drag & drop upload progress bar (XHR with progress events, percentage display)
46
46
  - [x] Folder download as ZIP (server-side zip compression, right-click context menu)
47
47
 
48
- ## v0.3.0 -- Collaboration & Operations
48
+ ## v0.2.1 -- Reverse Proxy Sub-Path (Released 2026-04-15)
49
+
50
+ - [x] Serve webgate behind a reverse proxy at any URL prefix (`WEBGATE_ROOT_PATH`)
51
+ - [x] nginx, Apache and Traefik examples in README
52
+
53
+ ## v0.2.2 -- Demo Mode (Released 2026-04-15)
54
+
55
+ - [x] `WEBGATE_DEMO_MODE` middleware blocks writes for public read-only deployments
56
+ - [x] `Dockerfile.demo` (webgate + sshd via supervisord) and `fly.toml` for one-command Fly.io deploys
57
+ - [x] Live demo at https://webgate-demo.fly.dev/
58
+
59
+ ## v0.3.0 -- Operations Pack (Released 2026-04-15)
60
+
61
+ - [x] **SSH jump host / bastion** (per-server `jump_via_id`, asyncssh tunneling)
62
+ - [x] **SSH command snippets** (per-user library, terminal toolbar, click to send)
63
+ - [x] **PostgreSQL support** (`pip install 'webgate[postgres]'`, dialect-aware migrations)
64
+ - [x] Two-factor authentication (TOTP) -- already in v0.2.x
65
+ - [x] API key authentication -- already in v0.2.x
66
+
67
+ ## v0.3.x -- Planned
49
68
 
50
69
  | Feature | Priority | Description |
51
70
  |---------|----------|-------------|
52
71
  | Shared terminal sessions | High | Multiple users can watch/interact with the same SSH session in real time |
53
72
  | Session recording & playback | High | Record terminal sessions for audit trail and training; replay in browser |
54
- | SSH command snippets | Medium | Save and execute common commands per server or globally; share across team |
55
73
  | Webhook notifications | Medium | Fire webhooks on events: user login, SSH connect, file upload, server added |
56
- | Two-factor authentication (TOTP) | High | Optional TOTP (Google Authenticator, Authy) for user accounts |
57
74
  | LDAP / Active Directory | Medium | Authenticate users against corporate LDAP/AD; auto-map groups |
58
- | API key authentication | Low | Generate API keys for programmatic access (automation, scripts, CI/CD) |
59
75
 
60
76
  ## v0.4.0 -- Enterprise & Scale
61
77
 
62
78
  | Feature | Priority | Description |
63
79
  |---------|----------|-------------|
64
- | PostgreSQL support | High | Test, document, and CI-verify PostgreSQL as alternative to SQLite |
65
80
  | Multi-instance deployment | Medium | Multiple webgate instances sharing a single database (stateless workers behind LB) |
66
- | SSH jump host / bastion | High | Connect through a bastion/jump host to reach servers not directly reachable |
67
81
  | Custom branding | Low | Configurable logo, application name, and color scheme |
68
82
  | Backup/restore UI | Medium | Export/import full application state (users, servers, audit log) from admin panel |
69
83
  | Internationalization (i18n) | Low | Multi-language support for the UI (starting with English and Spanish) |
webgate-0.3.0/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.1"
3
+ version = "0.3.0"
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"
@@ -42,6 +42,9 @@ dependencies = [
42
42
  "qrcode[pil]>=8.2",
43
43
  ]
44
44
 
45
+ [project.optional-dependencies]
46
+ postgres = ["asyncpg>=0.30.0"]
47
+
45
48
  [project.urls]
46
49
  Homepage = "https://github.com/kalexnolasco/webgate"
47
50
  Documentation = "https://kalexnolasco.github.io/webgate/"
@@ -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,10 +13,12 @@ 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
18
20
  from webgate.servers.routes import router as servers_router
21
+ from webgate.snippets.routes import router as snippets_router
19
22
  from webgate.terminal.routes import router as terminal_router
20
23
 
21
24
 
@@ -24,6 +27,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
24
27
  await init_db()
25
28
  async with async_session_factory() as session:
26
29
  await seed_admin(session)
30
+ if settings.demo_mode:
31
+ await seed_demo(session)
27
32
  await sftp_pool.start()
28
33
  await server_monitor.start()
29
34
  yield
@@ -55,10 +60,37 @@ def create_app() -> FastAPI:
55
60
  async def health() -> dict[str, str]: # pyright: ignore[reportUnusedFunction]
56
61
  return {"status": "ok"}
57
62
 
63
+ @app.get("/api/config")
64
+ async def public_config() -> dict[str, object]: # pyright: ignore[reportUnusedFunction]
65
+ # Public flags consumed by the frontend before login.
66
+ return {"demo_mode": settings.demo_mode}
67
+
68
+ if settings.demo_mode:
69
+ # In demo mode block any state-changing request on /api/* except login
70
+ # and the WS quick-connect endpoint (no arbitrary SSH targets allowed).
71
+ WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
72
+ WRITE_ALLOWLIST = {"/api/auth/login", "/api/auth/totp/verify"}
73
+
74
+ @app.middleware("http")
75
+ async def _demo_readonly(request: Request, call_next): # pyright: ignore[reportUnusedFunction]
76
+ path = request.url.path
77
+ if request.method in WRITE_METHODS and path.startswith("/api/") and path not in WRITE_ALLOWLIST:
78
+ return JSONResponse(
79
+ status_code=403,
80
+ content={"detail": "Demo mode: write operations are disabled"},
81
+ )
82
+ if path.endswith("/api/ws/terminal/quick"):
83
+ return JSONResponse(
84
+ status_code=403,
85
+ content={"detail": "Demo mode: quick-connect is disabled"},
86
+ )
87
+ return await call_next(request)
88
+
58
89
  app.include_router(auth_router)
59
90
  app.include_router(servers_router)
60
91
  app.include_router(terminal_router)
61
92
  app.include_router(files_router)
93
+ app.include_router(snippets_router)
62
94
 
63
95
  app.mount("/", StaticFiles(directory=str(settings.static_dir), html=True), name="static")
64
96
 
@@ -12,6 +12,7 @@ class Settings(BaseSettings):
12
12
  db_url: str = "sqlite+aiosqlite:///./webgate.db"
13
13
  allowed_origins: str = "*"
14
14
  root_path: str = "" # URL prefix when served behind a reverse proxy (e.g. "/webgate")
15
+ demo_mode: bool = False # Read-only public demo: blocks writes, hides admin UI
15
16
  log_level: str = "info"
16
17
  session_timeout: int = 3600
17
18
  max_upload_size: int = 104857600 # 100MB
@@ -30,21 +30,31 @@ async def get_session() -> AsyncGenerator[AsyncSession]:
30
30
  yield session
31
31
 
32
32
 
33
- _MIGRATIONS = [
34
- "ALTER TABLE servers ADD COLUMN sftp_read_only BOOLEAN DEFAULT 0",
35
- "ALTER TABLE users ADD COLUMN totp_secret VARCHAR(255) DEFAULT ''",
36
- "ALTER TABLE users ADD COLUMN totp_enabled BOOLEAN DEFAULT 0",
33
+ # (table, column, sqlite_def, postgres_def)
34
+ _MIGRATIONS: list[tuple[str, str, str, str]] = [
35
+ ("servers", "sftp_read_only", "BOOLEAN DEFAULT 0", "BOOLEAN DEFAULT FALSE"),
36
+ ("users", "totp_secret", "VARCHAR(255) DEFAULT ''", "VARCHAR(255) DEFAULT ''"),
37
+ ("users", "totp_enabled", "BOOLEAN DEFAULT 0", "BOOLEAN DEFAULT FALSE"),
38
+ (
39
+ "servers",
40
+ "jump_via_id",
41
+ "INTEGER REFERENCES servers(id)",
42
+ "INTEGER REFERENCES servers(id)",
43
+ ),
37
44
  ]
38
45
 
39
46
 
40
47
  async def init_db() -> None:
48
+ dialect = engine.dialect.name # "sqlite", "postgresql", ...
41
49
  async with engine.begin() as conn:
42
50
  await conn.run_sync(Base.metadata.create_all)
43
51
  # Lightweight migrations for columns added after initial release
44
- for sql in _MIGRATIONS:
52
+ for table, column, sqlite_def, pg_def in _MIGRATIONS:
53
+ col_def = pg_def if dialect == "postgresql" else sqlite_def
54
+ sql = f"ALTER TABLE {table} ADD COLUMN {column} {col_def}"
45
55
  try:
46
56
  await conn.execute(text(sql))
47
- logger.info("Migration applied: %s", sql.split("ADD COLUMN ")[1].split()[0])
57
+ logger.info("Migration applied: %s.%s", table, column)
48
58
  except Exception:
49
59
  pass # Column already exists
50
60
 
@@ -0,0 +1,90 @@
1
+ """Demo mode seeding: pre-populates the demo user and showcase servers.
2
+
3
+ Activated when WEBGATE_DEMO_MODE=true. Idempotent — safe to call on every startup.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import logging
10
+
11
+ from sqlalchemy.ext.asyncio import AsyncSession
12
+
13
+ from webgate.auth.service import create_user, get_user_by_username
14
+ from sqlalchemy import select
15
+
16
+ from webgate.servers.models import ServerCreate
17
+ from webgate.servers.service import create_server, list_servers
18
+ from webgate.snippets.models import Snippet
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ DEMO_USERNAME = "demo"
24
+ DEMO_PASSWORD = "demo"
25
+
26
+ def _bastion() -> ServerCreate:
27
+ return ServerCreate(
28
+ name="bastion",
29
+ hostname="127.0.0.1",
30
+ port=22,
31
+ username="demo",
32
+ password="demo",
33
+ group="demo",
34
+ tags=["demo", "bastion"],
35
+ description="Public SSH bastion. Other servers proxy through this one.",
36
+ sftp_read_only=True,
37
+ )
38
+
39
+
40
+ def _internal(jump_via_id: int) -> ServerCreate:
41
+ return ServerCreate(
42
+ name="internal-app",
43
+ hostname="127.0.0.1",
44
+ port=22,
45
+ username="demo",
46
+ password="demo",
47
+ group="demo",
48
+ tags=["demo", "internal"],
49
+ description="App server reachable only via the bastion (jump-host demo).",
50
+ sftp_read_only=True,
51
+ jump_via_id=jump_via_id,
52
+ )
53
+
54
+
55
+ async def seed_demo(session: AsyncSession) -> None:
56
+ user = await get_user_by_username(session, DEMO_USERNAME)
57
+ if user is None:
58
+ user = await create_user(session, DEMO_USERNAME, DEMO_PASSWORD, is_admin=False)
59
+ user.must_change_password = False
60
+ user.allowed_groups = json.dumps(["demo"])
61
+ await session.commit()
62
+ logger.info("Created demo user (%s/%s)", DEMO_USERNAME, DEMO_PASSWORD)
63
+
64
+ existing = await list_servers(session, user.id, is_admin=True)
65
+ existing_names = {s.name for s in existing}
66
+
67
+ bastion_id: int | None = next((s.id for s in existing if s.name == "bastion"), None)
68
+ if "bastion" not in existing_names:
69
+ bastion = await create_server(session, _bastion(), user.id)
70
+ bastion_id = bastion.id
71
+ logger.info("Seeded demo bastion (id=%s)", bastion_id)
72
+
73
+ if "internal-app" not in existing_names and bastion_id is not None:
74
+ await create_server(session, _internal(bastion_id), user.id)
75
+ logger.info("Seeded internal-app via bastion id=%s", bastion_id)
76
+
77
+ # Seed a few example snippets so the toolbar isn't empty
78
+ existing_snippets = (
79
+ await session.execute(select(Snippet).where(Snippet.user_id == user.id))
80
+ ).scalars().all()
81
+ if not existing_snippets:
82
+ for name, cmd, desc in [
83
+ ("ls -lah", "ls -lah", "List files (long, human sizes, hidden)"),
84
+ ("disk usage", "df -h", "Mounted filesystems with sizes"),
85
+ ("top procs", "ps aux --sort=-%mem | head", "Top memory-consuming processes"),
86
+ ("uptime", "uptime && uname -a", "Uptime + kernel info"),
87
+ ]:
88
+ session.add(Snippet(name=name, command=cmd, description=desc, user_id=user.id))
89
+ await session.commit()
90
+ logger.info("Seeded %d demo snippets", 4)
@@ -25,6 +25,7 @@ class _PoolEntry:
25
25
  client: SFTPClient
26
26
  last_used: float = field(default_factory=time.monotonic)
27
27
  in_use: int = 0
28
+ jump_conn: asyncssh.SSHClientConnection | None = None
28
29
 
29
30
 
30
31
  class SFTPPool:
@@ -63,6 +64,7 @@ class SFTPPool:
63
64
  username: str,
64
65
  password: str | None = None,
65
66
  private_key: str | None = None,
67
+ jump_kwargs: dict[str, object] | None = None,
66
68
  ) -> SFTPClient:
67
69
  key = self._key(server_id)
68
70
  if key not in self._locks:
@@ -96,10 +98,15 @@ class SFTPPool:
96
98
  elif password:
97
99
  kwargs["password"] = password
98
100
 
101
+ jump_conn: asyncssh.SSHClientConnection | None = None
102
+ if jump_kwargs:
103
+ jump_conn = await asyncssh.connect(**jump_kwargs) # type: ignore[arg-type]
104
+ kwargs["tunnel"] = jump_conn
105
+
99
106
  conn = await asyncssh.connect(**kwargs) # type: ignore[arg-type]
100
107
  client = SFTPClient(conn)
101
108
  await client.connect()
102
- entry = _PoolEntry(conn=conn, client=client, in_use=1)
109
+ entry = _PoolEntry(conn=conn, client=client, in_use=1, jump_conn=jump_conn)
103
110
  self._pool[key] = entry
104
111
  logger.info("SFTP pool: new connection to %s:%s (server_id=%s)", hostname, port, server_id)
105
112
  return client
@@ -120,6 +127,11 @@ class SFTPPool:
120
127
  entry.conn.close()
121
128
  except Exception:
122
129
  pass
130
+ if entry.jump_conn is not None:
131
+ try:
132
+ entry.jump_conn.close()
133
+ except Exception:
134
+ pass
123
135
 
124
136
  async def _cleanup_loop(self) -> None:
125
137
  while True:
@@ -25,7 +25,7 @@ from webgate.files.models import (
25
25
  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
- from webgate.servers.service import get_server, get_server_credentials
28
+ from webgate.servers.service import get_server, get_server_credentials, resolve_jump_creds
29
29
 
30
30
  router = APIRouter(prefix="/api/files", tags=["files"])
31
31
 
@@ -88,6 +88,7 @@ async def _sftp(
88
88
  allowed_paths = _get_allowed_paths(server)
89
89
  read_only = server.sftp_read_only
90
90
  password, private_key = get_server_credentials(server)
91
+ jump_kwargs = await resolve_jump_creds(session, server)
91
92
  try:
92
93
  client = await sftp_pool.acquire(
93
94
  server_id,
@@ -96,6 +97,7 @@ async def _sftp(
96
97
  username=server.username,
97
98
  password=password,
98
99
  private_key=private_key,
100
+ jump_kwargs=jump_kwargs,
99
101
  )
100
102
  yield client, allowed_paths, read_only
101
103
  finally:
@@ -25,6 +25,9 @@ class Server(Base):
25
25
  sftp_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
26
26
  sftp_allowed_paths: Mapped[str] = mapped_column(Text, default="[]") # JSON array of paths
27
27
  sftp_read_only: Mapped[bool] = mapped_column(Boolean, default=False)
28
+ jump_via_id: Mapped[int | None] = mapped_column(
29
+ Integer, ForeignKey("servers.id"), nullable=True
30
+ )
28
31
  last_connected_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
29
32
  created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
30
33
  user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"))
@@ -45,6 +48,7 @@ class ServerCreate(BaseModel):
45
48
  sftp_enabled: bool = True
46
49
  sftp_allowed_paths: list[str] = []
47
50
  sftp_read_only: bool = False
51
+ jump_via_id: int | None = None
48
52
 
49
53
 
50
54
  class ServerUpdate(BaseModel):
@@ -62,6 +66,7 @@ class ServerUpdate(BaseModel):
62
66
  sftp_enabled: bool | None = None
63
67
  sftp_allowed_paths: list[str] | None = None
64
68
  sftp_read_only: bool | None = None
69
+ jump_via_id: int | None = None
65
70
 
66
71
 
67
72
  class ServerOut(BaseModel):
@@ -78,6 +83,7 @@ class ServerOut(BaseModel):
78
83
  sftp_enabled: bool
79
84
  sftp_allowed_paths: list[str]
80
85
  sftp_read_only: bool
86
+ jump_via_id: int | None = None
81
87
  last_connected_at: datetime | None
82
88
  created_at: datetime
83
89
 
@@ -154,7 +154,7 @@ async def test_connectivity(
154
154
  server = await get_server(session, server_id, current_user.id, **_user_kwargs(current_user))
155
155
  if not server:
156
156
  raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Server not found")
157
- success, message = await test_server_connectivity(server)
157
+ success, message = await test_server_connectivity(server, session)
158
158
  if success:
159
159
  await update_last_connected(session, server)
160
160
  return {"success": success, "message": message}