webgate 0.2.2__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. {webgate-0.2.2 → webgate-0.3.1}/CHANGELOG.md +30 -0
  2. {webgate-0.2.2 → webgate-0.3.1}/PKG-INFO +20 -1
  3. {webgate-0.2.2 → webgate-0.3.1}/README.md +18 -0
  4. {webgate-0.2.2 → webgate-0.3.1}/ROADMAP.md +20 -6
  5. {webgate-0.2.2 → webgate-0.3.1}/pyproject.toml +2 -1
  6. {webgate-0.2.2 → webgate-0.3.1}/src/webgate/app.py +2 -0
  7. webgate-0.3.1/src/webgate/db/engine.py +67 -0
  8. webgate-0.3.1/src/webgate/demo.py +90 -0
  9. {webgate-0.2.2 → webgate-0.3.1}/src/webgate/files/pool.py +13 -1
  10. {webgate-0.2.2 → webgate-0.3.1}/src/webgate/files/routes.py +3 -1
  11. {webgate-0.2.2 → webgate-0.3.1}/src/webgate/servers/models.py +6 -0
  12. {webgate-0.2.2 → webgate-0.3.1}/src/webgate/servers/routes.py +1 -1
  13. {webgate-0.2.2 → webgate-0.3.1}/src/webgate/servers/service.py +50 -2
  14. webgate-0.3.1/src/webgate/snippets/models.py +34 -0
  15. webgate-0.3.1/src/webgate/snippets/routes.py +45 -0
  16. {webgate-0.2.2 → webgate-0.3.1}/src/webgate/static/index.html +40 -5
  17. {webgate-0.2.2 → webgate-0.3.1}/src/webgate/terminal/routes.py +8 -1
  18. {webgate-0.2.2 → webgate-0.3.1}/src/webgate/terminal/ssh_session.py +10 -0
  19. {webgate-0.2.2 → webgate-0.3.1}/src/webgate/terminal/ws_handler.py +2 -0
  20. webgate-0.3.1/tests/__init__.py +0 -0
  21. {webgate-0.2.2 → webgate-0.3.1}/uv.lock +51 -1
  22. webgate-0.2.2/src/webgate/db/engine.py +0 -53
  23. webgate-0.2.2/src/webgate/demo.py +0 -55
  24. {webgate-0.2.2 → webgate-0.3.1}/.dockerignore +0 -0
  25. {webgate-0.2.2 → webgate-0.3.1}/.github/workflows/docs.yml +0 -0
  26. {webgate-0.2.2 → webgate-0.3.1}/.gitignore +0 -0
  27. {webgate-0.2.2 → webgate-0.3.1}/Dockerfile +0 -0
  28. {webgate-0.2.2 → webgate-0.3.1}/Dockerfile.demo +0 -0
  29. {webgate-0.2.2 → webgate-0.3.1}/Dockerfile.ssh-demo +0 -0
  30. {webgate-0.2.2 → webgate-0.3.1}/LICENSE +0 -0
  31. {webgate-0.2.2 → webgate-0.3.1}/VERSION +0 -0
  32. {webgate-0.2.2 → webgate-0.3.1}/compose.dev.yml +0 -0
  33. {webgate-0.2.2 → webgate-0.3.1}/compose.yml +0 -0
  34. {webgate-0.2.2 → webgate-0.3.1}/docs/api/auth.md +0 -0
  35. {webgate-0.2.2 → webgate-0.3.1}/docs/api/files.md +0 -0
  36. {webgate-0.2.2 → webgate-0.3.1}/docs/api/servers.md +0 -0
  37. {webgate-0.2.2 → webgate-0.3.1}/docs/api/terminal.md +0 -0
  38. {webgate-0.2.2 → webgate-0.3.1}/docs/changelog.md +0 -0
  39. {webgate-0.2.2 → webgate-0.3.1}/docs/getting-started/installation.md +0 -0
  40. {webgate-0.2.2 → webgate-0.3.1}/docs/getting-started/quickstart.md +0 -0
  41. {webgate-0.2.2 → webgate-0.3.1}/docs/guide/files.md +0 -0
  42. {webgate-0.2.2 → webgate-0.3.1}/docs/guide/servers.md +0 -0
  43. {webgate-0.2.2 → webgate-0.3.1}/docs/guide/split.md +0 -0
  44. {webgate-0.2.2 → webgate-0.3.1}/docs/guide/terminal.md +0 -0
  45. {webgate-0.2.2 → webgate-0.3.1}/docs/guide/users.md +0 -0
  46. {webgate-0.2.2 → webgate-0.3.1}/docs/index.md +0 -0
  47. {webgate-0.2.2 → webgate-0.3.1}/docs/screenshots/access-control.png +0 -0
  48. {webgate-0.2.2 → webgate-0.3.1}/docs/screenshots/audit.png +0 -0
  49. {webgate-0.2.2 → webgate-0.3.1}/docs/screenshots/edit-access-control.png +0 -0
  50. {webgate-0.2.2 → webgate-0.3.1}/docs/screenshots/editor.png +0 -0
  51. {webgate-0.2.2 → webgate-0.3.1}/docs/screenshots/light-theme.png +0 -0
  52. {webgate-0.2.2 → webgate-0.3.1}/docs/screenshots/login.png +0 -0
  53. {webgate-0.2.2 → webgate-0.3.1}/docs/screenshots/new-server-form.png +0 -0
  54. {webgate-0.2.2 → webgate-0.3.1}/docs/screenshots/sftp-restricted.png +0 -0
  55. {webgate-0.2.2 → webgate-0.3.1}/docs/screenshots/sftp.png +0 -0
  56. {webgate-0.2.2 → webgate-0.3.1}/docs/screenshots/site-manager.png +0 -0
  57. {webgate-0.2.2 → webgate-0.3.1}/docs/screenshots/split-view.png +0 -0
  58. {webgate-0.2.2 → webgate-0.3.1}/docs/screenshots/ssh-disabled.png +0 -0
  59. {webgate-0.2.2 → webgate-0.3.1}/docs/screenshots/terminal.png +0 -0
  60. {webgate-0.2.2 → webgate-0.3.1}/docs/screenshots/users.png +0 -0
  61. {webgate-0.2.2 → webgate-0.3.1}/fly.toml +0 -0
  62. {webgate-0.2.2 → webgate-0.3.1}/mkdocs.yml +0 -0
  63. {webgate-0.2.2 → webgate-0.3.1}/src/webgate/__init__.py +0 -0
  64. {webgate-0.2.2 → webgate-0.3.1}/src/webgate/__main__.py +0 -0
  65. {webgate-0.2.2 → webgate-0.3.1}/src/webgate/audit/__init__.py +0 -0
  66. {webgate-0.2.2 → webgate-0.3.1}/src/webgate/audit/models.py +0 -0
  67. {webgate-0.2.2 → webgate-0.3.1}/src/webgate/audit/service.py +0 -0
  68. {webgate-0.2.2 → webgate-0.3.1}/src/webgate/auth/__init__.py +0 -0
  69. {webgate-0.2.2 → webgate-0.3.1}/src/webgate/auth/models.py +0 -0
  70. {webgate-0.2.2 → webgate-0.3.1}/src/webgate/auth/routes.py +0 -0
  71. {webgate-0.2.2 → webgate-0.3.1}/src/webgate/auth/service.py +0 -0
  72. {webgate-0.2.2 → webgate-0.3.1}/src/webgate/config.py +0 -0
  73. {webgate-0.2.2 → webgate-0.3.1}/src/webgate/db/__init__.py +0 -0
  74. {webgate-0.2.2 → webgate-0.3.1}/src/webgate/files/__init__.py +0 -0
  75. {webgate-0.2.2 → webgate-0.3.1}/src/webgate/files/models.py +0 -0
  76. {webgate-0.2.2 → webgate-0.3.1}/src/webgate/files/sftp_service.py +0 -0
  77. {webgate-0.2.2 → webgate-0.3.1}/src/webgate/servers/__init__.py +0 -0
  78. {webgate-0.2.2 → webgate-0.3.1}/src/webgate/servers/crypto.py +0 -0
  79. {webgate-0.2.2 → webgate-0.3.1}/src/webgate/servers/monitor.py +0 -0
  80. {webgate-0.2.2/src/webgate/terminal → webgate-0.3.1/src/webgate/snippets}/__init__.py +0 -0
  81. {webgate-0.2.2/tests → webgate-0.3.1/src/webgate/terminal}/__init__.py +0 -0
  82. {webgate-0.2.2 → webgate-0.3.1}/tests/conftest.py +0 -0
  83. {webgate-0.2.2 → webgate-0.3.1}/tests/test_auth.py +0 -0
  84. {webgate-0.2.2 → webgate-0.3.1}/tests/test_files.py +0 -0
  85. {webgate-0.2.2 → webgate-0.3.1}/tests/test_servers.py +0 -0
  86. {webgate-0.2.2 → webgate-0.3.1}/tests/test_terminal.py +0 -0
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.3.1 (2026-04-15)
4
+
5
+ ### Fixed
6
+
7
+ - **PostgreSQL actually works in Docker now.** v0.3.0 advertised PostgreSQL support but two bugs prevented it from working:
8
+ 1. `asyncpg` was an optional extra (`webgate[postgres]`), so the official Docker image (and `pip install webgate`) didn't have the driver. Now bundled by default.
9
+ 2. Lightweight migrations ran inside the same transaction as `create_all`. PostgreSQL aborts the entire transaction on any error (even when caught), so the first "column already exists" error rolled back table creation on subsequent runs. Each migration now uses its own transaction.
10
+
11
+ Verified end-to-end against a real `postgres:16-alpine` container: tables created, login works, server creation and persistence across restarts confirmed.
12
+
13
+ ---
14
+
15
+ ## v0.3.0 (2026-04-15)
16
+
17
+ ### Features
18
+
19
+ - **SSH jump host / bastion** -- per-server `jump_via_id` field. Webgate opens a tunneled SSH connection through the bastion using `asyncssh`'s `tunnel=` parameter. Works for both the terminal WebSocket and the SFTP browser. Solves the common "internal servers reachable only through one public bastion" scenario.
20
+ - **SSH command snippets** -- per-user library of named commands. Click a snippet button in the terminal toolbar to send the command (with Enter) to the active session. Right-click to delete, `+` to create.
21
+ - **PostgreSQL support** -- install with `pip install 'webgate[postgres]'` and set `WEBGATE_DB_URL=postgresql+asyncpg://...`. SQLite remains the default. Lightweight migrations are now dialect-aware.
22
+
23
+ ### Details
24
+
25
+ - New `Server.jump_via_id` (FK to `servers.id`, nullable) + `resolve_jump_creds()` helper
26
+ - New `Snippet` model + REST CRUD at `/api/snippets`
27
+ - Frontend: dropdown to pick a jump host in the Add/Edit Server modal; `↺` badge in the server card when a jump is configured
28
+ - Frontend: snippet toolbar in the terminal tab (hidden when there are no snippets)
29
+ - Demo seed pre-populates `bastion` + `internal-app` (jump-host pair) and 4 example snippets
30
+
31
+ ---
32
+
3
33
  ## v0.2.2 (2026-04-15)
4
34
 
5
35
  ### Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webgate
3
- Version: 0.2.2
3
+ Version: 0.3.1
4
4
  Summary: Self-hosted web application for remote server management via SSH terminal and SFTP file browser
5
5
  Project-URL: Homepage, https://github.com/kalexnolasco/webgate
6
6
  Project-URL: Documentation, https://kalexnolasco.github.io/webgate/
@@ -27,6 +27,7 @@ Classifier: Typing :: Typed
27
27
  Requires-Python: >=3.11
28
28
  Requires-Dist: aiofiles>=24.1.0
29
29
  Requires-Dist: aiosqlite>=0.21.0
30
+ Requires-Dist: asyncpg>=0.30.0
30
31
  Requires-Dist: asyncssh>=2.18.0
31
32
  Requires-Dist: bcrypt>=4.2.0
32
33
  Requires-Dist: cryptography>=44.0.0
@@ -452,6 +453,24 @@ All settings are configurable via environment variables with the `WEBGATE_` pref
452
453
  | `WEBGATE_ALLOWED_ORIGINS` | `*` | CORS origins (comma-separated) |
453
454
  | `WEBGATE_ROOT_PATH` | `` (empty) | URL prefix when served behind a reverse proxy at a sub-path (e.g. `/webgate`) |
454
455
  | `WEBGATE_DEMO_MODE` | `false` | Read-only public demo: blocks all writes, seeds a `demo`/`demo` user and shows a banner |
456
+
457
+ ### PostgreSQL
458
+
459
+ SQLite is the default. To use PostgreSQL, just point `WEBGATE_DB_URL` at it (the `asyncpg` driver is bundled as of v0.3.1):
460
+
461
+ ```bash
462
+ export WEBGATE_DB_URL='postgresql+asyncpg://user:pass@host:5432/webgate'
463
+ ```
464
+
465
+ Lightweight migrations are dialect-aware. Schema is created automatically on first start.
466
+
467
+ ### Jump host / bastion
468
+
469
+ When adding a server, pick another server from the **Jump Via** dropdown to tunnel the SSH and SFTP connections through it. Useful when the target server is only reachable from a specific bastion. Stored as `jump_via_id` (FK to another `Server` row); `asyncssh`'s native `tunnel=` parameter does the actual chaining.
470
+
471
+ ### SSH command snippets
472
+
473
+ Each user has a personal library of named commands. They appear as buttons in the terminal toolbar -- click to send `command + Enter`. Right-click to delete, `+` to create. Stored in the `snippets` table.
455
474
  | `WEBGATE_LOG_LEVEL` | `info` | Log level |
456
475
  | `WEBGATE_SESSION_TIMEOUT` | `3600` | SSH session timeout (seconds) |
457
476
  | `WEBGATE_MAX_UPLOAD_SIZE` | `104857600` | Max upload size (100 MB) |
@@ -406,6 +406,24 @@ All settings are configurable via environment variables with the `WEBGATE_` pref
406
406
  | `WEBGATE_ALLOWED_ORIGINS` | `*` | CORS origins (comma-separated) |
407
407
  | `WEBGATE_ROOT_PATH` | `` (empty) | URL prefix when served behind a reverse proxy at a sub-path (e.g. `/webgate`) |
408
408
  | `WEBGATE_DEMO_MODE` | `false` | Read-only public demo: blocks all writes, seeds a `demo`/`demo` user and shows a banner |
409
+
410
+ ### PostgreSQL
411
+
412
+ SQLite is the default. To use PostgreSQL, just point `WEBGATE_DB_URL` at it (the `asyncpg` driver is bundled as of v0.3.1):
413
+
414
+ ```bash
415
+ export WEBGATE_DB_URL='postgresql+asyncpg://user:pass@host:5432/webgate'
416
+ ```
417
+
418
+ Lightweight migrations are dialect-aware. Schema is created automatically on first start.
419
+
420
+ ### Jump host / bastion
421
+
422
+ When adding a server, pick another server from the **Jump Via** dropdown to tunnel the SSH and SFTP connections through it. Useful when the target server is only reachable from a specific bastion. Stored as `jump_via_id` (FK to another `Server` row); `asyncssh`'s native `tunnel=` parameter does the actual chaining.
423
+
424
+ ### SSH command snippets
425
+
426
+ Each user has a personal library of named commands. They appear as buttons in the terminal toolbar -- click to send `command + Enter`. Right-click to delete, `+` to create. Stored in the `snippets` table.
409
427
  | `WEBGATE_LOG_LEVEL` | `info` | Log level |
410
428
  | `WEBGATE_SESSION_TIMEOUT` | `3600` | SSH session timeout (seconds) |
411
429
  | `WEBGATE_MAX_UPLOAD_SIZE` | `104857600` | Max upload size (100 MB) |
@@ -45,25 +45,39 @@ This document tracks the development plan for **webgate**. Items are organized b
45
45
  - [x] Drag & drop upload progress bar (XHR with progress events, percentage display)
46
46
  - [x] Folder download as ZIP (server-side zip compression, right-click context menu)
47
47
 
48
- ## v0.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.1"
4
4
  description = "Self-hosted web application for remote server management via SSH terminal and SFTP file browser"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -40,6 +40,7 @@ dependencies = [
40
40
  "slowapi>=0.1.9",
41
41
  "pyotp>=2.9.0",
42
42
  "qrcode[pil]>=8.2",
43
+ "asyncpg>=0.30.0",
43
44
  ]
44
45
 
45
46
  [project.urls]
@@ -18,6 +18,7 @@ from webgate.files.pool import sftp_pool
18
18
  from webgate.files.routes import router as files_router
19
19
  from webgate.servers.monitor import server_monitor
20
20
  from webgate.servers.routes import router as servers_router
21
+ from webgate.snippets.routes import router as snippets_router
21
22
  from webgate.terminal.routes import router as terminal_router
22
23
 
23
24
 
@@ -89,6 +90,7 @@ def create_app() -> FastAPI:
89
90
  app.include_router(servers_router)
90
91
  app.include_router(terminal_router)
91
92
  app.include_router(files_router)
93
+ app.include_router(snippets_router)
92
94
 
93
95
  app.mount("/", StaticFiles(directory=str(settings.static_dir), html=True), name="static")
94
96
 
@@ -0,0 +1,67 @@
1
+ import logging
2
+ from collections.abc import AsyncGenerator
3
+
4
+ from sqlalchemy import MetaData, text
5
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
6
+ from sqlalchemy.orm import DeclarativeBase
7
+
8
+ from webgate.config import settings
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ engine = create_async_engine(settings.db_url, echo=False)
13
+ async_session_factory = async_sessionmaker(engine, expire_on_commit=False)
14
+
15
+ convention = {
16
+ "ix": "ix_%(column_0_label)s",
17
+ "uq": "uq_%(table_name)s_%(column_0_name)s",
18
+ "ck": "ck_%(table_name)s_%(constraint_name)s",
19
+ "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
20
+ "pk": "pk_%(table_name)s",
21
+ }
22
+
23
+
24
+ class Base(DeclarativeBase):
25
+ metadata = MetaData(naming_convention=convention)
26
+
27
+
28
+ async def get_session() -> AsyncGenerator[AsyncSession]:
29
+ async with async_session_factory() as session:
30
+ yield session
31
+
32
+
33
+ # (table, column, sqlite_def, postgres_def)
34
+ _MIGRATIONS: list[tuple[str, str, str, str]] = [
35
+ ("servers", "sftp_read_only", "BOOLEAN DEFAULT 0", "BOOLEAN DEFAULT FALSE"),
36
+ ("users", "totp_secret", "VARCHAR(255) DEFAULT ''", "VARCHAR(255) DEFAULT ''"),
37
+ ("users", "totp_enabled", "BOOLEAN DEFAULT 0", "BOOLEAN DEFAULT FALSE"),
38
+ (
39
+ "servers",
40
+ "jump_via_id",
41
+ "INTEGER REFERENCES servers(id)",
42
+ "INTEGER REFERENCES servers(id)",
43
+ ),
44
+ ]
45
+
46
+
47
+ async def init_db() -> None:
48
+ dialect = engine.dialect.name # "sqlite", "postgresql", ...
49
+ async with engine.begin() as conn:
50
+ await conn.run_sync(Base.metadata.create_all)
51
+ # Lightweight migrations: each in its OWN transaction. PostgreSQL aborts
52
+ # the whole transaction on any error (even one we catch), so running
53
+ # multiple ALTERs in a single txn would poison create_all on a fresh DB
54
+ # the moment the first "column already exists" fires.
55
+ for table, column, sqlite_def, pg_def in _MIGRATIONS:
56
+ col_def = pg_def if dialect == "postgresql" else sqlite_def
57
+ sql = f"ALTER TABLE {table} ADD COLUMN {column} {col_def}"
58
+ try:
59
+ async with engine.begin() as conn:
60
+ await conn.execute(text(sql))
61
+ logger.info("Migration applied: %s.%s", table, column)
62
+ except Exception:
63
+ pass # Column already exists, or table not yet present on fresh DB
64
+
65
+
66
+ async def close_db() -> None:
67
+ await engine.dispose()
@@ -0,0 +1,90 @@
1
+ """Demo mode seeding: pre-populates the demo user and showcase servers.
2
+
3
+ Activated when WEBGATE_DEMO_MODE=true. Idempotent — safe to call on every startup.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import logging
10
+
11
+ from sqlalchemy.ext.asyncio import AsyncSession
12
+
13
+ from webgate.auth.service import create_user, get_user_by_username
14
+ from sqlalchemy import select
15
+
16
+ from webgate.servers.models import ServerCreate
17
+ from webgate.servers.service import create_server, list_servers
18
+ from webgate.snippets.models import Snippet
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ DEMO_USERNAME = "demo"
24
+ DEMO_PASSWORD = "demo"
25
+
26
+ def _bastion() -> ServerCreate:
27
+ return ServerCreate(
28
+ name="bastion",
29
+ hostname="127.0.0.1",
30
+ port=22,
31
+ username="demo",
32
+ password="demo",
33
+ group="demo",
34
+ tags=["demo", "bastion"],
35
+ description="Public SSH bastion. Other servers proxy through this one.",
36
+ sftp_read_only=True,
37
+ )
38
+
39
+
40
+ def _internal(jump_via_id: int) -> ServerCreate:
41
+ return ServerCreate(
42
+ name="internal-app",
43
+ hostname="127.0.0.1",
44
+ port=22,
45
+ username="demo",
46
+ password="demo",
47
+ group="demo",
48
+ tags=["demo", "internal"],
49
+ description="App server reachable only via the bastion (jump-host demo).",
50
+ sftp_read_only=True,
51
+ jump_via_id=jump_via_id,
52
+ )
53
+
54
+
55
+ async def seed_demo(session: AsyncSession) -> None:
56
+ user = await get_user_by_username(session, DEMO_USERNAME)
57
+ if user is None:
58
+ user = await create_user(session, DEMO_USERNAME, DEMO_PASSWORD, is_admin=False)
59
+ user.must_change_password = False
60
+ user.allowed_groups = json.dumps(["demo"])
61
+ await session.commit()
62
+ logger.info("Created demo user (%s/%s)", DEMO_USERNAME, DEMO_PASSWORD)
63
+
64
+ existing = await list_servers(session, user.id, is_admin=True)
65
+ existing_names = {s.name for s in existing}
66
+
67
+ bastion_id: int | None = next((s.id for s in existing if s.name == "bastion"), None)
68
+ if "bastion" not in existing_names:
69
+ bastion = await create_server(session, _bastion(), user.id)
70
+ bastion_id = bastion.id
71
+ logger.info("Seeded demo bastion (id=%s)", bastion_id)
72
+
73
+ if "internal-app" not in existing_names and bastion_id is not None:
74
+ await create_server(session, _internal(bastion_id), user.id)
75
+ logger.info("Seeded internal-app via bastion id=%s", bastion_id)
76
+
77
+ # Seed a few example snippets so the toolbar isn't empty
78
+ existing_snippets = (
79
+ await session.execute(select(Snippet).where(Snippet.user_id == user.id))
80
+ ).scalars().all()
81
+ if not existing_snippets:
82
+ for name, cmd, desc in [
83
+ ("ls -lah", "ls -lah", "List files (long, human sizes, hidden)"),
84
+ ("disk usage", "df -h", "Mounted filesystems with sizes"),
85
+ ("top procs", "ps aux --sort=-%mem | head", "Top memory-consuming processes"),
86
+ ("uptime", "uptime && uname -a", "Uptime + kernel info"),
87
+ ]:
88
+ session.add(Snippet(name=name, command=cmd, description=desc, user_id=user.id))
89
+ await session.commit()
90
+ logger.info("Seeded %d demo snippets", 4)
@@ -25,6 +25,7 @@ class _PoolEntry:
25
25
  client: SFTPClient
26
26
  last_used: float = field(default_factory=time.monotonic)
27
27
  in_use: int = 0
28
+ jump_conn: asyncssh.SSHClientConnection | None = None
28
29
 
29
30
 
30
31
  class SFTPPool:
@@ -63,6 +64,7 @@ class SFTPPool:
63
64
  username: str,
64
65
  password: str | None = None,
65
66
  private_key: str | None = None,
67
+ jump_kwargs: dict[str, object] | None = None,
66
68
  ) -> SFTPClient:
67
69
  key = self._key(server_id)
68
70
  if key not in self._locks:
@@ -96,10 +98,15 @@ class SFTPPool:
96
98
  elif password:
97
99
  kwargs["password"] = password
98
100
 
101
+ jump_conn: asyncssh.SSHClientConnection | None = None
102
+ if jump_kwargs:
103
+ jump_conn = await asyncssh.connect(**jump_kwargs) # type: ignore[arg-type]
104
+ kwargs["tunnel"] = jump_conn
105
+
99
106
  conn = await asyncssh.connect(**kwargs) # type: ignore[arg-type]
100
107
  client = SFTPClient(conn)
101
108
  await client.connect()
102
- entry = _PoolEntry(conn=conn, client=client, in_use=1)
109
+ entry = _PoolEntry(conn=conn, client=client, in_use=1, jump_conn=jump_conn)
103
110
  self._pool[key] = entry
104
111
  logger.info("SFTP pool: new connection to %s:%s (server_id=%s)", hostname, port, server_id)
105
112
  return client
@@ -120,6 +127,11 @@ class SFTPPool:
120
127
  entry.conn.close()
121
128
  except Exception:
122
129
  pass
130
+ if entry.jump_conn is not None:
131
+ try:
132
+ entry.jump_conn.close()
133
+ except Exception:
134
+ pass
123
135
 
124
136
  async def _cleanup_loop(self) -> None:
125
137
  while True:
@@ -25,7 +25,7 @@ from webgate.files.models import (
25
25
  from webgate.files.pool import sftp_pool
26
26
  from webgate.files.sftp_service import SFTPClient, validate_path
27
27
  from webgate.servers.models import Server
28
- from webgate.servers.service import get_server, get_server_credentials
28
+ from webgate.servers.service import get_server, get_server_credentials, resolve_jump_creds
29
29
 
30
30
  router = APIRouter(prefix="/api/files", tags=["files"])
31
31
 
@@ -88,6 +88,7 @@ async def _sftp(
88
88
  allowed_paths = _get_allowed_paths(server)
89
89
  read_only = server.sftp_read_only
90
90
  password, private_key = get_server_credentials(server)
91
+ jump_kwargs = await resolve_jump_creds(session, server)
91
92
  try:
92
93
  client = await sftp_pool.acquire(
93
94
  server_id,
@@ -96,6 +97,7 @@ async def _sftp(
96
97
  username=server.username,
97
98
  password=password,
98
99
  private_key=private_key,
100
+ jump_kwargs=jump_kwargs,
99
101
  )
100
102
  yield client, allowed_paths, read_only
101
103
  finally:
@@ -25,6 +25,9 @@ class Server(Base):
25
25
  sftp_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
26
26
  sftp_allowed_paths: Mapped[str] = mapped_column(Text, default="[]") # JSON array of paths
27
27
  sftp_read_only: Mapped[bool] = mapped_column(Boolean, default=False)
28
+ jump_via_id: Mapped[int | None] = mapped_column(
29
+ Integer, ForeignKey("servers.id"), nullable=True
30
+ )
28
31
  last_connected_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
29
32
  created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
30
33
  user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"))
@@ -45,6 +48,7 @@ class ServerCreate(BaseModel):
45
48
  sftp_enabled: bool = True
46
49
  sftp_allowed_paths: list[str] = []
47
50
  sftp_read_only: bool = False
51
+ jump_via_id: int | None = None
48
52
 
49
53
 
50
54
  class ServerUpdate(BaseModel):
@@ -62,6 +66,7 @@ class ServerUpdate(BaseModel):
62
66
  sftp_enabled: bool | None = None
63
67
  sftp_allowed_paths: list[str] | None = None
64
68
  sftp_read_only: bool | None = None
69
+ jump_via_id: int | None = None
65
70
 
66
71
 
67
72
  class ServerOut(BaseModel):
@@ -78,6 +83,7 @@ class ServerOut(BaseModel):
78
83
  sftp_enabled: bool
79
84
  sftp_allowed_paths: list[str]
80
85
  sftp_read_only: bool
86
+ jump_via_id: int | None = None
81
87
  last_connected_at: datetime | None
82
88
  created_at: datetime
83
89
 
@@ -154,7 +154,7 @@ async def test_connectivity(
154
154
  server = await get_server(session, server_id, current_user.id, **_user_kwargs(current_user))
155
155
  if not server:
156
156
  raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Server not found")
157
- success, message = await test_server_connectivity(server)
157
+ success, message = await test_server_connectivity(server, session)
158
158
  if success:
159
159
  await update_last_connected(session, server)
160
160
  return {"success": success, "message": message}
@@ -53,6 +53,7 @@ def server_to_out(server: Server) -> ServerOut:
53
53
  sftp_enabled=server.sftp_enabled,
54
54
  sftp_allowed_paths=_paths_from_json(server.sftp_allowed_paths),
55
55
  sftp_read_only=server.sftp_read_only,
56
+ jump_via_id=server.jump_via_id,
56
57
  last_connected_at=server.last_connected_at,
57
58
  created_at=server.created_at,
58
59
  )
@@ -125,6 +126,7 @@ async def create_server(session: AsyncSession, data: ServerCreate, user_id: int)
125
126
  sftp_enabled=data.sftp_enabled,
126
127
  sftp_allowed_paths=_tags_to_json(data.sftp_allowed_paths),
127
128
  sftp_read_only=data.sftp_read_only,
129
+ jump_via_id=data.jump_via_id,
128
130
  user_id=user_id,
129
131
  )
130
132
  session.add(server)
@@ -166,6 +168,8 @@ async def update_server(
166
168
  server.sftp_allowed_paths = _tags_to_json(data.sftp_allowed_paths)
167
169
  if data.sftp_read_only is not None:
168
170
  server.sftp_read_only = data.sftp_read_only
171
+ if data.jump_via_id is not None:
172
+ server.jump_via_id = data.jump_via_id or None # 0 means "clear"
169
173
  await session.commit()
170
174
  await session.refresh(server)
171
175
  return server
@@ -176,7 +180,9 @@ async def delete_server(session: AsyncSession, server: Server) -> None:
176
180
  await session.commit()
177
181
 
178
182
 
179
- async def test_server_connectivity(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()