webgate 2.5.0__tar.gz → 2.6.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 (202) hide show
  1. {webgate-2.5.0 → webgate-2.6.0}/CHANGELOG.md +45 -0
  2. {webgate-2.5.0 → webgate-2.6.0}/PKG-INFO +12 -12
  3. {webgate-2.5.0 → webgate-2.6.0}/README.md +11 -11
  4. {webgate-2.5.0 → webgate-2.6.0}/ROADMAP.md +6 -2
  5. webgate-2.6.0/VERSION +1 -0
  6. {webgate-2.5.0 → webgate-2.6.0}/docs/LOCAL_TESTING.md +1 -1
  7. {webgate-2.5.0 → webgate-2.6.0}/docs/changelog.md +45 -0
  8. {webgate-2.5.0 → webgate-2.6.0}/docs/guide/users.md +2 -2
  9. webgate-2.6.0/docs/screenshots/v2/audit.png +0 -0
  10. webgate-2.6.0/docs/screenshots/v2/palette.png +0 -0
  11. webgate-2.6.0/docs/screenshots/v2/settings.png +0 -0
  12. webgate-2.6.0/docs/screenshots/v2/sftp.png +0 -0
  13. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/v2/site-manager.png +0 -0
  14. {webgate-2.5.0 → webgate-2.6.0}/pyproject.toml +1 -1
  15. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/config.py +1 -0
  16. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/files/routes.py +5 -1
  17. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/runtime_config/registry.py +17 -0
  18. webgate-2.6.0/src/webgate/servers/crypto.py +47 -0
  19. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/servers/monitor.py +17 -6
  20. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/servers/service.py +21 -7
  21. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/static/index.html +62 -6
  22. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/terminal/routes.py +9 -1
  23. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/terminal/shared.py +10 -0
  24. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/terminal/ws_handler.py +21 -0
  25. {webgate-2.5.0 → webgate-2.6.0}/tests/e2e/test_ui.py +4 -2
  26. webgate-2.6.0/tests/test_credential_rotation.py +124 -0
  27. webgate-2.6.0/tests/test_session_limit.py +168 -0
  28. {webgate-2.5.0 → webgate-2.6.0}/uv.lock +1 -1
  29. webgate-2.5.0/VERSION +0 -1
  30. webgate-2.5.0/docs/screenshots/v2/audit.png +0 -0
  31. webgate-2.5.0/docs/screenshots/v2/palette.png +0 -0
  32. webgate-2.5.0/docs/screenshots/v2/settings.png +0 -0
  33. webgate-2.5.0/docs/screenshots/v2/sftp.png +0 -0
  34. webgate-2.5.0/src/webgate/servers/crypto.py +0 -27
  35. {webgate-2.5.0 → webgate-2.6.0}/.dockerignore +0 -0
  36. {webgate-2.5.0 → webgate-2.6.0}/.github/workflows/ci.yml +0 -0
  37. {webgate-2.5.0 → webgate-2.6.0}/.github/workflows/demo.yml +0 -0
  38. {webgate-2.5.0 → webgate-2.6.0}/.github/workflows/docs.yml +0 -0
  39. {webgate-2.5.0 → webgate-2.6.0}/.github/workflows/release.yml +0 -0
  40. {webgate-2.5.0 → webgate-2.6.0}/.gitignore +0 -0
  41. {webgate-2.5.0 → webgate-2.6.0}/DESIGN.md +0 -0
  42. {webgate-2.5.0 → webgate-2.6.0}/Dockerfile +0 -0
  43. {webgate-2.5.0 → webgate-2.6.0}/Dockerfile.demo +0 -0
  44. {webgate-2.5.0 → webgate-2.6.0}/Dockerfile.ssh-demo +0 -0
  45. {webgate-2.5.0 → webgate-2.6.0}/LICENSE +0 -0
  46. {webgate-2.5.0 → webgate-2.6.0}/PRODUCT.md +0 -0
  47. {webgate-2.5.0 → webgate-2.6.0}/RELEASING.md +0 -0
  48. {webgate-2.5.0 → webgate-2.6.0}/compose.dev.yml +0 -0
  49. {webgate-2.5.0 → webgate-2.6.0}/compose.ha.yml +0 -0
  50. {webgate-2.5.0 → webgate-2.6.0}/compose.playground.yml +0 -0
  51. {webgate-2.5.0 → webgate-2.6.0}/compose.yml +0 -0
  52. {webgate-2.5.0 → webgate-2.6.0}/deploy/nginx-ha.conf +0 -0
  53. {webgate-2.5.0 → webgate-2.6.0}/docs/api/auth.md +0 -0
  54. {webgate-2.5.0 → webgate-2.6.0}/docs/api/files.md +0 -0
  55. {webgate-2.5.0 → webgate-2.6.0}/docs/api/servers.md +0 -0
  56. {webgate-2.5.0 → webgate-2.6.0}/docs/api/terminal.md +0 -0
  57. {webgate-2.5.0 → webgate-2.6.0}/docs/architecture.md +0 -0
  58. {webgate-2.5.0 → webgate-2.6.0}/docs/getting-started/installation.md +0 -0
  59. {webgate-2.5.0 → webgate-2.6.0}/docs/getting-started/quickstart.md +0 -0
  60. {webgate-2.5.0 → webgate-2.6.0}/docs/getting-started/upgrade.md +0 -0
  61. {webgate-2.5.0 → webgate-2.6.0}/docs/guide/access-control.md +0 -0
  62. {webgate-2.5.0 → webgate-2.6.0}/docs/guide/advanced.md +0 -0
  63. {webgate-2.5.0 → webgate-2.6.0}/docs/guide/files.md +0 -0
  64. {webgate-2.5.0 → webgate-2.6.0}/docs/guide/ha.md +0 -0
  65. {webgate-2.5.0 → webgate-2.6.0}/docs/guide/integrations.md +0 -0
  66. {webgate-2.5.0 → webgate-2.6.0}/docs/guide/servers.md +0 -0
  67. {webgate-2.5.0 → webgate-2.6.0}/docs/guide/settings.md +0 -0
  68. {webgate-2.5.0 → webgate-2.6.0}/docs/guide/split.md +0 -0
  69. {webgate-2.5.0 → webgate-2.6.0}/docs/guide/terminal.md +0 -0
  70. {webgate-2.5.0 → webgate-2.6.0}/docs/index.md +0 -0
  71. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/access-control.png +0 -0
  72. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/audit.png +0 -0
  73. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/edit-access-control.png +0 -0
  74. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/editor.png +0 -0
  75. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/light-theme.png +0 -0
  76. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/login.png +0 -0
  77. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/new-server-form.png +0 -0
  78. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/playground/01-login.png +0 -0
  79. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/playground/02-alice-ldap-empty.png +0 -0
  80. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/playground/03-add-server-jump-via.png +0 -0
  81. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/playground/04-dashboard-3-servers.png +0 -0
  82. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/playground/05-terminal-via-jump-host.png +0 -0
  83. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/playground/06-snippet-whoami.png +0 -0
  84. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/playground/07-shared-joiner.png +0 -0
  85. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/playground/07-shared-owner.png +0 -0
  86. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/playground/08-webhook-fired.png +0 -0
  87. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/playground/09-recordings-list.png +0 -0
  88. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/playground/10-recording-replay.png +0 -0
  89. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/playground/11-bob-limited.png +0 -0
  90. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/sftp-restricted.png +0 -0
  91. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/sftp.png +0 -0
  92. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/site-manager.png +0 -0
  93. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/split-view.png +0 -0
  94. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/ssh-disabled.png +0 -0
  95. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/terminal.png +0 -0
  96. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/users.png +0 -0
  97. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/v0.3/01-login-demo-banner.png +0 -0
  98. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/v0.3/02-dashboard-jump-host.png +0 -0
  99. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/v0.3/03-terminal-snippets-jump.png +0 -0
  100. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/v0.3/04-snippet-executed.png +0 -0
  101. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/v0.3/05-sftp-via-jump.png +0 -0
  102. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/v0.3/06-webhooks-modal.png +0 -0
  103. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/v0.3/07-webhook-test-fired.png +0 -0
  104. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/v0.3/08-add-server-jump-via.png +0 -0
  105. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/v0.4/01-shared-terminal-owner.png +0 -0
  106. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/v0.4/02-shared-terminal-joiner.png +0 -0
  107. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/v0.4/03-recording-replay.png +0 -0
  108. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/v0.4/04-recordings-modal.png +0 -0
  109. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/v2/agent.png +0 -0
  110. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/v2/branding.png +0 -0
  111. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/v2/light.png +0 -0
  112. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/v2/login.png +0 -0
  113. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/v2/terminal.png +0 -0
  114. {webgate-2.5.0 → webgate-2.6.0}/docs/screenshots/v2/users.png +0 -0
  115. {webgate-2.5.0 → webgate-2.6.0}/fly.toml +0 -0
  116. {webgate-2.5.0 → webgate-2.6.0}/mkdocs.yml +0 -0
  117. {webgate-2.5.0 → webgate-2.6.0}/scripts/release_notes.py +0 -0
  118. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/__init__.py +0 -0
  119. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/__main__.py +0 -0
  120. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/agent/__init__.py +0 -0
  121. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/agent/cache.py +0 -0
  122. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/agent/conversation.py +0 -0
  123. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/agent/memory.py +0 -0
  124. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/agent/models.py +0 -0
  125. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/agent/provider.py +0 -0
  126. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/agent/routes.py +0 -0
  127. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/agent/service.py +0 -0
  128. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/agent/sftp_tools.py +0 -0
  129. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/agent/store.py +0 -0
  130. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/agent/tools.py +0 -0
  131. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/app.py +0 -0
  132. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/audit/__init__.py +0 -0
  133. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/audit/models.py +0 -0
  134. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/audit/service.py +0 -0
  135. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/auth/__init__.py +0 -0
  136. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/auth/ldap.py +0 -0
  137. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/auth/models.py +0 -0
  138. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/auth/routes.py +0 -0
  139. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/auth/service.py +0 -0
  140. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/backup/__init__.py +0 -0
  141. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/backup/models.py +0 -0
  142. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/backup/routes.py +0 -0
  143. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/backup/service.py +0 -0
  144. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/branding/__init__.py +0 -0
  145. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/branding/routes.py +0 -0
  146. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/branding/store.py +0 -0
  147. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/db/__init__.py +0 -0
  148. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/db/engine.py +0 -0
  149. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/demo.py +0 -0
  150. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/files/__init__.py +0 -0
  151. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/files/limits.py +0 -0
  152. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/files/models.py +0 -0
  153. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/files/pool.py +0 -0
  154. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/files/sftp_service.py +0 -0
  155. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/recordings/__init__.py +0 -0
  156. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/recordings/models.py +0 -0
  157. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/recordings/recorder.py +0 -0
  158. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/recordings/routes.py +0 -0
  159. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/runtime_config/__init__.py +0 -0
  160. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/runtime_config/routes.py +0 -0
  161. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/runtime_config/store.py +0 -0
  162. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/servers/__init__.py +0 -0
  163. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/servers/hostkeys.py +0 -0
  164. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/servers/models.py +0 -0
  165. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/servers/routes.py +0 -0
  166. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/snippets/__init__.py +0 -0
  167. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/snippets/models.py +0 -0
  168. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/snippets/routes.py +0 -0
  169. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/terminal/__init__.py +0 -0
  170. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/terminal/ssh_session.py +0 -0
  171. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/webhooks/__init__.py +0 -0
  172. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/webhooks/dispatcher.py +0 -0
  173. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/webhooks/models.py +0 -0
  174. {webgate-2.5.0 → webgate-2.6.0}/src/webgate/webhooks/routes.py +0 -0
  175. {webgate-2.5.0 → webgate-2.6.0}/tests/__init__.py +0 -0
  176. {webgate-2.5.0 → webgate-2.6.0}/tests/conftest.py +0 -0
  177. {webgate-2.5.0 → webgate-2.6.0}/tests/e2e/conftest.py +0 -0
  178. {webgate-2.5.0 → webgate-2.6.0}/tests/e2e/sshlab.py +0 -0
  179. {webgate-2.5.0 → webgate-2.6.0}/tests/test_agent_conversation.py +0 -0
  180. {webgate-2.5.0 → webgate-2.6.0}/tests/test_agent_memory.py +0 -0
  181. {webgate-2.5.0 → webgate-2.6.0}/tests/test_agent_settings.py +0 -0
  182. {webgate-2.5.0 → webgate-2.6.0}/tests/test_agent_sftp_tools.py +0 -0
  183. {webgate-2.5.0 → webgate-2.6.0}/tests/test_agent_tools.py +0 -0
  184. {webgate-2.5.0 → webgate-2.6.0}/tests/test_audit_coverage.py +0 -0
  185. {webgate-2.5.0 → webgate-2.6.0}/tests/test_auth.py +0 -0
  186. {webgate-2.5.0 → webgate-2.6.0}/tests/test_backup.py +0 -0
  187. {webgate-2.5.0 → webgate-2.6.0}/tests/test_branding.py +0 -0
  188. {webgate-2.5.0 → webgate-2.6.0}/tests/test_files.py +0 -0
  189. {webgate-2.5.0 → webgate-2.6.0}/tests/test_files_listing.py +0 -0
  190. {webgate-2.5.0 → webgate-2.6.0}/tests/test_host_keys.py +0 -0
  191. {webgate-2.5.0 → webgate-2.6.0}/tests/test_idle_sessions.py +0 -0
  192. {webgate-2.5.0 → webgate-2.6.0}/tests/test_limits.py +0 -0
  193. {webgate-2.5.0 → webgate-2.6.0}/tests/test_migrations.py +0 -0
  194. {webgate-2.5.0 → webgate-2.6.0}/tests/test_monitor.py +0 -0
  195. {webgate-2.5.0 → webgate-2.6.0}/tests/test_recordings.py +0 -0
  196. {webgate-2.5.0 → webgate-2.6.0}/tests/test_release_notes.py +0 -0
  197. {webgate-2.5.0 → webgate-2.6.0}/tests/test_secret_key_gate.py +0 -0
  198. {webgate-2.5.0 → webgate-2.6.0}/tests/test_servers.py +0 -0
  199. {webgate-2.5.0 → webgate-2.6.0}/tests/test_settings.py +0 -0
  200. {webgate-2.5.0 → webgate-2.6.0}/tests/test_snippets.py +0 -0
  201. {webgate-2.5.0 → webgate-2.6.0}/tests/test_terminal.py +0 -0
  202. {webgate-2.5.0 → webgate-2.6.0}/tests/test_webhook_dispatch.py +0 -0
@@ -1,5 +1,50 @@
1
1
  # Changelog
2
2
 
3
+ ## v2.6.0 (2026-09-16) — rotating the secret key no longer breaks everything
4
+
5
+ ### Fixed
6
+
7
+ - **A changed `WEBGATE_SECRET_KEY` made webgate fall over instead of explaining
8
+ itself.** v2.2.0 refuses the shipped default, which asks every existing install to
9
+ set a real one; doing that leaves rows encrypted under the old key. `decrypt_value`
10
+ raised a bare Fernet error from twelve call sites and none of them caught it. The
11
+ status monitor died on its first sweep and froze every server's dot; opening a
12
+ terminal failed the WebSocket **upgrade**, so the browser got a 500 with nothing in
13
+ it. Found on the public demo, by walking into it.
14
+
15
+ An unreadable credential is now its own error, naming the server, why it happened
16
+ and what to do. The monitor marks that one server offline and carries on, the
17
+ terminal answers with an error frame, SFTP returns 502, and a connection test
18
+ reports it.
19
+
20
+ - **The demo banner covered the top of the interface.** It was `position: fixed` with
21
+ nothing making room for it, so the app bar sat underneath. It is in flow now, which
22
+ also holds when it wraps to two lines on a narrow screen. Only visible with
23
+ `WEBGATE_DEMO_MODE=true`, which is why no test caught it.
24
+
25
+ - **Diagrams that did not show.** Nine style directives set a pale fill and left the
26
+ label to the theme, so on GitHub's dark background they were light text on a light
27
+ box. All 21 diagrams in the repository now render, and every label is readable on
28
+ both grounds — checked with the Mermaid version GitHub actually uses, in a browser,
29
+ one diagram per page.
30
+
31
+ ### Added
32
+
33
+ - **The pinned host key is visible.** The fingerprint has been in the API since
34
+ v2.0.0 and nothing showed it. Servers carry a shield when a key is pinned, the edit
35
+ form shows the fingerprint, and an admin can clear it there — with the consequence
36
+ spelled out, since accepting a new key is the one moment the protection is stood
37
+ down.
38
+ - **A cap on concurrent SSH sessions per account** (**Admin → Settings → Security**,
39
+ 0 for none). There was nothing stopping a looping tab from opening sessions until
40
+ the worker ran out. Checked before the SSH connection is made, and counted per
41
+ worker — which the setting says, because a gateway behind a load balancer allows
42
+ that many on each.
43
+
44
+ 333 tests.
45
+
46
+ ---
47
+
3
48
  ## v2.5.0 (2026-09-16) — browser tests
4
49
 
5
50
  The audit gap in v2.4.0 was reported by a person using the interface, and no test in
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: webgate
3
- Version: 2.5.0
3
+ Version: 2.6.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/
@@ -126,9 +126,9 @@ flowchart TB
126
126
  GW -. "SSH :22" .-> APP2
127
127
  GW -. "SSH :22" .-> WORKER
128
128
  GW -. "SSH :22" .-> REDIS
129
- style internet fill:#e8f0fe,stroke:#4a90d9
130
- style firewall fill:#fff3e0,stroke:#ff9800
131
- style internal fill:#f0f9e8,stroke:#5cb85c
129
+ style internet fill:none,stroke:#4a90d9,stroke-width:2px
130
+ style firewall fill:none,stroke:#ff9800,stroke-width:2px
131
+ style internal fill:none,stroke:#5cb85c,stroke-width:2px
132
132
  style GW fill:#ffcc02,stroke:#e6a800,color:#333
133
133
  style YOU fill:#4a90d9,stroke:#2a6cb5,color:#fff
134
134
  ```
@@ -160,9 +160,9 @@ flowchart TB
160
160
  WG -- "SSH/SFTP" --> APP1
161
161
  WG -- "SSH/SFTP" --> WORKER
162
162
  WG -- "SSH/SFTP" --> REDIS
163
- style internet fill:#e8f0fe,stroke:#4a90d9
164
- style firewall fill:#fff3e0,stroke:#ff9800
165
- style internal fill:#f0f9e8,stroke:#5cb85c
163
+ style internet fill:none,stroke:#4a90d9,stroke-width:2px
164
+ style firewall fill:none,stroke:#ff9800,stroke-width:2px
165
+ style internal fill:none,stroke:#5cb85c,stroke-width:2px
166
166
  style WG fill:#5cb85c,stroke:#449d44,color:#fff
167
167
  style ENG1 fill:#4a90d9,stroke:#2a6cb5,color:#fff
168
168
  style ENG2 fill:#4a90d9,stroke:#2a6cb5,color:#fff
@@ -355,7 +355,7 @@ flowchart LR
355
355
  style B fill:#4a90d9,stroke:#2a6cb5,color:#fff
356
356
  style WG fill:#5cb85c,stroke:#449d44,color:#fff
357
357
  style BAST fill:#ffcc02,stroke:#e6a800,color:#333
358
- style INT fill:#fff3e0,stroke:#ff9800
358
+ style INT fill:#b25e00,stroke:#ff9800,color:#fff
359
359
  ```
360
360
 
361
361
  ### Shared terminal session
@@ -407,7 +407,7 @@ flowchart LR
407
407
  L1["alice ∈ cn=devs"]
408
408
  L2["alice ∈ cn=admins"]
409
409
  end
410
- subgraph "Group mapping<br/>(Admin → Settings → LDAP)"
410
+ subgraph MAP["Group mapping<br/>Admin &rarr; Settings &rarr; LDAP"]
411
411
  M["{<br/> &quot;devs&quot;: &quot;production&quot;,<br/> &quot;sre&quot;: &quot;all&quot;<br/>}"]
412
412
  end
413
413
  subgraph User
@@ -650,9 +650,9 @@ flowchart LR
650
650
  W3 -. watches .-> LEASE
651
651
  style LB fill:#ffcc02,stroke:#e6a800,color:#333
652
652
  style W1 fill:#5cb85c,stroke:#449d44,color:#fff
653
- style W2 fill:#e8f0fe,stroke:#4a90d9
654
- style W3 fill:#e8f0fe,stroke:#4a90d9
655
- style PG fill:#fff3e0,stroke:#ff9800
653
+ style W2 fill:#2a6cb5,stroke:#4a90d9,color:#fff
654
+ style W3 fill:#2a6cb5,stroke:#4a90d9,color:#fff
655
+ style PG fill:#b25e00,stroke:#ff9800,color:#fff
656
656
  ```
657
657
 
658
658
  Reference stack:
@@ -77,9 +77,9 @@ flowchart TB
77
77
  GW -. "SSH :22" .-> APP2
78
78
  GW -. "SSH :22" .-> WORKER
79
79
  GW -. "SSH :22" .-> REDIS
80
- style internet fill:#e8f0fe,stroke:#4a90d9
81
- style firewall fill:#fff3e0,stroke:#ff9800
82
- style internal fill:#f0f9e8,stroke:#5cb85c
80
+ style internet fill:none,stroke:#4a90d9,stroke-width:2px
81
+ style firewall fill:none,stroke:#ff9800,stroke-width:2px
82
+ style internal fill:none,stroke:#5cb85c,stroke-width:2px
83
83
  style GW fill:#ffcc02,stroke:#e6a800,color:#333
84
84
  style YOU fill:#4a90d9,stroke:#2a6cb5,color:#fff
85
85
  ```
@@ -111,9 +111,9 @@ flowchart TB
111
111
  WG -- "SSH/SFTP" --> APP1
112
112
  WG -- "SSH/SFTP" --> WORKER
113
113
  WG -- "SSH/SFTP" --> REDIS
114
- style internet fill:#e8f0fe,stroke:#4a90d9
115
- style firewall fill:#fff3e0,stroke:#ff9800
116
- style internal fill:#f0f9e8,stroke:#5cb85c
114
+ style internet fill:none,stroke:#4a90d9,stroke-width:2px
115
+ style firewall fill:none,stroke:#ff9800,stroke-width:2px
116
+ style internal fill:none,stroke:#5cb85c,stroke-width:2px
117
117
  style WG fill:#5cb85c,stroke:#449d44,color:#fff
118
118
  style ENG1 fill:#4a90d9,stroke:#2a6cb5,color:#fff
119
119
  style ENG2 fill:#4a90d9,stroke:#2a6cb5,color:#fff
@@ -306,7 +306,7 @@ flowchart LR
306
306
  style B fill:#4a90d9,stroke:#2a6cb5,color:#fff
307
307
  style WG fill:#5cb85c,stroke:#449d44,color:#fff
308
308
  style BAST fill:#ffcc02,stroke:#e6a800,color:#333
309
- style INT fill:#fff3e0,stroke:#ff9800
309
+ style INT fill:#b25e00,stroke:#ff9800,color:#fff
310
310
  ```
311
311
 
312
312
  ### Shared terminal session
@@ -358,7 +358,7 @@ flowchart LR
358
358
  L1["alice ∈ cn=devs"]
359
359
  L2["alice ∈ cn=admins"]
360
360
  end
361
- subgraph "Group mapping<br/>(Admin → Settings → LDAP)"
361
+ subgraph MAP["Group mapping<br/>Admin &rarr; Settings &rarr; LDAP"]
362
362
  M["{<br/> &quot;devs&quot;: &quot;production&quot;,<br/> &quot;sre&quot;: &quot;all&quot;<br/>}"]
363
363
  end
364
364
  subgraph User
@@ -601,9 +601,9 @@ flowchart LR
601
601
  W3 -. watches .-> LEASE
602
602
  style LB fill:#ffcc02,stroke:#e6a800,color:#333
603
603
  style W1 fill:#5cb85c,stroke:#449d44,color:#fff
604
- style W2 fill:#e8f0fe,stroke:#4a90d9
605
- style W3 fill:#e8f0fe,stroke:#4a90d9
606
- style PG fill:#fff3e0,stroke:#ff9800
604
+ style W2 fill:#2a6cb5,stroke:#4a90d9,color:#fff
605
+ style W3 fill:#2a6cb5,stroke:#4a90d9,color:#fff
606
+ style PG fill:#b25e00,stroke:#ff9800,color:#fff
607
607
  ```
608
608
 
609
609
  Reference stack:
@@ -90,6 +90,12 @@ Tracks the development plan for **webgate**. Items are organized by release.
90
90
  - [x] **The documentation screenshots are taken by those tests**, so they cannot drift from the shipped interface
91
91
  - [x] **CI runs them on every push**, and uploads what they captured
92
92
 
93
+ ### v2.6.0 — Key rotation, host key UI, session cap (2026-09-16)
94
+ - [x] **A changed secret key is handled** — it used to kill the monitor and fail the terminal's WebSocket upgrade with a bare 500
95
+ - [x] **The pinned host key fingerprint is visible and clearable** from the Site Manager
96
+ - [x] **A per-account cap on concurrent SSH sessions**
97
+ - [x] **The demo banner no longer covers the interface**, and every diagram in the repository renders legibly on both GitHub grounds
98
+
93
99
  ---
94
100
 
95
101
  ## Planned
@@ -97,8 +103,6 @@ Tracks the development plan for **webgate**. Items are organized by release.
97
103
  ### v2.1.x — Next
98
104
  | Feature | Priority | Description |
99
105
  |---------|----------|-------------|
100
- | Per-user concurrent-session limit | Medium | Nothing caps how many SSH sessions one account may hold open |
101
- | Host key fingerprint in the UI | Medium | The API exposes it and can clear it; the Site Manager does not show it yet |
102
106
  | Internationalization (i18n) | Low | UI translations starting with English / Spanish |
103
107
 
104
108
  ### Later
webgate-2.6.0/VERSION ADDED
@@ -0,0 +1 @@
1
+ 2.6.0
@@ -55,7 +55,7 @@ flowchart LR
55
55
  B -- SSH tunnel --> I
56
56
  style WG fill:#5cb85c,stroke:#449d44,color:#fff
57
57
  style B fill:#ffcc02,stroke:#e6a800,color:#333
58
- style I fill:#fff3e0,stroke:#ff9800
58
+ style I fill:#b25e00,stroke:#ff9800,color:#fff
59
59
  ```
60
60
 
61
61
  `webgate` and `ssh-internal` share **no network**, so reaching `ssh-internal` is only possible via the bastion.
@@ -1,5 +1,50 @@
1
1
  # Changelog
2
2
 
3
+ ## v2.6.0 (2026-09-16) — rotating the secret key no longer breaks everything
4
+
5
+ ### Fixed
6
+
7
+ - **A changed `WEBGATE_SECRET_KEY` made webgate fall over instead of explaining
8
+ itself.** v2.2.0 refuses the shipped default, which asks every existing install to
9
+ set a real one; doing that leaves rows encrypted under the old key. `decrypt_value`
10
+ raised a bare Fernet error from twelve call sites and none of them caught it. The
11
+ status monitor died on its first sweep and froze every server's dot; opening a
12
+ terminal failed the WebSocket **upgrade**, so the browser got a 500 with nothing in
13
+ it. Found on the public demo, by walking into it.
14
+
15
+ An unreadable credential is now its own error, naming the server, why it happened
16
+ and what to do. The monitor marks that one server offline and carries on, the
17
+ terminal answers with an error frame, SFTP returns 502, and a connection test
18
+ reports it.
19
+
20
+ - **The demo banner covered the top of the interface.** It was `position: fixed` with
21
+ nothing making room for it, so the app bar sat underneath. It is in flow now, which
22
+ also holds when it wraps to two lines on a narrow screen. Only visible with
23
+ `WEBGATE_DEMO_MODE=true`, which is why no test caught it.
24
+
25
+ - **Diagrams that did not show.** Nine style directives set a pale fill and left the
26
+ label to the theme, so on GitHub's dark background they were light text on a light
27
+ box. All 21 diagrams in the repository now render, and every label is readable on
28
+ both grounds — checked with the Mermaid version GitHub actually uses, in a browser,
29
+ one diagram per page.
30
+
31
+ ### Added
32
+
33
+ - **The pinned host key is visible.** The fingerprint has been in the API since
34
+ v2.0.0 and nothing showed it. Servers carry a shield when a key is pinned, the edit
35
+ form shows the fingerprint, and an admin can clear it there — with the consequence
36
+ spelled out, since accepting a new key is the one moment the protection is stood
37
+ down.
38
+ - **A cap on concurrent SSH sessions per account** (**Admin → Settings → Security**,
39
+ 0 for none). There was nothing stopping a looping tab from opening sessions until
40
+ the worker ran out. Checked before the SSH connection is made, and counted per
41
+ worker — which the setting says, because a gateway behind a load balancer allows
42
+ that many on each.
43
+
44
+ 333 tests.
45
+
46
+ ---
47
+
3
48
  ## v2.5.0 (2026-09-16) — browser tests
4
49
 
5
50
  The audit gap in v2.4.0 was reported by a person using the interface, and no test in
@@ -42,8 +42,8 @@ flowchart LR
42
42
  BOB -->|sees| S3
43
43
 
44
44
  style ADMIN fill:#5cb85c,stroke:#449d44,color:#fff
45
- style ALICE fill:#e8f0fe,stroke:#4a90d9
46
- style BOB fill:#fff3e0,stroke:#ff9800
45
+ style ALICE fill:#2a6cb5,stroke:#4a90d9,color:#fff
46
+ style BOB fill:#b25e00,stroke:#ff9800,color:#fff
47
47
  ```
48
48
 
49
49
  ## Editing Groups
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "webgate"
3
- version = "2.5.0"
3
+ version = "2.6.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"
@@ -61,6 +61,7 @@ class Settings(BaseSettings):
61
61
  disable_monitor: bool = False # Skip leader election; never run server monitor
62
62
  log_level: str = "info"
63
63
  session_timeout: int = 3600
64
+ max_sessions_per_user: int = 0 # 0 = no cap
64
65
  max_upload_size: int = 104857600 # 100MB
65
66
  first_run: bool = True
66
67
 
@@ -29,6 +29,7 @@ from webgate.files.models import (
29
29
  )
30
30
  from webgate.files.pool import sftp_pool
31
31
  from webgate.files.sftp_service import SFTPClient, validate_path
32
+ from webgate.servers.crypto import CredentialUnreadable
32
33
  from webgate.servers.hostkeys import remember, translate
33
34
  from webgate.servers.models import Server
34
35
  from webgate.servers.service import get_server, get_server_credentials, resolve_jump_creds
@@ -111,7 +112,10 @@ async def _sftp(
111
112
  )
112
113
  allowed_paths = _get_allowed_paths(server)
113
114
  read_only = server.sftp_read_only
114
- password, private_key = get_server_credentials(server)
115
+ try:
116
+ password, private_key = get_server_credentials(server)
117
+ except CredentialUnreadable as exc:
118
+ raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
115
119
  jump_kwargs = await resolve_jump_creds(session, server)
116
120
  try:
117
121
  client = await sftp_pool.acquire(
@@ -154,6 +154,23 @@ SPECS: tuple[Spec, ...] = (
154
154
  minimum=0,
155
155
  maximum=86400,
156
156
  ),
157
+ Spec(
158
+ key="max_sessions_per_user",
159
+ section="Security",
160
+ label="Concurrent SSH sessions per user",
161
+ help=(
162
+ "How many terminals one account may hold open on a single worker. 0 means "
163
+ "no limit. Counted per worker, so a gateway behind a load balancer allows "
164
+ "this many on each."
165
+ ),
166
+ kind="int",
167
+ minimum=0,
168
+ maximum=200,
169
+ warning=(
170
+ "0 means nothing stops a looping browser tab from opening SSH sessions "
171
+ "until the worker runs out."
172
+ ),
173
+ ),
157
174
  Spec(
158
175
  key="max_upload_size",
159
176
  section="Security",
@@ -0,0 +1,47 @@
1
+ import base64
2
+ import hashlib
3
+
4
+ from cryptography.fernet import Fernet, InvalidToken
5
+
6
+ from webgate.config import settings
7
+
8
+
9
+ def _derive_key(secret: str) -> bytes:
10
+ digest = hashlib.sha256(secret.encode()).digest()
11
+ return base64.urlsafe_b64encode(digest)
12
+
13
+
14
+ def get_fernet() -> Fernet:
15
+ return Fernet(_derive_key(settings.secret_key))
16
+
17
+
18
+ def encrypt_value(value: str) -> str:
19
+ if not value:
20
+ return ""
21
+ return get_fernet().encrypt(value.encode()).decode()
22
+
23
+
24
+ class CredentialUnreadable(Exception):
25
+ """Stored credentials will not decrypt with the key this process is using.
26
+
27
+ Almost always means WEBGATE_SECRET_KEY changed after the row was written --
28
+ which is exactly what upgrading from the shipped default asks an operator to do.
29
+ Raised as itself rather than as a Fernet error so every caller can say something
30
+ useful instead of returning a 500.
31
+ """
32
+
33
+ def __init__(self, what: str = "These credentials") -> None:
34
+ super().__init__(
35
+ f"{what} cannot be decrypted with the current WEBGATE_SECRET_KEY. The key "
36
+ f"changed after they were saved. Restore a backup taken before the change, "
37
+ f"or re-enter the credentials."
38
+ )
39
+
40
+
41
+ def decrypt_value(token: str, what: str = "These credentials") -> str:
42
+ if not token:
43
+ return ""
44
+ try:
45
+ return get_fernet().decrypt(token.encode()).decode()
46
+ except InvalidToken as exc:
47
+ raise CredentialUnreadable(what) from exc
@@ -26,7 +26,7 @@ from sqlalchemy import select, text
26
26
  from webgate.config import settings
27
27
  from webgate.db.engine import async_session_factory, engine
28
28
  from webgate.runtime_config import store as runtime
29
- from webgate.servers.crypto import decrypt_value
29
+ from webgate.servers.crypto import CredentialUnreadable, decrypt_value
30
30
  from webgate.servers.hostkeys import known_hosts_for
31
31
  from webgate.servers.models import Server
32
32
 
@@ -232,17 +232,28 @@ class ServerMonitor:
232
232
  await asyncio.gather(*[_check_one(s) for s in servers])
233
233
 
234
234
  async def _check_server(self, server: Server) -> ServerStatus:
235
- password = decrypt_value(server.encrypted_password) if server.encrypted_password else None
236
- private_key_str = (
237
- decrypt_value(server.encrypted_private_key) if server.encrypted_private_key else None
238
- )
235
+ now = datetime.now(UTC)
236
+ try:
237
+ password = (
238
+ decrypt_value(server.encrypted_password, server.name)
239
+ if server.encrypted_password
240
+ else None
241
+ )
242
+ private_key_str = (
243
+ decrypt_value(server.encrypted_private_key, server.name)
244
+ if server.encrypted_private_key
245
+ else None
246
+ )
247
+ except CredentialUnreadable as exc:
248
+ # Reported against this server, not raised: the sweep has other hosts to
249
+ # check, and the operator needs to know which one is unreadable.
250
+ return ServerStatus(online=False, last_checked=now, error=str(exc))
239
251
  kwargs: dict[str, object] = {
240
252
  "host": server.hostname,
241
253
  "port": server.port,
242
254
  "username": server.username,
243
255
  "known_hosts": known_hosts_for(getattr(server, "host_key", "") or ""),
244
256
  }
245
- now = datetime.now(UTC)
246
257
  start = time.monotonic()
247
258
  try:
248
259
  # Parsing the key belongs inside the try. An unreadable key row raised out
@@ -8,7 +8,7 @@ import asyncssh
8
8
  from sqlalchemy import select
9
9
  from sqlalchemy.ext.asyncio import AsyncSession
10
10
 
11
- from webgate.servers.crypto import decrypt_value, encrypt_value
11
+ from webgate.servers.crypto import CredentialUnreadable, decrypt_value, encrypt_value
12
12
  from webgate.servers.hostkeys import describe, known_hosts_for, remember, translate
13
13
  from webgate.servers.models import Server, ServerCreate, ServerOut, ServerUpdate
14
14
 
@@ -189,10 +189,19 @@ async def delete_server(session: AsyncSession, server: Server) -> None:
189
189
  async def test_server_connectivity(
190
190
  server: Server, session: AsyncSession | None = None
191
191
  ) -> tuple[bool, str]:
192
- password = decrypt_value(server.encrypted_password) if server.encrypted_password else None
193
- private_key_str = (
194
- decrypt_value(server.encrypted_private_key) if server.encrypted_private_key else None
195
- )
192
+ try:
193
+ password = (
194
+ decrypt_value(server.encrypted_password, server.name)
195
+ if server.encrypted_password
196
+ else None
197
+ )
198
+ private_key_str = (
199
+ decrypt_value(server.encrypted_private_key, server.name)
200
+ if server.encrypted_private_key
201
+ else None
202
+ )
203
+ except CredentialUnreadable as exc:
204
+ return False, str(exc)
196
205
 
197
206
  kwargs: dict[str, object] = {
198
207
  "host": server.hostname,
@@ -289,8 +298,13 @@ async def resolve_jump_creds(session: AsyncSession, server: Server) -> dict[str,
289
298
 
290
299
 
291
300
  def get_server_credentials(server: Server) -> tuple[str | None, str | None]:
292
- password = decrypt_value(server.encrypted_password) if server.encrypted_password else None
301
+ """Raises CredentialUnreadable when the secret key no longer matches the rows."""
302
+ password = (
303
+ decrypt_value(server.encrypted_password, server.name) if server.encrypted_password else None
304
+ )
293
305
  private_key = (
294
- decrypt_value(server.encrypted_private_key) if server.encrypted_private_key else None
306
+ decrypt_value(server.encrypted_private_key, server.name)
307
+ if server.encrypted_private_key
308
+ else None
295
309
  )
296
310
  return password, private_key
@@ -309,7 +309,9 @@
309
309
  .cm-editor .cm-scroller { overflow: auto; }
310
310
 
311
311
  /* Demo banner */
312
- .fz-demo-banner { position: fixed; top: 0; left: 0; right: 0; z-index: 200; background: var(--bg-secondary); border-bottom: 1px solid var(--border); color: var(--text-secondary); text-align: center; font-size: 12px; padding: 6px 12px; display: flex; align-items: center; justify-content: center; gap: 8px; flex-wrap: wrap; }
312
+ .fz-demo-banner { flex: none; z-index: 200; background: var(--bg-secondary); border-bottom: 1px solid var(--border); color: var(--text-secondary); text-align: center; font-size: 12px; padding: 6px 12px; display: flex; align-items: center; justify-content: center; gap: 8px; flex-wrap: wrap; }
313
+ .fz-root { height: 100vh; display: flex; flex-direction: column; }
314
+ .fz-app-shell { flex: 1; min-height: 0; display: flex; flex-direction: column; }
313
315
  .fz-demo-banner strong { color: var(--text-primary); font-weight: 600; }
314
316
  .fz-demo-banner code { background: var(--btn-bg); border: 1px solid var(--border); padding: 1px 5px; border-radius: var(--radius-sm); font-family: var(--mono); font-size: 11px; color: var(--text-primary); }
315
317
  .fz-demo-banner a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
@@ -360,6 +362,12 @@
360
362
  .fz-audit-filters { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; padding: 10px 14px; border-bottom: 1px solid var(--border); }
361
363
  .fz-audit-filters input, .fz-audit-filters select { margin: 0; }
362
364
  .fz-audit-empty { padding: 28px 16px; text-align: center; color: var(--text-muted); font-size: 13px; }
365
+ .fz-srv-badge.pinned { color: var(--accent); }
366
+ .fz-hostkey { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border); }
367
+ .fz-hostkey-head { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; margin-bottom: 8px; }
368
+ .fz-hostkey-head svg { color: var(--accent); }
369
+ .fz-hostkey-head button { margin-left: auto; }
370
+ .fz-hostkey-fp { display: block; font-family: var(--mono); font-size: 11px; word-break: break-all; padding: 7px 9px; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: 4px; }
363
371
  .fz-modal-footer { border-top: 1px solid var(--border); padding: 12px 16px; display: flex; justify-content: flex-end; gap: 8px; position: sticky; bottom: 0; background: var(--modal-bg); }
364
372
  .fz-row-2col { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
365
373
  .fz-table { width: 100%; border-collapse: collapse; font-size: 12px; }
@@ -488,7 +496,7 @@ window._showToastImpl = function(message, type) {
488
496
  <g id="i-play"><path d="M7 4.5 19 12 7 19.5V4.5Z"/></g>
489
497
  <g id="i-save"><path d="M5 3h11l4 4v14H5V3Z"/><path d="M8.5 3v6h7V3M8.5 21v-7h7v7"/></g>
490
498
  </defs></svg>
491
- <div x-data="app()" x-cloak @keydown.window="handleKeyboard($event)">
499
+ <div x-data="app()" x-cloak class="fz-root" @keydown.window="handleKeyboard($event)">
492
500
 
493
501
  <!-- Demo banner (shown when WEBGATE_DEMO_MODE=true) -->
494
502
  <div x-show="demoMode" class="fz-demo-banner">
@@ -539,7 +547,7 @@ window._showToastImpl = function(message, type) {
539
547
  </div>
540
548
 
541
549
  <!-- ======================== MAIN APP ======================== -->
542
- <div :style="token ? 'height:100vh;display:flex;flex-direction:column' : 'display:none'">
550
+ <div class="fz-app-shell" :style="token ? '' : 'display:none'">
543
551
 
544
552
  <!-- ---- App bar ---- -->
545
553
  <header class="fz-appbar">
@@ -744,6 +752,10 @@ window._showToastImpl = function(message, type) {
744
752
  <svg class="fz-icon fz-icon-sm" viewBox="0 0 24 24"><use href="#i-jump"/></svg>
745
753
  </span>
746
754
  <span x-show="srv.sftp_enabled && srv.sftp_read_only" class="fz-srv-badge ro" title="SFTP is read-only">RO</span>
755
+ <span x-show="srv.host_key_fingerprint" class="fz-srv-badge pinned"
756
+ :title="'Host key pinned: ' + srv.host_key_fingerprint">
757
+ <svg class="fz-icon fz-icon-sm" viewBox="0 0 24 24"><use href="#i-shield"/></svg>
758
+ </span>
747
759
  </div>
748
760
  <div class="fz-srv-actions">
749
761
  <button @click.stop="openServerTerminal(srv)" class="fz-btn fz-btn-primary" :disabled="srv.ssh_enabled===false" :title="srv.ssh_enabled===false ? 'SSH is disabled for this server' : 'Open SSH terminal'">SSH</button>
@@ -1713,10 +1725,31 @@ window._showToastImpl = function(message, type) {
1713
1725
  <input type="checkbox" x-model="serverForm.sftp_read_only" style="width:auto;accent-color:var(--warning)"> SFTP Read-Only (browse &amp; download only)
1714
1726
  </label>
1715
1727
  <label x-show="agentAvailable" style="display:flex;align-items:center;gap:8px;margin-top:12px">
1716
- <input type="checkbox" x-model="serverForm.agent_enabled" style="width:auto;accent-color:var(--info)"> Allow the diagnostic agent</label>
1717
- <label style="display:flex;align-items:center;gap:8px;font-size:13px">
1718
- <input type="checkbox" x-model="serverForm.record_sessions" style="width:auto;accent-color:var(--info)"> Record SSH sessions
1728
+ <input type="checkbox" x-model="serverForm.agent_enabled" style="width:auto;accent-color:var(--info)"> Allow the diagnostic agent
1729
+ </label>
1730
+ <label style="display:flex;align-items:center;gap:8px;margin-top:8px;font-size:13px">
1731
+ <input type="checkbox" x-model="serverForm.record_sessions" style="width:auto;accent-color:var(--info)"> Record SSH sessions
1719
1732
  </label>
1733
+
1734
+ <div x-show="editingServerId" class="fz-hostkey">
1735
+ <div class="fz-hostkey-head">
1736
+ <svg class="fz-icon fz-icon-sm" viewBox="0 0 24 24"><use href="#i-shield"/></svg>
1737
+ <span>Host key</span>
1738
+ <button x-show="hostKeyOf(editingServerId) && currentUser?.is_admin" type="button"
1739
+ @click="clearHostKey(editingServerId)"
1740
+ class="fz-btn fz-btn-ghost fz-btn-sm">Clear pinned key</button>
1741
+ </div>
1742
+ <code x-show="hostKeyOf(editingServerId)" x-text="hostKeyOf(editingServerId)" class="fz-hostkey-fp"></code>
1743
+ <div class="hint" x-show="hostKeyOf(editingServerId)">
1744
+ Recorded the first time this server was reached. A connection is refused if the
1745
+ key changes, so clear it only when you know why it changed &mdash; a rebuild, a
1746
+ rotation &mdash; and confirm the new one out of band.
1747
+ </div>
1748
+ <div class="hint" x-show="!hostKeyOf(editingServerId)">
1749
+ Nothing pinned yet. The next connection records the key this server presents,
1750
+ and every connection after that is checked against it.
1751
+ </div>
1752
+ </div>
1720
1753
  <div x-show="!agentAvailable" class="hint" style="margin-top:12px">
1721
1754
  The AI agent is not set up on this gateway.
1722
1755
  <a href="#" @click.prevent="showServerModal=false;openAgentSettings()" style="color:var(--accent)">Configure it in Admin &rarr; AI agent</a>
@@ -3079,6 +3112,29 @@ function app() {
3079
3112
  },
3080
3113
  async loadStatuses() { try { this.serverStatuses = await this.apiCall('/api/servers/status'); } catch {} },
3081
3114
  async loadGroups() { try { this.groups = await this.apiCall('/api/servers/groups'); } catch {} },
3115
+ hostKeyOf(serverId) {
3116
+ return (this.servers.find(s => s.id === serverId) || {}).host_key_fingerprint || '';
3117
+ },
3118
+ async clearHostKey(serverId) {
3119
+ const srv = this.servers.find(s => s.id === serverId);
3120
+ if (!srv) return;
3121
+ // Spelled out, because accepting a new key is the one moment the protection
3122
+ // is deliberately stood down.
3123
+ const ok = confirm(
3124
+ `Forget the pinned host key for ${srv.name}?\n\n` +
3125
+ `${srv.host_key_fingerprint}\n\n` +
3126
+ `The next connection will record whatever key the server presents and trust it. ` +
3127
+ `Do this only if you know why the key changed and have confirmed the new one ` +
3128
+ `some other way.`
3129
+ );
3130
+ if (!ok) return;
3131
+ try {
3132
+ const res = await this.apiCall(`/api/servers/${serverId}/host-key`, { method: 'DELETE' });
3133
+ await this.loadServers();
3134
+ this.showToast(`Forgot ${res.cleared || 'the pinned key'}`, 'success');
3135
+ this.log('status', `Cleared the host key for ${srv.name}`);
3136
+ } catch (e) { this.showToast(e.message || 'Could not clear the key', 'error'); }
3137
+ },
3082
3138
  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, agent_enabled: false, record_sessions: false, sftp_allowed_paths_str: '', jump_via_id: null }; },
3083
3139
  loadKeyFile(event) {
3084
3140
  const file = event.target.files?.[0]; if (!file) return;
@@ -16,6 +16,7 @@ from webgate.db.engine import async_session_factory as _session_factory
16
16
  from webgate.recordings.models import Recording
17
17
  from webgate.recordings.recorder import CastRecorder
18
18
  from webgate.runtime_config import store as runtime
19
+ from webgate.servers.crypto import CredentialUnreadable
19
20
  from webgate.servers.service import (
20
21
  get_server,
21
22
  get_server_credentials,
@@ -140,7 +141,14 @@ async def ws_terminal_server(ws: WebSocket, server_id: int) -> None:
140
141
  await ws.close(code=4003, reason="SSH is disabled for this server")
141
142
  return
142
143
 
143
- password, private_key = get_server_credentials(server)
144
+ try:
145
+ password, private_key = get_server_credentials(server)
146
+ except CredentialUnreadable as exc:
147
+ # Raising here fails the WebSocket upgrade itself, so the browser sees a
148
+ # bare 500 with nothing to act on.
149
+ await ws.send_json({"type": "error", "message": str(exc)})
150
+ await ws.close(code=1011)
151
+ return
144
152
  jump_kwargs = await resolve_jump_creds(session, server)
145
153
 
146
154
  await ws.accept()
@@ -114,6 +114,16 @@ class SharedSessionManager:
114
114
  self._by_token.pop(sess.share_token, None)
115
115
  return sess
116
116
 
117
+ def count_for(self, username: str) -> int:
118
+ """Live sessions this account owns on this worker.
119
+
120
+ Per worker, not per fleet: sessions are held in memory beside the PTY they
121
+ belong to, and the failure this guards against -- one account exhausting a
122
+ worker -- is per worker too. With N workers a cap of M allows up to N*M in
123
+ total, which the setting's help text says out loud.
124
+ """
125
+ return sum(1 for s in self._by_id.values() if s.owner_username == username and not s.closed)
126
+
117
127
  def get_by_id(self, session_id: str) -> SharedSession | None:
118
128
  return self._by_id.get(session_id)
119
129