webgate 0.4.2__tar.gz → 0.5.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 (110) hide show
  1. {webgate-0.4.2 → webgate-0.5.0}/CHANGELOG.md +36 -0
  2. {webgate-0.4.2 → webgate-0.5.0}/PKG-INFO +43 -1
  3. {webgate-0.4.2 → webgate-0.5.0}/README.md +42 -0
  4. webgate-0.5.0/compose.ha.yml +62 -0
  5. webgate-0.5.0/deploy/nginx-ha.conf +36 -0
  6. {webgate-0.4.2 → webgate-0.5.0}/pyproject.toml +1 -1
  7. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/app.py +6 -2
  8. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/config.py +4 -0
  9. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/db/engine.py +2 -0
  10. webgate-0.5.0/src/webgate/servers/monitor.py +212 -0
  11. webgate-0.4.2/src/webgate/servers/monitor.py +0 -117
  12. {webgate-0.4.2 → webgate-0.5.0}/.dockerignore +0 -0
  13. {webgate-0.4.2 → webgate-0.5.0}/.github/workflows/docs.yml +0 -0
  14. {webgate-0.4.2 → webgate-0.5.0}/.gitignore +0 -0
  15. {webgate-0.4.2 → webgate-0.5.0}/Dockerfile +0 -0
  16. {webgate-0.4.2 → webgate-0.5.0}/Dockerfile.demo +0 -0
  17. {webgate-0.4.2 → webgate-0.5.0}/Dockerfile.ssh-demo +0 -0
  18. {webgate-0.4.2 → webgate-0.5.0}/LICENSE +0 -0
  19. {webgate-0.4.2 → webgate-0.5.0}/ROADMAP.md +0 -0
  20. {webgate-0.4.2 → webgate-0.5.0}/VERSION +0 -0
  21. {webgate-0.4.2 → webgate-0.5.0}/after-reload.yaml +0 -0
  22. {webgate-0.4.2 → webgate-0.5.0}/compose.dev.yml +0 -0
  23. {webgate-0.4.2 → webgate-0.5.0}/compose.yml +0 -0
  24. {webgate-0.4.2 → webgate-0.5.0}/docs/api/auth.md +0 -0
  25. {webgate-0.4.2 → webgate-0.5.0}/docs/api/files.md +0 -0
  26. {webgate-0.4.2 → webgate-0.5.0}/docs/api/servers.md +0 -0
  27. {webgate-0.4.2 → webgate-0.5.0}/docs/api/terminal.md +0 -0
  28. {webgate-0.4.2 → webgate-0.5.0}/docs/changelog.md +0 -0
  29. {webgate-0.4.2 → webgate-0.5.0}/docs/getting-started/installation.md +0 -0
  30. {webgate-0.4.2 → webgate-0.5.0}/docs/getting-started/quickstart.md +0 -0
  31. {webgate-0.4.2 → webgate-0.5.0}/docs/guide/files.md +0 -0
  32. {webgate-0.4.2 → webgate-0.5.0}/docs/guide/servers.md +0 -0
  33. {webgate-0.4.2 → webgate-0.5.0}/docs/guide/split.md +0 -0
  34. {webgate-0.4.2 → webgate-0.5.0}/docs/guide/terminal.md +0 -0
  35. {webgate-0.4.2 → webgate-0.5.0}/docs/guide/users.md +0 -0
  36. {webgate-0.4.2 → webgate-0.5.0}/docs/index.md +0 -0
  37. {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/access-control.png +0 -0
  38. {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/audit.png +0 -0
  39. {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/edit-access-control.png +0 -0
  40. {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/editor.png +0 -0
  41. {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/light-theme.png +0 -0
  42. {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/login.png +0 -0
  43. {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/new-server-form.png +0 -0
  44. {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/sftp-restricted.png +0 -0
  45. {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/sftp.png +0 -0
  46. {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/site-manager.png +0 -0
  47. {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/split-view.png +0 -0
  48. {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/ssh-disabled.png +0 -0
  49. {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/terminal.png +0 -0
  50. {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/users.png +0 -0
  51. {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/v0.3/01-login-demo-banner.png +0 -0
  52. {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/v0.3/02-dashboard-jump-host.png +0 -0
  53. {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/v0.3/03-terminal-snippets-jump.png +0 -0
  54. {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/v0.3/04-snippet-executed.png +0 -0
  55. {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/v0.3/05-sftp-via-jump.png +0 -0
  56. {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/v0.3/06-webhooks-modal.png +0 -0
  57. {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/v0.3/07-webhook-test-fired.png +0 -0
  58. {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/v0.3/08-add-server-jump-via.png +0 -0
  59. {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/v0.4/01-shared-terminal-owner.png +0 -0
  60. {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/v0.4/02-shared-terminal-joiner.png +0 -0
  61. {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/v0.4/03-recording-replay.png +0 -0
  62. {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/v0.4/04-recordings-modal.png +0 -0
  63. {webgate-0.4.2 → webgate-0.5.0}/fly.toml +0 -0
  64. {webgate-0.4.2 → webgate-0.5.0}/mkdocs.yml +0 -0
  65. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/__init__.py +0 -0
  66. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/__main__.py +0 -0
  67. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/audit/__init__.py +0 -0
  68. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/audit/models.py +0 -0
  69. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/audit/service.py +0 -0
  70. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/auth/__init__.py +0 -0
  71. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/auth/ldap.py +0 -0
  72. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/auth/models.py +0 -0
  73. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/auth/routes.py +0 -0
  74. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/auth/service.py +0 -0
  75. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/db/__init__.py +0 -0
  76. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/demo.py +0 -0
  77. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/files/__init__.py +0 -0
  78. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/files/models.py +0 -0
  79. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/files/pool.py +0 -0
  80. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/files/routes.py +0 -0
  81. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/files/sftp_service.py +0 -0
  82. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/recordings/__init__.py +0 -0
  83. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/recordings/models.py +0 -0
  84. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/recordings/recorder.py +0 -0
  85. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/recordings/routes.py +0 -0
  86. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/servers/__init__.py +0 -0
  87. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/servers/crypto.py +0 -0
  88. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/servers/models.py +0 -0
  89. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/servers/routes.py +0 -0
  90. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/servers/service.py +0 -0
  91. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/snippets/__init__.py +0 -0
  92. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/snippets/models.py +0 -0
  93. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/snippets/routes.py +0 -0
  94. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/static/index.html +0 -0
  95. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/terminal/__init__.py +0 -0
  96. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/terminal/routes.py +0 -0
  97. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/terminal/shared.py +0 -0
  98. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/terminal/ssh_session.py +0 -0
  99. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/terminal/ws_handler.py +0 -0
  100. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/webhooks/__init__.py +0 -0
  101. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/webhooks/dispatcher.py +0 -0
  102. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/webhooks/models.py +0 -0
  103. {webgate-0.4.2 → webgate-0.5.0}/src/webgate/webhooks/routes.py +0 -0
  104. {webgate-0.4.2 → webgate-0.5.0}/tests/__init__.py +0 -0
  105. {webgate-0.4.2 → webgate-0.5.0}/tests/conftest.py +0 -0
  106. {webgate-0.4.2 → webgate-0.5.0}/tests/test_auth.py +0 -0
  107. {webgate-0.4.2 → webgate-0.5.0}/tests/test_files.py +0 -0
  108. {webgate-0.4.2 → webgate-0.5.0}/tests/test_servers.py +0 -0
  109. {webgate-0.4.2 → webgate-0.5.0}/tests/test_terminal.py +0 -0
  110. {webgate-0.4.2 → webgate-0.5.0}/uv.lock +0 -0
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.5.0 (2026-04-15)
4
+
5
+ ### Features
6
+
7
+ - **Multi-instance HA deployment** -- run N webgate workers behind a load balancer, sharing a PostgreSQL database. Only one worker at a time performs server-connectivity probes (leader election via a singleton lease row); all workers serve REST + WS traffic normally.
8
+ - **`compose.ha.yml`** reference deployment: 2 webgate replicas + Postgres + nginx LB with `ip_hash` sticky sessions. Verified end-to-end including automatic failover.
9
+ - **`/api/health`** now reports `instance_id` (per-worker UUID) and `monitor_role` (`leader` / `follower`) so LB health checks and observability can tell replicas apart.
10
+
11
+ ### Configuration
12
+
13
+ | Variable | Default | Description |
14
+ |---|---|---|
15
+ | `WEBGATE_INSTANCE_ID` | auto (UUID) | Stable identifier for this worker |
16
+ | `WEBGATE_DISABLE_MONITOR` | `false` | Skip leader election entirely (pure follower worker, useful if a separate process owns the monitor) |
17
+
18
+ ### Details
19
+
20
+ - New `monitor_lease` singleton table (auto-created at startup) holds the current leader's instance id and expiry. 90 s TTL with 30 s heartbeat.
21
+ - On leader loss / expiry, any other worker picks up the probe loop within ~1 check cycle (≤ 90 s).
22
+ - Dialect-agnostic (works on SQLite for single-instance dev, PostgreSQL for real HA).
23
+
24
+ ### Known limitation
25
+
26
+ - Shared terminal sessions (`/api/ws/terminal/join/{token}`) still require owner and joiner to land on the same worker. Sticky-session routing handles same-browser joins; cross-worker cross-engineer sharing needs Redis pub/sub (planned in v0.5.x).
27
+
28
+ ### Verified
29
+
30
+ `compose.ha.yml` stack (2 replicas + Postgres + nginx):
31
+
32
+ - Both replicas show `{"instance_id":"…","monitor_role":"follower"|"leader"}`
33
+ - Exactly one replica holds the lease row in Postgres
34
+ - Servers created on replica A immediately visible from replica B (shared DB)
35
+ - Killed the leader → follower promoted automatically, LB kept serving
36
+
37
+ ---
38
+
3
39
  ## v0.4.2 (2026-04-15)
4
40
 
5
41
  ### Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webgate
3
- Version: 0.4.2
3
+ Version: 0.5.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/
@@ -552,6 +552,48 @@ labels:
552
552
  - "traefik.http.services.webgate.loadbalancer.server.port=8443"
553
553
  ```
554
554
 
555
+ ### Multi-instance HA (N replicas + PostgreSQL)
556
+
557
+ Run multiple webgate workers behind a load balancer, all sharing one Postgres database. Only one worker probes server connectivity at a time (leader election via a singleton lease row); the rest handle REST/WS traffic normally.
558
+
559
+ ```mermaid
560
+ flowchart LR
561
+ U["Users"]
562
+ LB["Load balancer<br/>(sticky: ip_hash)"]
563
+ W1["webgate #1<br/>(leader)"]
564
+ W2["webgate #2<br/>(follower)"]
565
+ W3["webgate #3<br/>(follower)"]
566
+ PG[(PostgreSQL<br/>shared state)]
567
+ LEASE[["monitor_lease<br/>(singleton row)"]]
568
+ U --> LB
569
+ LB --> W1
570
+ LB --> W2
571
+ LB --> W3
572
+ W1 --> PG
573
+ W2 --> PG
574
+ W3 --> PG
575
+ W1 -- holds --> LEASE
576
+ W2 -. watches .-> LEASE
577
+ W3 -. watches .-> LEASE
578
+ style LB fill:#ffcc02,stroke:#e6a800,color:#333
579
+ style W1 fill:#5cb85c,stroke:#449d44,color:#fff
580
+ style W2 fill:#e8f0fe,stroke:#4a90d9
581
+ style W3 fill:#e8f0fe,stroke:#4a90d9
582
+ style PG fill:#fff3e0,stroke:#ff9800
583
+ ```
584
+
585
+ Reference stack:
586
+
587
+ ```bash
588
+ export WEBGATE_SECRET_KEY=$(openssl rand -hex 32)
589
+ docker compose -f compose.ha.yml up -d
590
+ curl -s http://localhost:8443/api/health # shows instance_id + monitor_role
591
+ ```
592
+
593
+ [`compose.ha.yml`](compose.ha.yml) spins up 2 webgate replicas + Postgres + nginx with `ip_hash` sticky sessions. On leader loss, the lease expires within 90 seconds and another replica picks it up automatically.
594
+
595
+ > **Known limitation**: live shared-terminal sessions still need owner and joiner on the same worker. Sticky sessions mitigate it for same-browser joins; true cross-worker fan-out needs Redis pub/sub — planned for a later v0.5.x.
596
+
555
597
  ### Public read-only demo (Fly.io)
556
598
 
557
599
  The repo includes [`Dockerfile.demo`](Dockerfile.demo) (webgate + sandboxed sshd via supervisord) and [`fly.toml`](fly.toml). Deploy:
@@ -503,6 +503,48 @@ labels:
503
503
  - "traefik.http.services.webgate.loadbalancer.server.port=8443"
504
504
  ```
505
505
 
506
+ ### Multi-instance HA (N replicas + PostgreSQL)
507
+
508
+ Run multiple webgate workers behind a load balancer, all sharing one Postgres database. Only one worker probes server connectivity at a time (leader election via a singleton lease row); the rest handle REST/WS traffic normally.
509
+
510
+ ```mermaid
511
+ flowchart LR
512
+ U["Users"]
513
+ LB["Load balancer<br/>(sticky: ip_hash)"]
514
+ W1["webgate #1<br/>(leader)"]
515
+ W2["webgate #2<br/>(follower)"]
516
+ W3["webgate #3<br/>(follower)"]
517
+ PG[(PostgreSQL<br/>shared state)]
518
+ LEASE[["monitor_lease<br/>(singleton row)"]]
519
+ U --> LB
520
+ LB --> W1
521
+ LB --> W2
522
+ LB --> W3
523
+ W1 --> PG
524
+ W2 --> PG
525
+ W3 --> PG
526
+ W1 -- holds --> LEASE
527
+ W2 -. watches .-> LEASE
528
+ W3 -. watches .-> LEASE
529
+ style LB fill:#ffcc02,stroke:#e6a800,color:#333
530
+ style W1 fill:#5cb85c,stroke:#449d44,color:#fff
531
+ style W2 fill:#e8f0fe,stroke:#4a90d9
532
+ style W3 fill:#e8f0fe,stroke:#4a90d9
533
+ style PG fill:#fff3e0,stroke:#ff9800
534
+ ```
535
+
536
+ Reference stack:
537
+
538
+ ```bash
539
+ export WEBGATE_SECRET_KEY=$(openssl rand -hex 32)
540
+ docker compose -f compose.ha.yml up -d
541
+ curl -s http://localhost:8443/api/health # shows instance_id + monitor_role
542
+ ```
543
+
544
+ [`compose.ha.yml`](compose.ha.yml) spins up 2 webgate replicas + Postgres + nginx with `ip_hash` sticky sessions. On leader loss, the lease expires within 90 seconds and another replica picks it up automatically.
545
+
546
+ > **Known limitation**: live shared-terminal sessions still need owner and joiner on the same worker. Sticky sessions mitigate it for same-browser joins; true cross-worker fan-out needs Redis pub/sub — planned for a later v0.5.x.
547
+
506
548
  ### Public read-only demo (Fly.io)
507
549
 
508
550
  The repo includes [`Dockerfile.demo`](Dockerfile.demo) (webgate + sandboxed sshd via supervisord) and [`fly.toml`](fly.toml). Deploy:
@@ -0,0 +1,62 @@
1
+ # High-availability deployment: 2 webgate workers share a Postgres DB
2
+ # behind a nginx load balancer with sticky sessions.
3
+ #
4
+ # Run: WEBGATE_SECRET_KEY=$(openssl rand -hex 32) docker compose -f compose.ha.yml up -d
5
+ # Open: http://localhost:8443/ (login: admin / admin)
6
+ #
7
+ # /api/health reports the instance_id + monitor_role so you can see which
8
+ # replica answered. Only one replica at a time becomes the monitor leader
9
+ # via a lease row in the shared Postgres DB.
10
+ #
11
+ # Known limitation: shared terminal sessions (live "Share" URLs) still
12
+ # require the owner and joiner to land on the same worker. Sticky sessions
13
+ # by IP hash handle same-browser joins; cross-engineer sharing between
14
+ # workers needs Redis pub/sub (planned for v0.5.x).
15
+
16
+ services:
17
+ postgres:
18
+ image: postgres:16-alpine
19
+ container_name: webgate-pg
20
+ restart: unless-stopped
21
+ environment:
22
+ POSTGRES_USER: webgate
23
+ POSTGRES_PASSWORD: webgate
24
+ POSTGRES_DB: webgate
25
+ volumes:
26
+ - webgate-pg:/var/lib/postgresql/data
27
+ healthcheck:
28
+ test: ["CMD-SHELL", "pg_isready -U webgate"]
29
+ interval: 5s
30
+ timeout: 3s
31
+ retries: 10
32
+
33
+ webgate-1: &webgate
34
+ image: kalexnolasco/webgate:latest
35
+ restart: unless-stopped
36
+ depends_on:
37
+ postgres:
38
+ condition: service_healthy
39
+ environment:
40
+ WEBGATE_SECRET_KEY: "${WEBGATE_SECRET_KEY:?run: export WEBGATE_SECRET_KEY=$(openssl rand -hex 32)}"
41
+ WEBGATE_DB_URL: "postgresql+asyncpg://webgate:webgate@postgres:5432/webgate"
42
+ WEBGATE_FIRST_RUN: "true"
43
+
44
+ webgate-2:
45
+ <<: *webgate
46
+ # Same image + env, different instance. The monitor leader-election
47
+ # picks exactly one of these to do the probing.
48
+
49
+ nginx:
50
+ image: nginx:alpine
51
+ container_name: webgate-lb
52
+ restart: unless-stopped
53
+ depends_on:
54
+ - webgate-1
55
+ - webgate-2
56
+ ports:
57
+ - "8443:80"
58
+ volumes:
59
+ - ./deploy/nginx-ha.conf:/etc/nginx/conf.d/default.conf:ro
60
+
61
+ volumes:
62
+ webgate-pg:
@@ -0,0 +1,36 @@
1
+ # nginx HA config for compose.ha.yml
2
+ #
3
+ # ip_hash is the minimum needed for WebSocket affinity without a shared
4
+ # session store — all requests from the same source IP always hit the
5
+ # same upstream. Good enough for this reference setup; in a real cluster
6
+ # swap for sticky cookies via nginx Plus or Traefik.
7
+
8
+ upstream webgate_cluster {
9
+ ip_hash;
10
+ server webgate-1:8443 max_fails=3 fail_timeout=10s;
11
+ server webgate-2:8443 max_fails=3 fail_timeout=10s;
12
+ }
13
+
14
+ server {
15
+ listen 80;
16
+
17
+ # Terminal WebSocket — needs long timeouts and Upgrade passthrough.
18
+ location /api/ws/ {
19
+ proxy_pass http://webgate_cluster;
20
+ proxy_http_version 1.1;
21
+ proxy_set_header Upgrade $http_upgrade;
22
+ proxy_set_header Connection "upgrade";
23
+ proxy_set_header Host $host;
24
+ proxy_read_timeout 86400s;
25
+ proxy_send_timeout 86400s;
26
+ }
27
+
28
+ location / {
29
+ proxy_pass http://webgate_cluster;
30
+ proxy_set_header Host $host;
31
+ proxy_set_header X-Real-IP $remote_addr;
32
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
33
+ proxy_set_header X-Forwarded-Proto $scheme;
34
+ client_max_body_size 100m;
35
+ }
36
+ }
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "webgate"
3
- version = "0.4.2"
3
+ version = "0.5.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"
@@ -59,8 +59,12 @@ def create_app() -> FastAPI:
59
59
  )
60
60
 
61
61
  @app.get("/api/health")
62
- async def health() -> dict[str, str]: # pyright: ignore[reportUnusedFunction]
63
- return {"status": "ok"}
62
+ async def health() -> dict[str, object]: # pyright: ignore[reportUnusedFunction]
63
+ return {
64
+ "status": "ok",
65
+ "instance_id": server_monitor.instance_id,
66
+ "monitor_role": "leader" if server_monitor.is_leader else "follower",
67
+ }
64
68
 
65
69
  @app.get("/api/config")
66
70
  async def public_config() -> dict[str, object]: # pyright: ignore[reportUnusedFunction]
@@ -27,6 +27,10 @@ class Settings(BaseSettings):
27
27
  ldap_group_filter: str = "(member={dn})" # AD: (member:1.2.840.113556.1.4.1941:={dn})
28
28
  ldap_group_map: str = "{}" # JSON: {"ldap-group-cn": "webgate-group-name"}
29
29
  ldap_admin_groups: str = "[]" # JSON list of LDAP group CNs that grant admin
30
+
31
+ # Multi-instance HA
32
+ instance_id: str = "" # Unique per worker; auto-generated UUID if empty
33
+ disable_monitor: bool = False # Skip leader election; never run server monitor
30
34
  log_level: str = "info"
31
35
  session_timeout: int = 3600
32
36
  max_upload_size: int = 104857600 # 100MB
@@ -33,6 +33,8 @@ async def get_session() -> AsyncGenerator[AsyncSession]:
33
33
  # (table, column, sqlite_def, postgres_def)
34
34
  _MIGRATIONS: list[tuple[str, str, str, str]] = [
35
35
  ("servers", "sftp_read_only", "BOOLEAN DEFAULT 0", "BOOLEAN DEFAULT FALSE"),
36
+ # Singleton lease table for monitor leader election (id always = 1).
37
+ # Created lazily by ServerMonitor.start() on first call; rows added there too.
36
38
  ("users", "totp_secret", "VARCHAR(255) DEFAULT ''", "VARCHAR(255) DEFAULT ''"),
37
39
  ("users", "totp_enabled", "BOOLEAN DEFAULT 0", "BOOLEAN DEFAULT FALSE"),
38
40
  (
@@ -0,0 +1,212 @@
1
+ """Background server connectivity monitor with leader election.
2
+
3
+ In a multi-instance deployment, only one worker should actively probe servers.
4
+ We use a tiny singleton row in `monitor_lease` as a lease: workers try to claim
5
+ it; the holder heartbeats every LEASE_RENEW seconds; when the lease expires
6
+ without a renewal, any other worker can take over.
7
+
8
+ Followers keep the loop alive but skip the actual probing — they still serve
9
+ `/api/servers/status` from the leader's writes via the DB row each status is
10
+ persisted to (we store statuses on `Server` rows for cross-worker reads).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import contextlib
17
+ import logging
18
+ import time
19
+ import uuid
20
+ from dataclasses import dataclass
21
+ from datetime import UTC, datetime, timedelta
22
+
23
+ import asyncssh
24
+ from sqlalchemy import select, text
25
+
26
+ from webgate.config import settings
27
+ from webgate.db.engine import async_session_factory, engine
28
+ from webgate.servers.crypto import decrypt_value
29
+ from webgate.servers.models import Server
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ CHECK_INTERVAL = 60 # seconds between full check cycles (leader only)
34
+ CONNECT_TIMEOUT = 5 # seconds per SSH connect attempt
35
+ MAX_CONCURRENT = 10 # max parallel checks
36
+ LEASE_TTL = 90 # seconds; longer than CHECK_INTERVAL so a slow cycle doesn't drop the lease
37
+ LEASE_RENEW = 30 # seconds; heartbeat interval
38
+
39
+
40
+ @dataclass
41
+ class ServerStatus:
42
+ online: bool
43
+ last_checked: datetime
44
+ latency_ms: float | None = None
45
+ error: str | None = None
46
+
47
+
48
+ class ServerMonitor:
49
+ """Periodically check SSH connectivity for all servers, with leader election."""
50
+
51
+ def __init__(self) -> None:
52
+ self._statuses: dict[int, ServerStatus] = {}
53
+ self._task: asyncio.Task[None] | None = None
54
+ self._instance_id: str = settings.instance_id or str(uuid.uuid4())
55
+ self._is_leader: bool = False
56
+
57
+ @property
58
+ def instance_id(self) -> str:
59
+ return self._instance_id
60
+
61
+ @property
62
+ def is_leader(self) -> bool:
63
+ return self._is_leader
64
+
65
+ async def start(self) -> None:
66
+ if settings.disable_monitor:
67
+ logger.info("Monitor disabled by WEBGATE_DISABLE_MONITOR (instance %s)", self._instance_id)
68
+ return
69
+ await self._ensure_lease_table()
70
+ self._task = asyncio.create_task(self._loop())
71
+ logger.info("Server monitor started (instance %s)", self._instance_id)
72
+
73
+ async def stop(self) -> None:
74
+ if self._task is not None:
75
+ self._task.cancel()
76
+ with contextlib.suppress(asyncio.CancelledError):
77
+ await self._task
78
+ self._task = None
79
+ if self._is_leader:
80
+ await self._release_lease()
81
+ logger.info("Server monitor stopped (instance %s)", self._instance_id)
82
+
83
+ def get_status(self, server_id: int) -> ServerStatus | None:
84
+ return self._statuses.get(server_id)
85
+
86
+ def get_all_statuses(self) -> dict[int, ServerStatus]:
87
+ return dict(self._statuses)
88
+
89
+ # --- leader election ---------------------------------------------------
90
+
91
+ async def _ensure_lease_table(self) -> None:
92
+ ddl = (
93
+ "CREATE TABLE IF NOT EXISTS monitor_lease ("
94
+ "id INTEGER PRIMARY KEY, "
95
+ "instance_id VARCHAR(64) NOT NULL, "
96
+ "expires_at TIMESTAMP NOT NULL"
97
+ ")"
98
+ )
99
+ async with engine.begin() as conn:
100
+ with contextlib.suppress(Exception):
101
+ await conn.execute(text(ddl))
102
+
103
+ async def _try_claim(self) -> bool:
104
+ """Atomically claim the singleton lease. Returns True if we are the leader."""
105
+ # Store as naive UTC so we work with both SQLite's TEXT storage and
106
+ # Postgres' TIMESTAMP WITHOUT TIME ZONE.
107
+ now = datetime.now(UTC).replace(tzinfo=None)
108
+ new_expiry = now + timedelta(seconds=LEASE_TTL)
109
+ async with engine.begin() as conn:
110
+ try:
111
+ row = (await conn.execute(text("SELECT instance_id, expires_at FROM monitor_lease WHERE id = 1"))).fetchone()
112
+ if row is None:
113
+ await conn.execute(
114
+ text("INSERT INTO monitor_lease (id, instance_id, expires_at) VALUES (1, :iid, :exp)"),
115
+ {"iid": self._instance_id, "exp": new_expiry},
116
+ )
117
+ return True
118
+ # Postgres returns datetime, SQLite may return a string.
119
+ expires_raw = row[1]
120
+ expires = expires_raw if isinstance(expires_raw, datetime) else datetime.fromisoformat(str(expires_raw))
121
+ if expires.tzinfo is not None:
122
+ expires = expires.astimezone(UTC).replace(tzinfo=None)
123
+ if row[0] == self._instance_id or expires < now:
124
+ res = await conn.execute(
125
+ text(
126
+ "UPDATE monitor_lease SET instance_id = :iid, expires_at = :exp "
127
+ "WHERE id = 1 AND (instance_id = :iid OR expires_at < :now)"
128
+ ),
129
+ {"iid": self._instance_id, "exp": new_expiry, "now": now},
130
+ )
131
+ return (res.rowcount or 0) > 0
132
+ return False
133
+ except Exception as exc:
134
+ logger.warning("Lease claim failed: %s", exc)
135
+ return False
136
+
137
+ async def _release_lease(self) -> None:
138
+ async with engine.begin() as conn:
139
+ with contextlib.suppress(Exception):
140
+ await conn.execute(
141
+ text("DELETE FROM monitor_lease WHERE id = 1 AND instance_id = :iid"),
142
+ {"iid": self._instance_id},
143
+ )
144
+
145
+ # --- main loop ---------------------------------------------------------
146
+
147
+ async def _loop(self) -> None:
148
+ last_renew = 0.0
149
+ while True:
150
+ try:
151
+ if not self._is_leader:
152
+ self._is_leader = await self._try_claim()
153
+ if self._is_leader:
154
+ logger.info("Became monitor leader (instance %s)", self._instance_id)
155
+ if self._is_leader:
156
+ now = time.monotonic()
157
+ if now - last_renew > LEASE_RENEW:
158
+ renewed = await self._try_claim()
159
+ if not renewed:
160
+ logger.warning("Lost monitor lease (instance %s) -> stepping down", self._instance_id)
161
+ self._is_leader = False
162
+ await asyncio.sleep(LEASE_RENEW)
163
+ continue
164
+ last_renew = now
165
+ await self._check_all()
166
+ else:
167
+ # Followers wake up roughly once per check interval to retry leadership.
168
+ await asyncio.sleep(min(LEASE_RENEW, CHECK_INTERVAL))
169
+ continue
170
+ except Exception:
171
+ logger.exception("Error in monitor loop (instance %s)", self._instance_id)
172
+ await asyncio.sleep(CHECK_INTERVAL)
173
+
174
+ async def _check_all(self) -> None:
175
+ async with async_session_factory() as session:
176
+ result = await session.execute(select(Server))
177
+ servers = result.scalars().all()
178
+
179
+ semaphore = asyncio.Semaphore(MAX_CONCURRENT)
180
+
181
+ async def _check_one(server: Server) -> None:
182
+ async with semaphore:
183
+ status = await self._check_server(server)
184
+ self._statuses[server.id] = status
185
+
186
+ await asyncio.gather(*[_check_one(s) for s in servers])
187
+
188
+ async def _check_server(self, server: Server) -> ServerStatus:
189
+ password = decrypt_value(server.encrypted_password) if server.encrypted_password else None
190
+ private_key_str = (
191
+ decrypt_value(server.encrypted_private_key) if server.encrypted_private_key else None
192
+ )
193
+ kwargs: dict[str, object] = {
194
+ "host": server.hostname, "port": server.port, "username": server.username,
195
+ "known_hosts": None,
196
+ }
197
+ if private_key_str:
198
+ kwargs["client_keys"] = [asyncssh.import_private_key(private_key_str)]
199
+ elif password:
200
+ kwargs["password"] = password
201
+ now = datetime.now(UTC)
202
+ start = time.monotonic()
203
+ try:
204
+ conn = await asyncio.wait_for(asyncssh.connect(**kwargs), timeout=CONNECT_TIMEOUT) # type: ignore[arg-type]
205
+ elapsed = (time.monotonic() - start) * 1000
206
+ conn.close()
207
+ return ServerStatus(online=True, last_checked=now, latency_ms=round(elapsed, 1))
208
+ except Exception as exc:
209
+ return ServerStatus(online=False, last_checked=now, error=str(exc))
210
+
211
+
212
+ server_monitor = ServerMonitor()
@@ -1,117 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import asyncio
4
- import contextlib
5
- import logging
6
- import time
7
- from dataclasses import dataclass
8
- from datetime import UTC, datetime
9
-
10
- import asyncssh
11
- from sqlalchemy import select
12
-
13
- from webgate.db.engine import async_session_factory
14
- from webgate.servers.crypto import decrypt_value
15
- from webgate.servers.models import Server
16
-
17
- logger = logging.getLogger(__name__)
18
-
19
- CHECK_INTERVAL = 60 # seconds between full check cycles
20
- CONNECT_TIMEOUT = 5 # seconds per SSH connect attempt
21
- MAX_CONCURRENT = 10 # max parallel checks
22
-
23
-
24
- @dataclass
25
- class ServerStatus:
26
- online: bool
27
- last_checked: datetime
28
- latency_ms: float | None = None
29
- error: str | None = None
30
-
31
-
32
- class ServerMonitor:
33
- """Background monitor that periodically checks SSH connectivity for all servers."""
34
-
35
- def __init__(self) -> None:
36
- self._statuses: dict[int, ServerStatus] = {}
37
- self._task: asyncio.Task[None] | None = None
38
-
39
- async def start(self) -> None:
40
- """Launch the background check loop."""
41
- self._task = asyncio.create_task(self._check_loop())
42
- logger.info("Server monitor started")
43
-
44
- async def stop(self) -> None:
45
- """Cancel the background task."""
46
- if self._task is not None:
47
- self._task.cancel()
48
- with contextlib.suppress(asyncio.CancelledError):
49
- await self._task
50
- self._task = None
51
- logger.info("Server monitor stopped")
52
-
53
- def get_status(self, server_id: int) -> ServerStatus | None:
54
- return self._statuses.get(server_id)
55
-
56
- def get_all_statuses(self) -> dict[int, ServerStatus]:
57
- return dict(self._statuses)
58
-
59
- async def _check_loop(self) -> None:
60
- """Run connectivity checks every CHECK_INTERVAL seconds."""
61
- while True:
62
- try:
63
- await self._check_all()
64
- except Exception:
65
- logger.exception("Error during server status check cycle")
66
- await asyncio.sleep(CHECK_INTERVAL)
67
-
68
- async def _check_all(self) -> None:
69
- """Load all servers from DB and check each one concurrently."""
70
- async with async_session_factory() as session:
71
- result = await session.execute(select(Server))
72
- servers = result.scalars().all()
73
-
74
- semaphore = asyncio.Semaphore(MAX_CONCURRENT)
75
-
76
- async def _check_one(server: Server) -> None:
77
- async with semaphore:
78
- status = await self._check_server(server)
79
- self._statuses[server.id] = status
80
-
81
- await asyncio.gather(*[_check_one(s) for s in servers])
82
-
83
- async def _check_server(self, server: Server) -> ServerStatus:
84
- """Attempt an SSH connection to determine if the server is reachable."""
85
- password = decrypt_value(server.encrypted_password) if server.encrypted_password else None
86
- private_key_str = (
87
- decrypt_value(server.encrypted_private_key)
88
- if server.encrypted_private_key
89
- else None
90
- )
91
-
92
- kwargs: dict[str, object] = {
93
- "host": server.hostname,
94
- "port": server.port,
95
- "username": server.username,
96
- "known_hosts": None,
97
- }
98
- if private_key_str:
99
- kwargs["client_keys"] = [asyncssh.import_private_key(private_key_str)]
100
- elif password:
101
- kwargs["password"] = password
102
-
103
- now = datetime.now(UTC)
104
- start = time.monotonic()
105
- try:
106
- conn = await asyncio.wait_for(
107
- asyncssh.connect(**kwargs), # type: ignore[arg-type]
108
- timeout=CONNECT_TIMEOUT,
109
- )
110
- elapsed = (time.monotonic() - start) * 1000
111
- conn.close()
112
- return ServerStatus(online=True, last_checked=now, latency_ms=round(elapsed, 1))
113
- except Exception as exc:
114
- return ServerStatus(online=False, last_checked=now, error=str(exc))
115
-
116
-
117
- server_monitor = ServerMonitor()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes