webgate 0.2.2__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.
- {webgate-0.2.2 → webgate-0.3.0}/CHANGELOG.md +18 -0
- {webgate-0.2.2 → webgate-0.3.0}/PKG-INFO +22 -1
- {webgate-0.2.2 → webgate-0.3.0}/README.md +19 -0
- {webgate-0.2.2 → webgate-0.3.0}/ROADMAP.md +20 -6
- {webgate-0.2.2 → webgate-0.3.0}/pyproject.toml +4 -1
- {webgate-0.2.2 → webgate-0.3.0}/src/webgate/app.py +2 -0
- {webgate-0.2.2 → webgate-0.3.0}/src/webgate/db/engine.py +16 -6
- webgate-0.3.0/src/webgate/demo.py +90 -0
- {webgate-0.2.2 → webgate-0.3.0}/src/webgate/files/pool.py +13 -1
- {webgate-0.2.2 → webgate-0.3.0}/src/webgate/files/routes.py +3 -1
- {webgate-0.2.2 → webgate-0.3.0}/src/webgate/servers/models.py +6 -0
- {webgate-0.2.2 → webgate-0.3.0}/src/webgate/servers/routes.py +1 -1
- {webgate-0.2.2 → webgate-0.3.0}/src/webgate/servers/service.py +50 -2
- webgate-0.3.0/src/webgate/snippets/models.py +34 -0
- webgate-0.3.0/src/webgate/snippets/routes.py +45 -0
- {webgate-0.2.2 → webgate-0.3.0}/src/webgate/static/index.html +40 -5
- {webgate-0.2.2 → webgate-0.3.0}/src/webgate/terminal/routes.py +8 -1
- {webgate-0.2.2 → webgate-0.3.0}/src/webgate/terminal/ssh_session.py +10 -0
- {webgate-0.2.2 → webgate-0.3.0}/src/webgate/terminal/ws_handler.py +2 -0
- webgate-0.3.0/tests/__init__.py +0 -0
- webgate-0.2.2/src/webgate/demo.py +0 -55
- {webgate-0.2.2 → webgate-0.3.0}/.dockerignore +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/.github/workflows/docs.yml +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/.gitignore +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/Dockerfile +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/Dockerfile.demo +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/Dockerfile.ssh-demo +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/LICENSE +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/VERSION +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/compose.dev.yml +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/compose.yml +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/docs/api/auth.md +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/docs/api/files.md +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/docs/api/servers.md +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/docs/api/terminal.md +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/docs/changelog.md +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/docs/getting-started/installation.md +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/docs/getting-started/quickstart.md +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/docs/guide/files.md +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/docs/guide/servers.md +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/docs/guide/split.md +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/docs/guide/terminal.md +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/docs/guide/users.md +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/docs/index.md +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/docs/screenshots/access-control.png +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/docs/screenshots/audit.png +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/docs/screenshots/edit-access-control.png +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/docs/screenshots/editor.png +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/docs/screenshots/light-theme.png +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/docs/screenshots/login.png +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/docs/screenshots/new-server-form.png +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/docs/screenshots/sftp-restricted.png +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/docs/screenshots/sftp.png +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/docs/screenshots/site-manager.png +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/docs/screenshots/split-view.png +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/docs/screenshots/ssh-disabled.png +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/docs/screenshots/terminal.png +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/docs/screenshots/users.png +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/fly.toml +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/mkdocs.yml +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/src/webgate/__init__.py +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/src/webgate/__main__.py +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/src/webgate/audit/__init__.py +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/src/webgate/audit/models.py +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/src/webgate/audit/service.py +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/src/webgate/auth/__init__.py +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/src/webgate/auth/models.py +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/src/webgate/auth/routes.py +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/src/webgate/auth/service.py +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/src/webgate/config.py +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/src/webgate/db/__init__.py +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/src/webgate/files/__init__.py +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/src/webgate/files/models.py +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/src/webgate/files/sftp_service.py +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/src/webgate/servers/__init__.py +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/src/webgate/servers/crypto.py +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/src/webgate/servers/monitor.py +0 -0
- {webgate-0.2.2/src/webgate/terminal → webgate-0.3.0/src/webgate/snippets}/__init__.py +0 -0
- {webgate-0.2.2/tests → webgate-0.3.0/src/webgate/terminal}/__init__.py +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/tests/conftest.py +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/tests/test_auth.py +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/tests/test_files.py +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/tests/test_servers.py +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/tests/test_terminal.py +0 -0
- {webgate-0.2.2 → webgate-0.3.0}/uv.lock +0 -0
|
@@ -1,5 +1,23 @@
|
|
|
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
|
+
|
|
3
21
|
## v0.2.2 (2026-04-15)
|
|
4
22
|
|
|
5
23
|
### Features
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: webgate
|
|
3
|
-
Version: 0.
|
|
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
|
|
@@ -452,6 +454,25 @@ All settings are configurable via environment variables with the `WEBGATE_` pref
|
|
|
452
454
|
| `WEBGATE_ALLOWED_ORIGINS` | `*` | CORS origins (comma-separated) |
|
|
453
455
|
| `WEBGATE_ROOT_PATH` | `` (empty) | URL prefix when served behind a reverse proxy at a sub-path (e.g. `/webgate`) |
|
|
454
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.
|
|
455
476
|
| `WEBGATE_LOG_LEVEL` | `info` | Log level |
|
|
456
477
|
| `WEBGATE_SESSION_TIMEOUT` | `3600` | SSH session timeout (seconds) |
|
|
457
478
|
| `WEBGATE_MAX_UPLOAD_SIZE` | `104857600` | Max upload size (100 MB) |
|
|
@@ -406,6 +406,25 @@ 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 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.
|
|
409
428
|
| `WEBGATE_LOG_LEVEL` | `info` | Log level |
|
|
410
429
|
| `WEBGATE_SESSION_TIMEOUT` | `3600` | SSH session timeout (seconds) |
|
|
411
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.
|
|
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.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/"
|
|
@@ -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
|
|
|
@@ -30,21 +30,31 @@ async def get_session() -> AsyncGenerator[AsyncSession]:
|
|
|
30
30
|
yield session
|
|
31
31
|
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
"
|
|
36
|
-
"
|
|
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
|
|
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",
|
|
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}
|
|
@@ -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()
|
|
@@ -316,6 +316,7 @@ window._showToastImpl = function(message, type) {
|
|
|
316
316
|
<div class="srv-host">
|
|
317
317
|
<span x-text="srv.username + '@' + srv.hostname + ':' + srv.port"></span>
|
|
318
318
|
<span x-show="srv.auth_method==='key'" style="color:#3fb950;margin-left:4px" title="Key auth">🔑</span>
|
|
319
|
+
<span x-show="srv.jump_via_id" style="color:#a78bfa;margin-left:4px" :title="'Via ' + (servers.find(s => s.id === srv.jump_via_id)?.name || 'jump host')">↺</span>
|
|
319
320
|
</div>
|
|
320
321
|
<span x-show="srv.group" class="srv-group" x-text="srv.group"></span>
|
|
321
322
|
<div class="fz-srv-actions">
|
|
@@ -349,7 +350,16 @@ window._showToastImpl = function(message, type) {
|
|
|
349
350
|
<div x-show="activeTabId===tab.id && view!=='dashboard'" class="fz-flex-col">
|
|
350
351
|
|
|
351
352
|
<!-- ---- Terminal tab ---- -->
|
|
352
|
-
<div x-show="tab.type==='terminal'" style="flex:1;min-height:0
|
|
353
|
+
<div x-show="tab.type==='terminal'" style="flex:1;min-height:0;display:flex;flex-direction:column">
|
|
354
|
+
<div style="padding:4px 8px;background:var(--bg-secondary);border-bottom:1px solid var(--border);display:flex;gap:4px;align-items:center;flex-wrap:wrap">
|
|
355
|
+
<span style="font-size:11px;color:var(--text-muted);font-weight:600;margin-right:4px">Snippets:</span>
|
|
356
|
+
<template x-for="s in snippets" :key="s.id">
|
|
357
|
+
<button @click="runSnippet(tab, s)" @contextmenu.prevent="confirmDeleteSnippet(s)" class="fz-btn" style="font-size:11px;padding:2px 8px" :title="(s.description || s.command) + ' (right-click to delete)'" x-text="s.name"></button>
|
|
358
|
+
</template>
|
|
359
|
+
<button @click="promptNewSnippet()" class="fz-btn" style="font-size:11px;padding:2px 8px" title="Create new snippet">+</button>
|
|
360
|
+
</div>
|
|
361
|
+
<div style="flex:1;min-height:0" :id="tab.id"></div>
|
|
362
|
+
</div>
|
|
353
363
|
|
|
354
364
|
<!-- ---- Split tab (terminal + files) ---- -->
|
|
355
365
|
<div x-show="tab.type==='split'" style="flex:1;display:flex;min-height:0">
|
|
@@ -532,6 +542,13 @@ window._showToastImpl = function(message, type) {
|
|
|
532
542
|
</div>
|
|
533
543
|
<label>Description</label>
|
|
534
544
|
<input type="text" x-model="serverForm.description" placeholder="Primary web server">
|
|
545
|
+
<label>Jump Via (bastion / SSH proxy)</label>
|
|
546
|
+
<select x-model.number="serverForm.jump_via_id">
|
|
547
|
+
<option :value="null">— direct (no jump) —</option>
|
|
548
|
+
<template x-for="s in servers.filter(x => x.id !== editingServerId)" :key="s.id">
|
|
549
|
+
<option :value="s.id" x-text="s.name + ' (' + s.hostname + ')'"></option>
|
|
550
|
+
</template>
|
|
551
|
+
</select>
|
|
535
552
|
<!-- Access Control -->
|
|
536
553
|
<div style="margin-top:14px;padding-top:12px;border-top:1px solid var(--border)">
|
|
537
554
|
<label style="font-size:12px;font-weight:600;color:var(--text-primary);margin-bottom:8px">Access Control</label>
|
|
@@ -817,6 +834,7 @@ function app() {
|
|
|
817
834
|
return {
|
|
818
835
|
theme: localStorage.getItem('webgate_theme') || 'dark',
|
|
819
836
|
demoMode: false,
|
|
837
|
+
snippets: [],
|
|
820
838
|
uploadProgress: { active: false, percent: 0, fileName: '', total: 0, loaded: 0 },
|
|
821
839
|
token: localStorage.getItem('webgate_token') || '',
|
|
822
840
|
currentUser: null, username: '', password: '',
|
|
@@ -833,7 +851,7 @@ function app() {
|
|
|
833
851
|
servers: [], groups: [], selectedGroup: '', searchQuery: '',
|
|
834
852
|
selectedServerId: null,
|
|
835
853
|
showServerModal: false, editingServerId: null, testingServerId: null,
|
|
836
|
-
serverForm: { name: '', hostname: '', port: 22, username: '', auth_method: 'password', password: '', private_key: '', group: '', tagsStr: '', description: '' },
|
|
854
|
+
serverForm: { name: '', hostname: '', port: 22, username: '', auth_method: 'password', password: '', private_key: '', group: '', tagsStr: '', description: '', jump_via_id: null },
|
|
837
855
|
tabs: [], activeTabId: null,
|
|
838
856
|
logMessages: [],
|
|
839
857
|
serverStatuses: {},
|
|
@@ -893,7 +911,7 @@ function app() {
|
|
|
893
911
|
if (this.token) {
|
|
894
912
|
await this.fetchMe();
|
|
895
913
|
if (this.currentUser?.must_change_password) return;
|
|
896
|
-
await this.loadServers(); await this.loadGroups(); await this.loadStatuses();
|
|
914
|
+
await this.loadServers(); await this.loadGroups(); await this.loadStatuses(); await this.loadSnippets();
|
|
897
915
|
setInterval(() => this.loadStatuses(), 30000);
|
|
898
916
|
this._restoreTabs();
|
|
899
917
|
}
|
|
@@ -993,16 +1011,33 @@ function app() {
|
|
|
993
1011
|
|
|
994
1012
|
// Servers
|
|
995
1013
|
async loadServers() { try { let url = '/api/servers'; if (this.searchQuery) url += `?search=${encodeURIComponent(this.searchQuery)}`; this.servers = await this.apiCall(url); } catch (e) { this.showToast(e.message, 'error'); } },
|
|
1014
|
+
async loadSnippets() { try { this.snippets = await this.apiCall('/api/snippets'); } catch {} },
|
|
1015
|
+
async promptNewSnippet() {
|
|
1016
|
+
const name = prompt('Snippet name:'); if (!name) return;
|
|
1017
|
+
const command = prompt('Command (will be sent to terminal + Enter):'); if (!command) return;
|
|
1018
|
+
try { await this.apiCall('/api/snippets', { method: 'POST', body: JSON.stringify({ name, command, description: '' }) }); await this.loadSnippets(); this.showToast('Snippet created'); }
|
|
1019
|
+
catch (e) { this.showToast(e.message, 'error'); }
|
|
1020
|
+
},
|
|
1021
|
+
async confirmDeleteSnippet(s) {
|
|
1022
|
+
if (!confirm(`Delete snippet "${s.name}"?`)) return;
|
|
1023
|
+
try { await this.apiCall(`/api/snippets/${s.id}`, { method: 'DELETE' }); await this.loadSnippets(); this.showToast('Snippet deleted'); }
|
|
1024
|
+
catch (e) { this.showToast(e.message, 'error'); }
|
|
1025
|
+
},
|
|
1026
|
+
runSnippet(tab, s) {
|
|
1027
|
+
if (!tab._ws || tab._ws.readyState !== WebSocket.OPEN) { this.showToast('Terminal not connected', 'error'); return; }
|
|
1028
|
+
tab._ws.send(s.command + '\r');
|
|
1029
|
+
this.log('cmd', `Sent snippet "${s.name}"`);
|
|
1030
|
+
},
|
|
996
1031
|
async loadStatuses() { try { this.serverStatuses = await this.apiCall('/api/servers/status'); } catch {} },
|
|
997
1032
|
async loadGroups() { try { this.groups = await this.apiCall('/api/servers/groups'); } catch {} },
|
|
998
|
-
resetServerForm() { this.editingServerId = null; this.serverForm = { name: '', hostname: '', port: 22, username: '', auth_method: 'password', password: '', private_key: '', keyFileName: '', group: '', tagsStr: '', description: '', ssh_enabled: true, sftp_enabled: true, sftp_read_only: false, sftp_allowed_paths_str: '' }; },
|
|
1033
|
+
resetServerForm() { this.editingServerId = null; this.serverForm = { name: '', hostname: '', port: 22, username: '', auth_method: 'password', password: '', private_key: '', keyFileName: '', group: '', tagsStr: '', description: '', ssh_enabled: true, sftp_enabled: true, sftp_read_only: false, sftp_allowed_paths_str: '', jump_via_id: null }; },
|
|
999
1034
|
loadKeyFile(event) {
|
|
1000
1035
|
const file = event.target.files?.[0]; if (!file) return;
|
|
1001
1036
|
const reader = new FileReader();
|
|
1002
1037
|
reader.onload = (e) => { this.serverForm.private_key = e.target.result; this.serverForm.keyFileName = file.name; };
|
|
1003
1038
|
reader.readAsText(file); event.target.value = '';
|
|
1004
1039
|
},
|
|
1005
|
-
editServer(srv) { this.editingServerId = srv.id; this.serverForm = { name: srv.name, hostname: srv.hostname, port: srv.port, username: srv.username, auth_method: srv.auth_method, password: '', private_key: '', keyFileName: '', group: srv.group, tagsStr: (srv.tags || []).join(', '), description: srv.description, ssh_enabled: srv.ssh_enabled !== false, sftp_enabled: srv.sftp_enabled !== false, sftp_read_only: srv.sftp_read_only || false, sftp_allowed_paths_str: (srv.sftp_allowed_paths || []).join('\n') }; this.showServerModal = true; },
|
|
1040
|
+
editServer(srv) { this.editingServerId = srv.id; this.serverForm = { name: srv.name, hostname: srv.hostname, port: srv.port, username: srv.username, auth_method: srv.auth_method, password: '', private_key: '', keyFileName: '', group: srv.group, tagsStr: (srv.tags || []).join(', '), description: srv.description, ssh_enabled: srv.ssh_enabled !== false, sftp_enabled: srv.sftp_enabled !== false, sftp_read_only: srv.sftp_read_only || false, sftp_allowed_paths_str: (srv.sftp_allowed_paths || []).join('\n'), jump_via_id: srv.jump_via_id || null }; this.showServerModal = true; },
|
|
1006
1041
|
async saveServer() {
|
|
1007
1042
|
const tags = this.serverForm.tagsStr.split(',').map(t => t.trim()).filter(Boolean);
|
|
1008
1043
|
const sftp_allowed_paths = (this.serverForm.sftp_allowed_paths_str || '').split('\n').map(p => p.trim()).filter(Boolean);
|
|
@@ -6,7 +6,12 @@ from webgate.audit.service import log_action
|
|
|
6
6
|
from webgate.auth.models import UserOut
|
|
7
7
|
from webgate.auth.service import get_user_by_id
|
|
8
8
|
from webgate.db.engine import async_session_factory
|
|
9
|
-
from webgate.servers.service import
|
|
9
|
+
from webgate.servers.service import (
|
|
10
|
+
get_server,
|
|
11
|
+
get_server_credentials,
|
|
12
|
+
resolve_jump_creds,
|
|
13
|
+
update_last_connected,
|
|
14
|
+
)
|
|
10
15
|
from webgate.terminal.ws_handler import authenticate_websocket, handle_terminal_ws
|
|
11
16
|
|
|
12
17
|
router = APIRouter(tags=["terminal"])
|
|
@@ -86,6 +91,7 @@ async def ws_terminal_server(ws: WebSocket, server_id: int) -> None:
|
|
|
86
91
|
return
|
|
87
92
|
|
|
88
93
|
password, private_key = get_server_credentials(server)
|
|
94
|
+
jump_kwargs = await resolve_jump_creds(session, server)
|
|
89
95
|
|
|
90
96
|
await ws.accept()
|
|
91
97
|
|
|
@@ -111,4 +117,5 @@ async def ws_terminal_server(ws: WebSocket, server_id: int) -> None:
|
|
|
111
117
|
private_key=private_key,
|
|
112
118
|
cols=cols,
|
|
113
119
|
rows=rows,
|
|
120
|
+
jump_kwargs=jump_kwargs,
|
|
114
121
|
)
|
|
@@ -18,6 +18,8 @@ class SSHSession:
|
|
|
18
18
|
username: str
|
|
19
19
|
password: str | None = None
|
|
20
20
|
private_key: str | None = None
|
|
21
|
+
jump_kwargs: dict[str, Any] | None = None # connect kwargs for a bastion (optional)
|
|
22
|
+
_jump_conn: asyncssh.SSHClientConnection | None = field(default=None, repr=False)
|
|
21
23
|
_conn: asyncssh.SSHClientConnection | None = field(default=None, repr=False)
|
|
22
24
|
_process: asyncssh.SSHClientProcess[str] | None = field(default=None, repr=False)
|
|
23
25
|
_closed: bool = field(default=False, repr=False)
|
|
@@ -34,6 +36,10 @@ class SSHSession:
|
|
|
34
36
|
elif self.password:
|
|
35
37
|
connect_kwargs["password"] = self.password
|
|
36
38
|
|
|
39
|
+
if self.jump_kwargs:
|
|
40
|
+
self._jump_conn = await asyncssh.connect(**self.jump_kwargs)
|
|
41
|
+
connect_kwargs["tunnel"] = self._jump_conn
|
|
42
|
+
|
|
37
43
|
self._conn = await asyncssh.connect(**connect_kwargs)
|
|
38
44
|
self._process = await self._conn.create_process(
|
|
39
45
|
term_type="xterm-256color",
|
|
@@ -77,6 +83,10 @@ class SSHSession:
|
|
|
77
83
|
self._conn.close()
|
|
78
84
|
with contextlib.suppress(Exception):
|
|
79
85
|
await asyncio.wait_for(self._conn.wait_closed(), timeout=2.0)
|
|
86
|
+
if self._jump_conn is not None:
|
|
87
|
+
self._jump_conn.close()
|
|
88
|
+
with contextlib.suppress(Exception):
|
|
89
|
+
await asyncio.wait_for(self._jump_conn.wait_closed(), timeout=2.0)
|
|
80
90
|
|
|
81
91
|
@property
|
|
82
92
|
def is_closed(self) -> bool:
|
|
@@ -40,6 +40,7 @@ async def handle_terminal_ws(
|
|
|
40
40
|
private_key: str | None = None,
|
|
41
41
|
cols: int = 80,
|
|
42
42
|
rows: int = 24,
|
|
43
|
+
jump_kwargs: dict[str, object] | None = None,
|
|
43
44
|
) -> None:
|
|
44
45
|
session = SSHSession(
|
|
45
46
|
host=host,
|
|
@@ -47,6 +48,7 @@ async def handle_terminal_ws(
|
|
|
47
48
|
username=username,
|
|
48
49
|
password=password,
|
|
49
50
|
private_key=private_key,
|
|
51
|
+
jump_kwargs=jump_kwargs,
|
|
50
52
|
)
|
|
51
53
|
|
|
52
54
|
try:
|
|
File without changes
|
|
@@ -1,55 +0,0 @@
|
|
|
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 webgate.servers.models import ServerCreate
|
|
15
|
-
from webgate.servers.service import create_server, list_servers
|
|
16
|
-
|
|
17
|
-
logger = logging.getLogger(__name__)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
DEMO_USERNAME = "demo"
|
|
21
|
-
DEMO_PASSWORD = "demo"
|
|
22
|
-
|
|
23
|
-
# Servers pre-registered for the demo. Hostnames must be reachable from the
|
|
24
|
-
# webgate container. In Fly.io they resolve via *.internal DNS.
|
|
25
|
-
DEMO_SERVERS: list[ServerCreate] = [
|
|
26
|
-
ServerCreate(
|
|
27
|
-
name="demo-server",
|
|
28
|
-
hostname="127.0.0.1",
|
|
29
|
-
port=22,
|
|
30
|
-
username="demo",
|
|
31
|
-
password="demo",
|
|
32
|
-
group="demo",
|
|
33
|
-
tags=["demo", "debian"],
|
|
34
|
-
description="Sandbox SSH/SFTP server (debian). Try `ls`, `htop`, browse /home/demo.",
|
|
35
|
-
sftp_read_only=True,
|
|
36
|
-
),
|
|
37
|
-
]
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
async def seed_demo(session: AsyncSession) -> None:
|
|
41
|
-
user = await get_user_by_username(session, DEMO_USERNAME)
|
|
42
|
-
if user is None:
|
|
43
|
-
user = await create_user(session, DEMO_USERNAME, DEMO_PASSWORD, is_admin=False)
|
|
44
|
-
user.must_change_password = False
|
|
45
|
-
user.allowed_groups = json.dumps(["demo"])
|
|
46
|
-
await session.commit()
|
|
47
|
-
logger.info("Created demo user (%s/%s)", DEMO_USERNAME, DEMO_PASSWORD)
|
|
48
|
-
|
|
49
|
-
existing = await list_servers(session, user.id, is_admin=True)
|
|
50
|
-
existing_names = {s.name for s in existing}
|
|
51
|
-
for srv in DEMO_SERVERS:
|
|
52
|
-
if srv.name in existing_names:
|
|
53
|
-
continue
|
|
54
|
-
await create_server(session, srv, user.id)
|
|
55
|
-
logger.info("Seeded demo server: %s -> %s:%s", srv.name, srv.hostname, srv.port)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|