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.
- {webgate-0.4.2 → webgate-0.5.0}/CHANGELOG.md +36 -0
- {webgate-0.4.2 → webgate-0.5.0}/PKG-INFO +43 -1
- {webgate-0.4.2 → webgate-0.5.0}/README.md +42 -0
- webgate-0.5.0/compose.ha.yml +62 -0
- webgate-0.5.0/deploy/nginx-ha.conf +36 -0
- {webgate-0.4.2 → webgate-0.5.0}/pyproject.toml +1 -1
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/app.py +6 -2
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/config.py +4 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/db/engine.py +2 -0
- webgate-0.5.0/src/webgate/servers/monitor.py +212 -0
- webgate-0.4.2/src/webgate/servers/monitor.py +0 -117
- {webgate-0.4.2 → webgate-0.5.0}/.dockerignore +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/.github/workflows/docs.yml +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/.gitignore +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/Dockerfile +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/Dockerfile.demo +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/Dockerfile.ssh-demo +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/LICENSE +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/ROADMAP.md +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/VERSION +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/after-reload.yaml +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/compose.dev.yml +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/compose.yml +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/api/auth.md +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/api/files.md +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/api/servers.md +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/api/terminal.md +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/changelog.md +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/getting-started/installation.md +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/getting-started/quickstart.md +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/guide/files.md +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/guide/servers.md +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/guide/split.md +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/guide/terminal.md +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/guide/users.md +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/index.md +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/access-control.png +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/audit.png +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/edit-access-control.png +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/editor.png +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/light-theme.png +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/login.png +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/new-server-form.png +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/sftp-restricted.png +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/sftp.png +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/site-manager.png +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/split-view.png +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/ssh-disabled.png +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/terminal.png +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/users.png +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/v0.3/01-login-demo-banner.png +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/v0.3/02-dashboard-jump-host.png +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/v0.3/03-terminal-snippets-jump.png +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/v0.3/04-snippet-executed.png +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/v0.3/05-sftp-via-jump.png +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/v0.3/06-webhooks-modal.png +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/v0.3/07-webhook-test-fired.png +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/v0.3/08-add-server-jump-via.png +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/v0.4/01-shared-terminal-owner.png +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/v0.4/02-shared-terminal-joiner.png +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/v0.4/03-recording-replay.png +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/docs/screenshots/v0.4/04-recordings-modal.png +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/fly.toml +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/mkdocs.yml +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/__init__.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/__main__.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/audit/__init__.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/audit/models.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/audit/service.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/auth/__init__.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/auth/ldap.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/auth/models.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/auth/routes.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/auth/service.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/db/__init__.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/demo.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/files/__init__.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/files/models.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/files/pool.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/files/routes.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/files/sftp_service.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/recordings/__init__.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/recordings/models.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/recordings/recorder.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/recordings/routes.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/servers/__init__.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/servers/crypto.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/servers/models.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/servers/routes.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/servers/service.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/snippets/__init__.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/snippets/models.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/snippets/routes.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/static/index.html +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/terminal/__init__.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/terminal/routes.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/terminal/shared.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/terminal/ssh_session.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/terminal/ws_handler.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/webhooks/__init__.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/webhooks/dispatcher.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/webhooks/models.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/src/webgate/webhooks/routes.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/tests/__init__.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/tests/conftest.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/tests/test_auth.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/tests/test_files.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/tests/test_servers.py +0 -0
- {webgate-0.4.2 → webgate-0.5.0}/tests/test_terminal.py +0 -0
- {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.
|
|
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
|
+
}
|
|
@@ -59,8 +59,12 @@ def create_app() -> FastAPI:
|
|
|
59
59
|
)
|
|
60
60
|
|
|
61
61
|
@app.get("/api/health")
|
|
62
|
-
async def health() -> dict[str,
|
|
63
|
-
return {
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|