webgate 0.2.2__tar.gz → 0.3.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.2 → webgate-0.3.1}/CHANGELOG.md +30 -0
- {webgate-0.2.2 → webgate-0.3.1}/PKG-INFO +20 -1
- {webgate-0.2.2 → webgate-0.3.1}/README.md +18 -0
- {webgate-0.2.2 → webgate-0.3.1}/ROADMAP.md +20 -6
- {webgate-0.2.2 → webgate-0.3.1}/pyproject.toml +2 -1
- {webgate-0.2.2 → webgate-0.3.1}/src/webgate/app.py +2 -0
- webgate-0.3.1/src/webgate/db/engine.py +67 -0
- webgate-0.3.1/src/webgate/demo.py +90 -0
- {webgate-0.2.2 → webgate-0.3.1}/src/webgate/files/pool.py +13 -1
- {webgate-0.2.2 → webgate-0.3.1}/src/webgate/files/routes.py +3 -1
- {webgate-0.2.2 → webgate-0.3.1}/src/webgate/servers/models.py +6 -0
- {webgate-0.2.2 → webgate-0.3.1}/src/webgate/servers/routes.py +1 -1
- {webgate-0.2.2 → webgate-0.3.1}/src/webgate/servers/service.py +50 -2
- webgate-0.3.1/src/webgate/snippets/models.py +34 -0
- webgate-0.3.1/src/webgate/snippets/routes.py +45 -0
- {webgate-0.2.2 → webgate-0.3.1}/src/webgate/static/index.html +40 -5
- {webgate-0.2.2 → webgate-0.3.1}/src/webgate/terminal/routes.py +8 -1
- {webgate-0.2.2 → webgate-0.3.1}/src/webgate/terminal/ssh_session.py +10 -0
- {webgate-0.2.2 → webgate-0.3.1}/src/webgate/terminal/ws_handler.py +2 -0
- webgate-0.3.1/tests/__init__.py +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/uv.lock +51 -1
- webgate-0.2.2/src/webgate/db/engine.py +0 -53
- webgate-0.2.2/src/webgate/demo.py +0 -55
- {webgate-0.2.2 → webgate-0.3.1}/.dockerignore +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/.github/workflows/docs.yml +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/.gitignore +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/Dockerfile +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/Dockerfile.demo +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/Dockerfile.ssh-demo +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/LICENSE +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/VERSION +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/compose.dev.yml +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/compose.yml +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/docs/api/auth.md +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/docs/api/files.md +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/docs/api/servers.md +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/docs/api/terminal.md +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/docs/changelog.md +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/docs/getting-started/installation.md +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/docs/getting-started/quickstart.md +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/docs/guide/files.md +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/docs/guide/servers.md +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/docs/guide/split.md +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/docs/guide/terminal.md +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/docs/guide/users.md +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/docs/index.md +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/docs/screenshots/access-control.png +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/docs/screenshots/audit.png +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/docs/screenshots/edit-access-control.png +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/docs/screenshots/editor.png +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/docs/screenshots/light-theme.png +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/docs/screenshots/login.png +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/docs/screenshots/new-server-form.png +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/docs/screenshots/sftp-restricted.png +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/docs/screenshots/sftp.png +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/docs/screenshots/site-manager.png +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/docs/screenshots/split-view.png +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/docs/screenshots/ssh-disabled.png +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/docs/screenshots/terminal.png +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/docs/screenshots/users.png +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/fly.toml +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/mkdocs.yml +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/src/webgate/__init__.py +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/src/webgate/__main__.py +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/src/webgate/audit/__init__.py +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/src/webgate/audit/models.py +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/src/webgate/audit/service.py +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/src/webgate/auth/__init__.py +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/src/webgate/auth/models.py +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/src/webgate/auth/routes.py +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/src/webgate/auth/service.py +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/src/webgate/config.py +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/src/webgate/db/__init__.py +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/src/webgate/files/__init__.py +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/src/webgate/files/models.py +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/src/webgate/files/sftp_service.py +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/src/webgate/servers/__init__.py +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/src/webgate/servers/crypto.py +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/src/webgate/servers/monitor.py +0 -0
- {webgate-0.2.2/src/webgate/terminal → webgate-0.3.1/src/webgate/snippets}/__init__.py +0 -0
- {webgate-0.2.2/tests → webgate-0.3.1/src/webgate/terminal}/__init__.py +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/tests/conftest.py +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/tests/test_auth.py +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/tests/test_files.py +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/tests/test_servers.py +0 -0
- {webgate-0.2.2 → webgate-0.3.1}/tests/test_terminal.py +0 -0
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.3.1 (2026-04-15)
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **PostgreSQL actually works in Docker now.** v0.3.0 advertised PostgreSQL support but two bugs prevented it from working:
|
|
8
|
+
1. `asyncpg` was an optional extra (`webgate[postgres]`), so the official Docker image (and `pip install webgate`) didn't have the driver. Now bundled by default.
|
|
9
|
+
2. Lightweight migrations ran inside the same transaction as `create_all`. PostgreSQL aborts the entire transaction on any error (even when caught), so the first "column already exists" error rolled back table creation on subsequent runs. Each migration now uses its own transaction.
|
|
10
|
+
|
|
11
|
+
Verified end-to-end against a real `postgres:16-alpine` container: tables created, login works, server creation and persistence across restarts confirmed.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## v0.3.0 (2026-04-15)
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
- **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.
|
|
20
|
+
- **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.
|
|
21
|
+
- **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.
|
|
22
|
+
|
|
23
|
+
### Details
|
|
24
|
+
|
|
25
|
+
- New `Server.jump_via_id` (FK to `servers.id`, nullable) + `resolve_jump_creds()` helper
|
|
26
|
+
- New `Snippet` model + REST CRUD at `/api/snippets`
|
|
27
|
+
- Frontend: dropdown to pick a jump host in the Add/Edit Server modal; `↺` badge in the server card when a jump is configured
|
|
28
|
+
- Frontend: snippet toolbar in the terminal tab (hidden when there are no snippets)
|
|
29
|
+
- Demo seed pre-populates `bastion` + `internal-app` (jump-host pair) and 4 example snippets
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
3
33
|
## v0.2.2 (2026-04-15)
|
|
4
34
|
|
|
5
35
|
### Features
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: webgate
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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/
|
|
@@ -27,6 +27,7 @@ Classifier: Typing :: Typed
|
|
|
27
27
|
Requires-Python: >=3.11
|
|
28
28
|
Requires-Dist: aiofiles>=24.1.0
|
|
29
29
|
Requires-Dist: aiosqlite>=0.21.0
|
|
30
|
+
Requires-Dist: asyncpg>=0.30.0
|
|
30
31
|
Requires-Dist: asyncssh>=2.18.0
|
|
31
32
|
Requires-Dist: bcrypt>=4.2.0
|
|
32
33
|
Requires-Dist: cryptography>=44.0.0
|
|
@@ -452,6 +453,24 @@ All settings are configurable via environment variables with the `WEBGATE_` pref
|
|
|
452
453
|
| `WEBGATE_ALLOWED_ORIGINS` | `*` | CORS origins (comma-separated) |
|
|
453
454
|
| `WEBGATE_ROOT_PATH` | `` (empty) | URL prefix when served behind a reverse proxy at a sub-path (e.g. `/webgate`) |
|
|
454
455
|
| `WEBGATE_DEMO_MODE` | `false` | Read-only public demo: blocks all writes, seeds a `demo`/`demo` user and shows a banner |
|
|
456
|
+
|
|
457
|
+
### PostgreSQL
|
|
458
|
+
|
|
459
|
+
SQLite is the default. To use PostgreSQL, just point `WEBGATE_DB_URL` at it (the `asyncpg` driver is bundled as of v0.3.1):
|
|
460
|
+
|
|
461
|
+
```bash
|
|
462
|
+
export WEBGATE_DB_URL='postgresql+asyncpg://user:pass@host:5432/webgate'
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
Lightweight migrations are dialect-aware. Schema is created automatically on first start.
|
|
466
|
+
|
|
467
|
+
### Jump host / bastion
|
|
468
|
+
|
|
469
|
+
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.
|
|
470
|
+
|
|
471
|
+
### SSH command snippets
|
|
472
|
+
|
|
473
|
+
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.
|
|
455
474
|
| `WEBGATE_LOG_LEVEL` | `info` | Log level |
|
|
456
475
|
| `WEBGATE_SESSION_TIMEOUT` | `3600` | SSH session timeout (seconds) |
|
|
457
476
|
| `WEBGATE_MAX_UPLOAD_SIZE` | `104857600` | Max upload size (100 MB) |
|
|
@@ -406,6 +406,24 @@ All settings are configurable via environment variables with the `WEBGATE_` pref
|
|
|
406
406
|
| `WEBGATE_ALLOWED_ORIGINS` | `*` | CORS origins (comma-separated) |
|
|
407
407
|
| `WEBGATE_ROOT_PATH` | `` (empty) | URL prefix when served behind a reverse proxy at a sub-path (e.g. `/webgate`) |
|
|
408
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, just point `WEBGATE_DB_URL` at it (the `asyncpg` driver is bundled as of v0.3.1):
|
|
413
|
+
|
|
414
|
+
```bash
|
|
415
|
+
export WEBGATE_DB_URL='postgresql+asyncpg://user:pass@host:5432/webgate'
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
Lightweight migrations are dialect-aware. Schema is created automatically on first start.
|
|
419
|
+
|
|
420
|
+
### Jump host / bastion
|
|
421
|
+
|
|
422
|
+
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.
|
|
423
|
+
|
|
424
|
+
### SSH command snippets
|
|
425
|
+
|
|
426
|
+
Each user has a personal library of named commands. They appear as buttons in the terminal toolbar -- click to send `command + Enter`. Right-click to delete, `+` to create. Stored in the `snippets` table.
|
|
409
427
|
| `WEBGATE_LOG_LEVEL` | `info` | Log level |
|
|
410
428
|
| `WEBGATE_SESSION_TIMEOUT` | `3600` | SSH session timeout (seconds) |
|
|
411
429
|
| `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.
|
|
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) |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "webgate"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.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"
|
|
@@ -40,6 +40,7 @@ dependencies = [
|
|
|
40
40
|
"slowapi>=0.1.9",
|
|
41
41
|
"pyotp>=2.9.0",
|
|
42
42
|
"qrcode[pil]>=8.2",
|
|
43
|
+
"asyncpg>=0.30.0",
|
|
43
44
|
]
|
|
44
45
|
|
|
45
46
|
[project.urls]
|
|
@@ -18,6 +18,7 @@ from webgate.files.pool import sftp_pool
|
|
|
18
18
|
from webgate.files.routes import router as files_router
|
|
19
19
|
from webgate.servers.monitor import server_monitor
|
|
20
20
|
from webgate.servers.routes import router as servers_router
|
|
21
|
+
from webgate.snippets.routes import router as snippets_router
|
|
21
22
|
from webgate.terminal.routes import router as terminal_router
|
|
22
23
|
|
|
23
24
|
|
|
@@ -89,6 +90,7 @@ def create_app() -> FastAPI:
|
|
|
89
90
|
app.include_router(servers_router)
|
|
90
91
|
app.include_router(terminal_router)
|
|
91
92
|
app.include_router(files_router)
|
|
93
|
+
app.include_router(snippets_router)
|
|
92
94
|
|
|
93
95
|
app.mount("/", StaticFiles(directory=str(settings.static_dir), html=True), name="static")
|
|
94
96
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from collections.abc import AsyncGenerator
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import MetaData, text
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
6
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
7
|
+
|
|
8
|
+
from webgate.config import settings
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
engine = create_async_engine(settings.db_url, echo=False)
|
|
13
|
+
async_session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
|
14
|
+
|
|
15
|
+
convention = {
|
|
16
|
+
"ix": "ix_%(column_0_label)s",
|
|
17
|
+
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
|
18
|
+
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
|
19
|
+
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
|
20
|
+
"pk": "pk_%(table_name)s",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Base(DeclarativeBase):
|
|
25
|
+
metadata = MetaData(naming_convention=convention)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def get_session() -> AsyncGenerator[AsyncSession]:
|
|
29
|
+
async with async_session_factory() as session:
|
|
30
|
+
yield session
|
|
31
|
+
|
|
32
|
+
|
|
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
|
+
),
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def init_db() -> None:
|
|
48
|
+
dialect = engine.dialect.name # "sqlite", "postgresql", ...
|
|
49
|
+
async with engine.begin() as conn:
|
|
50
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
51
|
+
# Lightweight migrations: each in its OWN transaction. PostgreSQL aborts
|
|
52
|
+
# the whole transaction on any error (even one we catch), so running
|
|
53
|
+
# multiple ALTERs in a single txn would poison create_all on a fresh DB
|
|
54
|
+
# the moment the first "column already exists" fires.
|
|
55
|
+
for table, column, sqlite_def, pg_def in _MIGRATIONS:
|
|
56
|
+
col_def = pg_def if dialect == "postgresql" else sqlite_def
|
|
57
|
+
sql = f"ALTER TABLE {table} ADD COLUMN {column} {col_def}"
|
|
58
|
+
try:
|
|
59
|
+
async with engine.begin() as conn:
|
|
60
|
+
await conn.execute(text(sql))
|
|
61
|
+
logger.info("Migration applied: %s.%s", table, column)
|
|
62
|
+
except Exception:
|
|
63
|
+
pass # Column already exists, or table not yet present on fresh DB
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def close_db() -> None:
|
|
67
|
+
await engine.dispose()
|
|
@@ -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}
|
|
@@ -53,6 +53,7 @@ def server_to_out(server: Server) -> ServerOut:
|
|
|
53
53
|
sftp_enabled=server.sftp_enabled,
|
|
54
54
|
sftp_allowed_paths=_paths_from_json(server.sftp_allowed_paths),
|
|
55
55
|
sftp_read_only=server.sftp_read_only,
|
|
56
|
+
jump_via_id=server.jump_via_id,
|
|
56
57
|
last_connected_at=server.last_connected_at,
|
|
57
58
|
created_at=server.created_at,
|
|
58
59
|
)
|
|
@@ -125,6 +126,7 @@ async def create_server(session: AsyncSession, data: ServerCreate, user_id: int)
|
|
|
125
126
|
sftp_enabled=data.sftp_enabled,
|
|
126
127
|
sftp_allowed_paths=_tags_to_json(data.sftp_allowed_paths),
|
|
127
128
|
sftp_read_only=data.sftp_read_only,
|
|
129
|
+
jump_via_id=data.jump_via_id,
|
|
128
130
|
user_id=user_id,
|
|
129
131
|
)
|
|
130
132
|
session.add(server)
|
|
@@ -166,6 +168,8 @@ async def update_server(
|
|
|
166
168
|
server.sftp_allowed_paths = _tags_to_json(data.sftp_allowed_paths)
|
|
167
169
|
if data.sftp_read_only is not None:
|
|
168
170
|
server.sftp_read_only = data.sftp_read_only
|
|
171
|
+
if data.jump_via_id is not None:
|
|
172
|
+
server.jump_via_id = data.jump_via_id or None # 0 means "clear"
|
|
169
173
|
await session.commit()
|
|
170
174
|
await session.refresh(server)
|
|
171
175
|
return server
|
|
@@ -176,7 +180,9 @@ async def delete_server(session: AsyncSession, server: Server) -> None:
|
|
|
176
180
|
await session.commit()
|
|
177
181
|
|
|
178
182
|
|
|
179
|
-
async def test_server_connectivity(
|
|
183
|
+
async def test_server_connectivity(
|
|
184
|
+
server: Server, session: AsyncSession | None = None
|
|
185
|
+
) -> tuple[bool, str]:
|
|
180
186
|
password = decrypt_value(server.encrypted_password) if server.encrypted_password else None
|
|
181
187
|
private_key_str = (
|
|
182
188
|
decrypt_value(server.encrypted_private_key) if server.encrypted_private_key else None
|
|
@@ -193,12 +199,25 @@ async def test_server_connectivity(server: Server) -> tuple[bool, str]:
|
|
|
193
199
|
elif password:
|
|
194
200
|
kwargs["password"] = password
|
|
195
201
|
|
|
202
|
+
jump_conn = None
|
|
203
|
+
if session is not None:
|
|
204
|
+
jump_kwargs = await resolve_jump_creds(session, server)
|
|
205
|
+
if jump_kwargs:
|
|
206
|
+
try:
|
|
207
|
+
jump_conn = await asyncssh.connect(**jump_kwargs) # type: ignore[arg-type]
|
|
208
|
+
kwargs["tunnel"] = jump_conn
|
|
209
|
+
except Exception as e:
|
|
210
|
+
return False, f"Jump host failed: {e}"
|
|
211
|
+
|
|
196
212
|
try:
|
|
197
213
|
conn = await asyncssh.connect(**kwargs) # type: ignore[arg-type]
|
|
198
214
|
conn.close()
|
|
199
|
-
return True, "Connection successful"
|
|
215
|
+
return True, "Connection successful" + (" (via jump host)" if jump_conn else "")
|
|
200
216
|
except Exception as e:
|
|
201
217
|
return False, str(e)
|
|
218
|
+
finally:
|
|
219
|
+
if jump_conn is not None:
|
|
220
|
+
jump_conn.close()
|
|
202
221
|
|
|
203
222
|
|
|
204
223
|
async def update_last_connected(session: AsyncSession, server: Server) -> None:
|
|
@@ -219,6 +238,35 @@ async def list_groups(
|
|
|
219
238
|
return [row[0] for row in result.all()]
|
|
220
239
|
|
|
221
240
|
|
|
241
|
+
async def resolve_jump_creds(
|
|
242
|
+
session: AsyncSession, server: Server
|
|
243
|
+
) -> dict[str, object] | None:
|
|
244
|
+
"""If `server` has a jump_via_id, return connect kwargs for the bastion.
|
|
245
|
+
|
|
246
|
+
Returns None when no jump host is configured. Cycles are silently broken
|
|
247
|
+
(server pointing to itself, or to its own jump, etc.) by following at most
|
|
248
|
+
one hop.
|
|
249
|
+
"""
|
|
250
|
+
if server.jump_via_id is None or server.jump_via_id == server.id:
|
|
251
|
+
return None
|
|
252
|
+
stmt = select(Server).where(Server.id == server.jump_via_id)
|
|
253
|
+
jump = (await session.execute(stmt)).scalar_one_or_none()
|
|
254
|
+
if jump is None:
|
|
255
|
+
return None
|
|
256
|
+
password, private_key = get_server_credentials(jump)
|
|
257
|
+
kwargs: dict[str, object] = {
|
|
258
|
+
"host": jump.hostname,
|
|
259
|
+
"port": jump.port,
|
|
260
|
+
"username": jump.username,
|
|
261
|
+
"known_hosts": None,
|
|
262
|
+
}
|
|
263
|
+
if private_key:
|
|
264
|
+
kwargs["client_keys"] = [asyncssh.import_private_key(private_key)]
|
|
265
|
+
elif password:
|
|
266
|
+
kwargs["password"] = password
|
|
267
|
+
return kwargs
|
|
268
|
+
|
|
269
|
+
|
|
222
270
|
def get_server_credentials(server: Server) -> tuple[str | None, str | None]:
|
|
223
271
|
password = decrypt_value(server.encrypted_password) if server.encrypted_password else None
|
|
224
272
|
private_key = (
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
|
|
5
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
6
|
+
|
|
7
|
+
from webgate.db.engine import Base
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Snippet(Base):
|
|
11
|
+
__tablename__ = "snippets"
|
|
12
|
+
|
|
13
|
+
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
|
14
|
+
name: Mapped[str] = mapped_column(String(255))
|
|
15
|
+
command: Mapped[str] = mapped_column(Text)
|
|
16
|
+
description: Mapped[str] = mapped_column(Text, default="")
|
|
17
|
+
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"))
|
|
18
|
+
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SnippetCreate(BaseModel):
|
|
22
|
+
name: str
|
|
23
|
+
command: str
|
|
24
|
+
description: str = ""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SnippetOut(BaseModel):
|
|
28
|
+
id: int
|
|
29
|
+
name: str
|
|
30
|
+
command: str
|
|
31
|
+
description: str
|
|
32
|
+
created_at: datetime
|
|
33
|
+
|
|
34
|
+
model_config = {"from_attributes": True}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, HTTPException, status
|
|
4
|
+
from sqlalchemy import select
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
6
|
+
|
|
7
|
+
from webgate.auth.models import UserOut
|
|
8
|
+
from webgate.auth.routes import get_current_user
|
|
9
|
+
from webgate.db.engine import get_session
|
|
10
|
+
from webgate.snippets.models import Snippet, SnippetCreate, SnippetOut
|
|
11
|
+
|
|
12
|
+
router = APIRouter(prefix="/api/snippets", tags=["snippets"])
|
|
13
|
+
|
|
14
|
+
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
|
15
|
+
CurrentUserDep = Annotated[UserOut, Depends(get_current_user)]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@router.get("", response_model=list[SnippetOut])
|
|
19
|
+
async def list_snippets(session: SessionDep, user: CurrentUserDep) -> list[Snippet]:
|
|
20
|
+
stmt = select(Snippet).where(Snippet.user_id == user.id).order_by(Snippet.name)
|
|
21
|
+
result = await session.execute(stmt)
|
|
22
|
+
return list(result.scalars().all())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@router.post("", response_model=SnippetOut, status_code=status.HTTP_201_CREATED)
|
|
26
|
+
async def create_snippet(
|
|
27
|
+
body: SnippetCreate, session: SessionDep, user: CurrentUserDep
|
|
28
|
+
) -> Snippet:
|
|
29
|
+
snippet = Snippet(
|
|
30
|
+
name=body.name, command=body.command, description=body.description, user_id=user.id
|
|
31
|
+
)
|
|
32
|
+
session.add(snippet)
|
|
33
|
+
await session.commit()
|
|
34
|
+
await session.refresh(snippet)
|
|
35
|
+
return snippet
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@router.delete("/{snippet_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
39
|
+
async def delete_snippet(snippet_id: int, session: SessionDep, user: CurrentUserDep) -> None:
|
|
40
|
+
stmt = select(Snippet).where(Snippet.id == snippet_id, Snippet.user_id == user.id)
|
|
41
|
+
snippet = (await session.execute(stmt)).scalar_one_or_none()
|
|
42
|
+
if snippet is None:
|
|
43
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Snippet not found")
|
|
44
|
+
await session.delete(snippet)
|
|
45
|
+
await session.commit()
|