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.
Files changed (85) hide show
  1. {webgate-0.2.2 → webgate-0.3.0}/CHANGELOG.md +18 -0
  2. {webgate-0.2.2 → webgate-0.3.0}/PKG-INFO +22 -1
  3. {webgate-0.2.2 → webgate-0.3.0}/README.md +19 -0
  4. {webgate-0.2.2 → webgate-0.3.0}/ROADMAP.md +20 -6
  5. {webgate-0.2.2 → webgate-0.3.0}/pyproject.toml +4 -1
  6. {webgate-0.2.2 → webgate-0.3.0}/src/webgate/app.py +2 -0
  7. {webgate-0.2.2 → webgate-0.3.0}/src/webgate/db/engine.py +16 -6
  8. webgate-0.3.0/src/webgate/demo.py +90 -0
  9. {webgate-0.2.2 → webgate-0.3.0}/src/webgate/files/pool.py +13 -1
  10. {webgate-0.2.2 → webgate-0.3.0}/src/webgate/files/routes.py +3 -1
  11. {webgate-0.2.2 → webgate-0.3.0}/src/webgate/servers/models.py +6 -0
  12. {webgate-0.2.2 → webgate-0.3.0}/src/webgate/servers/routes.py +1 -1
  13. {webgate-0.2.2 → webgate-0.3.0}/src/webgate/servers/service.py +50 -2
  14. webgate-0.3.0/src/webgate/snippets/models.py +34 -0
  15. webgate-0.3.0/src/webgate/snippets/routes.py +45 -0
  16. {webgate-0.2.2 → webgate-0.3.0}/src/webgate/static/index.html +40 -5
  17. {webgate-0.2.2 → webgate-0.3.0}/src/webgate/terminal/routes.py +8 -1
  18. {webgate-0.2.2 → webgate-0.3.0}/src/webgate/terminal/ssh_session.py +10 -0
  19. {webgate-0.2.2 → webgate-0.3.0}/src/webgate/terminal/ws_handler.py +2 -0
  20. webgate-0.3.0/tests/__init__.py +0 -0
  21. webgate-0.2.2/src/webgate/demo.py +0 -55
  22. {webgate-0.2.2 → webgate-0.3.0}/.dockerignore +0 -0
  23. {webgate-0.2.2 → webgate-0.3.0}/.github/workflows/docs.yml +0 -0
  24. {webgate-0.2.2 → webgate-0.3.0}/.gitignore +0 -0
  25. {webgate-0.2.2 → webgate-0.3.0}/Dockerfile +0 -0
  26. {webgate-0.2.2 → webgate-0.3.0}/Dockerfile.demo +0 -0
  27. {webgate-0.2.2 → webgate-0.3.0}/Dockerfile.ssh-demo +0 -0
  28. {webgate-0.2.2 → webgate-0.3.0}/LICENSE +0 -0
  29. {webgate-0.2.2 → webgate-0.3.0}/VERSION +0 -0
  30. {webgate-0.2.2 → webgate-0.3.0}/compose.dev.yml +0 -0
  31. {webgate-0.2.2 → webgate-0.3.0}/compose.yml +0 -0
  32. {webgate-0.2.2 → webgate-0.3.0}/docs/api/auth.md +0 -0
  33. {webgate-0.2.2 → webgate-0.3.0}/docs/api/files.md +0 -0
  34. {webgate-0.2.2 → webgate-0.3.0}/docs/api/servers.md +0 -0
  35. {webgate-0.2.2 → webgate-0.3.0}/docs/api/terminal.md +0 -0
  36. {webgate-0.2.2 → webgate-0.3.0}/docs/changelog.md +0 -0
  37. {webgate-0.2.2 → webgate-0.3.0}/docs/getting-started/installation.md +0 -0
  38. {webgate-0.2.2 → webgate-0.3.0}/docs/getting-started/quickstart.md +0 -0
  39. {webgate-0.2.2 → webgate-0.3.0}/docs/guide/files.md +0 -0
  40. {webgate-0.2.2 → webgate-0.3.0}/docs/guide/servers.md +0 -0
  41. {webgate-0.2.2 → webgate-0.3.0}/docs/guide/split.md +0 -0
  42. {webgate-0.2.2 → webgate-0.3.0}/docs/guide/terminal.md +0 -0
  43. {webgate-0.2.2 → webgate-0.3.0}/docs/guide/users.md +0 -0
  44. {webgate-0.2.2 → webgate-0.3.0}/docs/index.md +0 -0
  45. {webgate-0.2.2 → webgate-0.3.0}/docs/screenshots/access-control.png +0 -0
  46. {webgate-0.2.2 → webgate-0.3.0}/docs/screenshots/audit.png +0 -0
  47. {webgate-0.2.2 → webgate-0.3.0}/docs/screenshots/edit-access-control.png +0 -0
  48. {webgate-0.2.2 → webgate-0.3.0}/docs/screenshots/editor.png +0 -0
  49. {webgate-0.2.2 → webgate-0.3.0}/docs/screenshots/light-theme.png +0 -0
  50. {webgate-0.2.2 → webgate-0.3.0}/docs/screenshots/login.png +0 -0
  51. {webgate-0.2.2 → webgate-0.3.0}/docs/screenshots/new-server-form.png +0 -0
  52. {webgate-0.2.2 → webgate-0.3.0}/docs/screenshots/sftp-restricted.png +0 -0
  53. {webgate-0.2.2 → webgate-0.3.0}/docs/screenshots/sftp.png +0 -0
  54. {webgate-0.2.2 → webgate-0.3.0}/docs/screenshots/site-manager.png +0 -0
  55. {webgate-0.2.2 → webgate-0.3.0}/docs/screenshots/split-view.png +0 -0
  56. {webgate-0.2.2 → webgate-0.3.0}/docs/screenshots/ssh-disabled.png +0 -0
  57. {webgate-0.2.2 → webgate-0.3.0}/docs/screenshots/terminal.png +0 -0
  58. {webgate-0.2.2 → webgate-0.3.0}/docs/screenshots/users.png +0 -0
  59. {webgate-0.2.2 → webgate-0.3.0}/fly.toml +0 -0
  60. {webgate-0.2.2 → webgate-0.3.0}/mkdocs.yml +0 -0
  61. {webgate-0.2.2 → webgate-0.3.0}/src/webgate/__init__.py +0 -0
  62. {webgate-0.2.2 → webgate-0.3.0}/src/webgate/__main__.py +0 -0
  63. {webgate-0.2.2 → webgate-0.3.0}/src/webgate/audit/__init__.py +0 -0
  64. {webgate-0.2.2 → webgate-0.3.0}/src/webgate/audit/models.py +0 -0
  65. {webgate-0.2.2 → webgate-0.3.0}/src/webgate/audit/service.py +0 -0
  66. {webgate-0.2.2 → webgate-0.3.0}/src/webgate/auth/__init__.py +0 -0
  67. {webgate-0.2.2 → webgate-0.3.0}/src/webgate/auth/models.py +0 -0
  68. {webgate-0.2.2 → webgate-0.3.0}/src/webgate/auth/routes.py +0 -0
  69. {webgate-0.2.2 → webgate-0.3.0}/src/webgate/auth/service.py +0 -0
  70. {webgate-0.2.2 → webgate-0.3.0}/src/webgate/config.py +0 -0
  71. {webgate-0.2.2 → webgate-0.3.0}/src/webgate/db/__init__.py +0 -0
  72. {webgate-0.2.2 → webgate-0.3.0}/src/webgate/files/__init__.py +0 -0
  73. {webgate-0.2.2 → webgate-0.3.0}/src/webgate/files/models.py +0 -0
  74. {webgate-0.2.2 → webgate-0.3.0}/src/webgate/files/sftp_service.py +0 -0
  75. {webgate-0.2.2 → webgate-0.3.0}/src/webgate/servers/__init__.py +0 -0
  76. {webgate-0.2.2 → webgate-0.3.0}/src/webgate/servers/crypto.py +0 -0
  77. {webgate-0.2.2 → webgate-0.3.0}/src/webgate/servers/monitor.py +0 -0
  78. {webgate-0.2.2/src/webgate/terminal → webgate-0.3.0/src/webgate/snippets}/__init__.py +0 -0
  79. {webgate-0.2.2/tests → webgate-0.3.0/src/webgate/terminal}/__init__.py +0 -0
  80. {webgate-0.2.2 → webgate-0.3.0}/tests/conftest.py +0 -0
  81. {webgate-0.2.2 → webgate-0.3.0}/tests/test_auth.py +0 -0
  82. {webgate-0.2.2 → webgate-0.3.0}/tests/test_files.py +0 -0
  83. {webgate-0.2.2 → webgate-0.3.0}/tests/test_servers.py +0 -0
  84. {webgate-0.2.2 → webgate-0.3.0}/tests/test_terminal.py +0 -0
  85. {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.2.2
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.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) |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "webgate"
3
- version = "0.2.2"
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
- _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}
@@ -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(server: Server) -> tuple[bool, str]:
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">&#128273;</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')">&#8634;</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" :id="tab.id"></div>
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 get_server, get_server_credentials, update_last_connected
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