CallMeTechie
EN Anmelden
Home Produkte Blog Über mich Kontakt

Gateway-Pools

v1.0 · Updated vor 1 Monat

Inhaltsverzeichnis

  1. Problem & Motivation
  2. Konzepte
  3. Setup
  4. Failover-Mode im Detail
  5. Load-Balancing-Mode im Detail
  6. Health-Monitoring
  7. Edge-Cases
  8. Troubleshooting
  9. Architektur-Referenz

Problem & Motivation

GateControl-Routen pinnen normalerweise eine Domain (nas.example.com) an genau ein Gateway-Peer. Der Frontend-Caddy auf dem GateControl-Server proxyt zu <gateway-ip>:<proxy-port> über den WireGuard-Tunnel; das companion-caddy auf dem Gateway forwarded zur lokalen Backend-Adresse (192.168.1.5:5001 etc.).

Was schief geht ohne Pools:

  • Reboots eines Gateways (Linux-Updates, Synology-Updates, Proxmox-Reboots) machen alle darüber laufenden Domains für die Reboot-Dauer unerreichbar.
  • Hardware-Ausfall = Totalausfall, bis manuell umkonfiguriert wird.
  • Geplante Wartungsfenster erfordern händisches Umpinnen jeder Route.

Was Pools lösen:

Ein Gateway-Pool gruppiert mehrere Gateway-Peers, die dieselben Backend-Hosts erreichen können. Bei Ausfall eines Members übernimmt automatisch ein anderer — entweder durch Failover (Vorrang nach Priorität) oder Load-Balancing (parallele Verteilung).

Wichtig: Pool-Member müssen die Backend-Hosts (192.168.1.5 etc.) tatsächlich erreichen können. Stehen die Gateways auf unterschiedlichen LANs, hilft Failover nichts, weil der Failover-Member den Backend-Host gar nicht kennt.


Konzepte

Begriff Bedeutung
Pool Logische Gruppe von Gateway-Peers (DB: gateway_pools)
Mode failover oder load_balancing — pro Pool eindeutig
Member Peer mit Priorität in einem Pool (DB: gateway_pool_members)
Priorität Niedriger = höhere Priorität. Position 1 ist primär.
failback_cooldown_s Nach Recovery: Wartezeit bevor Routen zurück auf den ursprünglichen Member wandern
outage_message Custom-503-Body, wenn alle Member down sind
target_peer_id Die Peer-ID, die eine Route aktuell bedient (kann sich bei Failover ändern)
original_peer_id Wo die Route ursprünglich gepinnt war — wird bei Failover gesetzt, bei Recovery zurückgeschrieben
target_pool_id Alternativer Routing-Modus: Route ist explizit am Pool gebunden (für Load-Balancing)

Zwei Routing-Wege

GateControl unterstützt zwei separate Wege wie eine Route den Pool nutzt:

A) Peer-pinned mit implicit Failover (häufigster Fall)

  • Route hat target_peer_id=<gw>, target_pool_id=NULL
  • Frontend-Caddy liest target_peer_id direkt aus der DB
  • Bei Ausfall: gatewayHealth._onTransition schreibt target_peer_id auf den nächst-höchst-priorisierten alive Pool-Member um, merkt sich den Original in original_peer_id
  • Kein Caddy-seitiges Resolving zur Laufzeit nötig — die DB ist die Wahrheit

B) Pool-routed mit Caddy-Resolver (für Load-Balancing)

  • Route hat target_pool_id=<pool>, target_peer_id=NULL
  • Frontend-Caddy resolved zur Laufzeit über gatewayPool.resolveActivePeer(s) mit dem Health-Snapshot
  • Bei Load-Balancing erhält Caddy alle alive Members als upstreams[] plus die Selection-Policy
  • Bei Failover-Mode am Pool wird genau ein Member ausgewählt (höchste Priorität alive)

Für reines Failover brauchst Du nichts zu migrieren. Mitgliedschaft im Pool reicht. Für Load-Balancing musst Du Routen explizit per "Routen migrieren" auf den Pool umstellen.


Setup

1. Voraussetzungen

  • Mindestens 2 Gateway-Peers angelegt (/peers, peer_type=gateway)
  • Beide Gateways müssen die Backend-Hosts in ihrem LAN erreichen können
  • Lizenz: gateway_pools=true (Failover ist meist im Basis-Plan, Load-Balancing oft Pro)

2. Pool anlegen

/gateway-pools → "Pool erstellen":

  1. Name: Aussagekräftiger Bezeichner (z. B. Heimnetz)
  2. Modus:
    • Failover (priorisiert) — primärer Gateway bedient, Backup nimmt nur bei Ausfall
    • Load-Balancing — alle alive Members bedienen parallel (lizenzabhängig)
  3. LB-Policy (nur Load-Balancing):
    • round_robin — gleichverteilt nach Reihenfolge
    • least_conn — Member mit wenigsten aktiven Verbindungen
    • ip_hash — Sticky pro Client-IP (gleicher Client → gleicher Member)
  4. Failback-Cooldown: Wie lange nach Recovery gewartet wird, bevor Routen zurück wandern. Presets:
    • 60 s — Linux-Container (LXC), schneller Reboot
    • 180 s — Linux-VM
    • 600 s (10 min) — Proxmox-Host
    • 900 s (15 min) — Synology / QNAP NAS
    • 1800 s (30 min) — Windows-Server
    • 3600 s (60 min) — Konservativ
  5. Outage-Nachricht (optional): Custom 503-Body, wenn ALLE Member down

3. Member hinzufügen

Im Modal rechts:

  1. Gateway aus Dropdown wählen → "Hinzufügen"
  2. Position #1 ist primär (höchste Priorität). Per Drag & Drop sortieren.
  3. Bereits hinzugefügte Gateways verschwinden aus dem Dropdown (= keine Doppelung möglich)
  4. "Speichern" persistiert atomar (PUT /api/v1/gateway-pools/:id/members)

4. Routen verbinden

Bei Failover: Nichts zu tun. Sobald die Route's target_peer_id einem Pool-Member gehört, greift die Failover-Logik beim nächsten state-change automatisch.

Bei Load-Balancing:

/gateway-pools → "Routen migrieren":

  1. Ziel-Pool auswählen
  2. Liste zeigt alle Gateway-pinned Routen, gruppiert nach Quell-Peer
  3. Loopback-Routen (127.0.0.1) sind gelb markiert + standardmäßig ungecheckt — Begründung: ssh.example.com → 127.0.0.1:22 bedeutet "auf der Gateway-Maschine selbst". Wenn das auf einen anderen Member migriert wird, ändert sich die Zielmaschine.
  4. Auswahl prüfen, "OK" → atomarer DB-Update + Caddy-Resync

Alternativ: Routen einzeln über /routes editieren und target_kind: pool, Ziel-Pool wählen.


Failover-Mode im Detail

Mechanismus

Normaler Betrieb:
  routes.target_peer_id = 79 (Home)
  routes.original_peer_id = NULL
  ↓
  Frontend-Caddy → 10.8.0.8:18080 (Home companion-caddy)
  → 192.168.1.5:5001 (NAS auf Home-LAN)

Home fällt aus:
  watchdogTick (alle 30 s) → evaluatePeer → alive=0 → transition='alive_to_down'
  ↓
  _onTransition('alive_to_down', peerId=79):
    UPDATE routes
    SET target_peer_id = 84,         -- DS918, höchste Priorität alive
        original_peer_id = 79,       -- Quelle merken
        updated_at = NOW
    WHERE target_peer_id = 79 AND original_peer_id IS NULL AND target_kind = 'gateway'
    ↓
    syncToCaddy()  -- Caddy reload mit neuen Upstream
    notifyConfigChanged(79)  -- Home weiß nichts mehr von den Routen
    notifyConfigChanged(84)  -- DS918 zieht die Routen jetzt
  ↓
  Frontend-Caddy → 10.8.0.2:18080 (DS918 companion-caddy)
  → 192.168.1.5:5001 (NAS, jetzt von DS918 aus erreicht)

Home recovered:
  watchdogTick → cooldown_to_alive → transition fired NACH failback_cooldown_s
  ↓
  _onTransition('cooldown_to_alive', peerId=79):
    UPDATE routes
    SET target_peer_id = original_peer_id,
        original_peer_id = NULL,
        updated_at = NOW
    WHERE original_peer_id = 79
    ↓
    syncToCaddy() + notifyConfigChanged für 79 + 84
  ↓
  Routen wieder auf Home, normal weiter.

Boot-Reconcile

Transitionen feuern nur bei State-Changes. Wenn ein Peer beim Container-Start bereits offline ist, gibt's kein alive_to_down → kein Pivot. gatewayHealth.reconcileFailoverState() läuft 1× beim Server-Boot und holt das nach:

  1. Für jeden offline Pool-Member: alive Sibling finden, Routen pivoten
  2. Für jede Route mit original_peer_id != NULL deren Original-Peer wieder alive ist: Route zurück migrieren

Damit ist die DB nach Container-Restart konsistent mit dem realen Health-State.

Beobachtbarkeit

Activity-Log-Events:

  • gateway_down / gateway_alive — State-Change pro Peer
  • pool_failover_activated — Routes wurden auf Sibling pivotet (mit fromPeerId/toPeerId)
  • pool_failover_restored — Routes zurück auf originalen Peer
  • pool_outage_started / pool_outage_resolved — Alle/erster Pool-Member offline

Webhook (falls konfiguriert): gateway_state_change mit Payload {peer_id, alive: bool}.

SQL für aktuellen Stand:

SELECT id, domain, target_peer_id, original_peer_id
FROM routes WHERE target_kind='gateway' AND enabled=1;

Eine Zeile zeigt sofort wer wohin routet — original_peer_id != NULL heißt "aktuell im Failover".


Load-Balancing-Mode im Detail

Aktivierung

Eine Route nutzt LB nur, wenn alle drei Bedingungen stimmen:

  1. route.target_pool_id gesetzt (NICHT target_peer_id)
  2. Pool-mode = 'load_balancing'
  3. Pool-lb_policy ∈ {round_robin, least_conn, ip_hash}

Caddy-Konfiguration (HTTP)

Beim Build-Cycle ruft caddyConfig.resolveRouteUpstreams gatewayPool.resolveActivePeers(snapshot) auf — die gibt alle alive Pool-Member zurück:

"reverse_proxy": {
  "upstreams": [
    { "dial": "10.8.0.2:18080" },
    { "dial": "10.8.0.8:18080" }
  ],
  "load_balancing": {
    "selection_policy": { "policy": "round_robin" }
  },
  "health_checks": {
    "passive": {
      "fail_duration": "30s",
      "max_fails": 3,
      "unhealthy_status": [500, 502, 503, 504]
    }
  }
}

LB-Policies

Policy Verhalten Wann sinnvoll
round_robin Reihum durch die Upstream-Liste Symmetrische Last, identische Member
least_conn Member mit wenigsten aktiven Connections Lange Streams (Jellyfin-Streaming, Backups)
ip_hash Hash über Client-IP → fester Member Session-Affinität ohne Cookies (Legacy-Apps)

Trusted Proxies (für ip_hash hinter CDN/LB)

Wenn GateControl hinter einer privaten LB oder einem CDN steht, sieht der Server-Caddy als Remote-IP nur die Proxy-IP — ip_hash wäre dann wirkungslos (alle Requests gleichen Bucket). Workaround ist im srv0-HTTP-Server eingebaut:

{
  "trusted_proxies": {
    "source": "static",
    "ranges": [
      "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
      "100.64.0.0/10", "fd00::/8", "::1/128", "127.0.0.0/8"
    ]
  },
  "client_ip_headers": ["X-Forwarded-For"]
}

X-Forwarded-For wird nur geehrt wenn die Connection aus einer der gelisteten Ranges kommt. Direkt aus dem Internet kommende Clients können XFF nicht spoofen, weil ihre Source-IP nicht in der Trust-Liste steht.

Passive Health-Checks

Zusätzlich zum gatewayHealth-Watchdog (alle 30 s) prüft Caddy selbst passiv:

  • Schickt eine Anfrage an Member X
  • X antwortet mit 5xx-Status (oder Connection-Fail)
  • Nach max_fails=3 solchen Fehlern wird X für fail_duration=30s aus der Rotation genommen
  • Nach 30 s wieder probieren

Damit greift Circuit-Breaking sekündlich, statt erst nach dem 90s-gateway_down_threshold der globalen Health-Logik.

L4-Load-Balancing (TCP/UDP)

Funktioniert genauso für L4-Routen — caddy-l4's proxy-handler bekommt mehrere upstreams[] plus load_balancing.selection_policy:

{
  "handler": "proxy",
  "upstreams": [
    { "dial": ["10.8.0.2:13389"] },
    { "dial": ["10.8.0.8:13389"] }
  ],
  "load_balancing": {
    "selection_policy": { "policy": "round_robin" }
  },
  "health_checks": {
    "passive": { "fail_duration": "30s", "max_fails": 3 }
  }
}

L4-Routen müssen route_type='l4' sein und Pool-gebunden über target_pool_id.


Health-Monitoring

Heartbeat-Modell

  • Companion auf jedem Gateway sendet alle ~10 s einen Heartbeat (HTTPS) an den Server
  • Server speichert gateway_meta.last_seen_at
  • evaluatePeer (alle 30 s pro Watchdog-Tick) prüft now - last_seen_at gegen gateway_down_threshold_s (default 90 s)

State-Maschine

       evaluatePeer()                 evaluatePeer()
            │                              │
            ▼                              ▼
    ┌───────────┐  isStale       ┌───────────┐
    │  alive=1  │ ────────────▶ │  alive=0  │
    │           │                │           │
    │  (alive)  │ ◀──────────── │  (down)   │
    └───────────┘  cooldown      └───────────┘
                   _to_alive            │
                       ▲                │ heartbeat received
                       │                │ (still within threshold)
                       │                ▼
                       │          ┌───────────┐
                       │          │  alive=0  │
                       │          │recovered_ │
                       │          │first_hb_at│
                       └───────── │(cooldown) │
                                  └───────────┘
                                       │
                                       │ heartbeat-Lücke
                                       ▼
                                  cooldown_reset
                                  (zurück zu down)

Übergänge:

  • alive_to_down — Peer war alive, jetzt stale → Routen pivoten
  • down_to_cooldown — Peer war down, erster Heartbeat angekommen → cooldown-Timer starten
  • cooldown_to_alive — Cooldown-Zeit abgelaufen ohne Heartbeat-Lücke → Recovery, Routen restoren
  • cooldown_reset — Während Cooldown gab's eine Heartbeat-Lücke → zurück zu down (kein Restore!)
  • first_alive — Allererster Heartbeat ohne went_down_at-Marker (z. B. nach DB-Wipe)

failback_cooldown_s ist pro Pool konfigurierbar — getMaxCooldownForPeer(peerId) nimmt das Maximum aller Pools, in denen der Peer Mitglied ist.

Snapshot

gatewayHealth.getSnapshot() ist der In-Memory-Cache mit {[peerId]: {alive, last_seen_at, went_down_at, recovered_first_hb_at}}. Wird von evaluatePeer aktualisiert. caddyConfig.resolveRouteUpstreams nutzt das beim Build.

Boot-Spezialfall: Snapshot ist initial leer. resolveRouteUpstreams seedet ihn aus der DB (gateway_meta.alive), damit Pool-Routen beim Boot-Caddy-Build korrekt aufgelöst werden statt 503 zu liefern.


Edge-Cases

Alle Member offline

resolveActivePeer returned null / resolveActivePeers returned []resolveRouteUpstreams returned outage: truecaddyConfig rendert einen 503-static-response mit outage_message (statt eines reverse_proxy zu einem toten Backend). User sieht eine kontrollierte Ausfall-Seite.

Mehrfach-Failover

T=0: Home alive, DS918 alive          → routes target_peer_id=79
T=1: Home → down                      → routes target_peer_id=84, original=79
T=2: DS918 → down (Home noch down)    → kein Pivot (kein alive Sibling), routes bleiben auf 84
T=3: Home → recovered                 → routes target_peer_id=79, original=NULL
                                        (Pivot-Logic findet original=79 → restored)

Der WHERE original_peer_id IS NULL-Guard im Failover-UPDATE verhindert "Doppel-Pivot" — eine bereits weggepivotete Route wird beim 2. Failover NICHT erneut geändert. So bleibt original_peer_id immer beim wirklich originalen Pin.

Peer aus Pool entfernen während Routen darauf gepinnt sind

gatewayPool.replaceMembers prüft beim Leeren eines Pools (members.length === 0):

  • Existieren noch Routen mit target_pool_id = poolId?
  • Existieren noch RDP-Routen mit gateway_pool_id = poolId?
  • Falls ja → last_member_in_use Error, Save fehlt

Bei target_peer_id (peer-pinned ohne Pool-Bindung) ist das Entfernen aus dem Pool nicht blockiert — die Route bleibt einfach am Peer gepinnt, ohne Failover-Schutz.

Peer in mehreren Pools

listPoolsForPeer(peerId) returned alle Pools, in denen der Peer Mitglied ist. _onTransition iteriert sie alle:

  • Beim ersten Pool wo ein alive Sibling existiert: Routen pivoten, break aus der Schleife
  • Subsequente Pools würden bei diesem Pivot nichts mehr finden (WHERE original_peer_id IS NULL matcht nichts mehr)

Mehrfach-Pool-Mitgliedschaft ist also unterstützt, aber nur ein Pool gewinnt pro state-change.

Drag-and-Drop Priorität ändern

PUT /api/v1/gateway-pools/:id/members mit der vollständigen Member-Liste in DOM-Reihenfolge. Backend macht atomar:

DELETE FROM gateway_pool_members WHERE pool_id = ?;
INSERT INTO gateway_pool_members ... -- für jeden Member, neue Priorität = Index+1

in einer Transaktion. Reihenfolge im UI = neue Priorität. Anschließend applyPoolMutationWithSequencing: einmalig Caddy-Sync + Companion-Confirm.


Architektur-Referenz

Datei-Karte

Datei Verantwortung
src/services/gatewayPool.js Pool/Member-CRUD, replaceMembers (atomar), resolveActivePeer(s)
src/services/gatewayHealth.js Heartbeat-Watchdog, _onTransition (DB-Pivot bei state-change), reconcileFailoverState (Boot)
src/services/caddyConfig.js buildCaddyConfig, resolveRouteUpstreams (für target_pool_id), HTTP-LB + passive HC
src/services/l4.js L4-Caddy-Server-Config inkl. multi-upstream + LB
src/services/gatewayPoolSync.js applyPoolMutationWithSequencing — Companion-Confirm + Caddy-Sync nach Pool-Mutation
src/services/gateways.js getGatewayConfig (Companion-Pull), notifyConfigChanged (Server-Push), Hash-Computing
src/routes/api/gatewayPools.js REST-API: CRUD, members bulk PUT, migrate-routes
src/db/migrationList.js v40: pools+members, v42: proxy_port, v43: routes.original_peer_id

DB-Schema (relevant)

CREATE TABLE gateway_pools (
  id INTEGER PRIMARY KEY,
  name TEXT NOT NULL UNIQUE,
  mode TEXT NOT NULL,            -- 'failover' | 'load_balancing'
  lb_policy TEXT,                -- NULL für failover, sonst round_robin/least_conn/ip_hash
  failback_cooldown_s INTEGER NOT NULL,
  outage_message TEXT,
  enabled INTEGER NOT NULL DEFAULT 1,
  created_at, updated_at
);

CREATE TABLE gateway_pool_members (
  pool_id INTEGER REFERENCES gateway_pools(id) ON DELETE CASCADE,
  peer_id INTEGER REFERENCES peers(id) ON DELETE CASCADE,
  priority INTEGER NOT NULL,
  PRIMARY KEY (pool_id, peer_id)
);

-- routes-Tabelle (relevant für Pools):
--   target_kind        TEXT       'gateway' für Gateway-Routen
--   target_peer_id     INTEGER    aktuell servierender Peer (kann via Pivot wechseln)
--   target_pool_id     INTEGER    alternativ: explicit pool routing (LB)
--   original_peer_id   INTEGER    v43: Original-Pin für Failover-Restore (NULL = nicht im Failover)

CREATE TABLE gateway_meta (
  peer_id INTEGER PRIMARY KEY,
  api_port INTEGER NOT NULL DEFAULT 9876,
  api_token_hash TEXT NOT NULL,
  push_token_encrypted TEXT NOT NULL,
  alive INTEGER NOT NULL DEFAULT 1,
  last_seen_at INTEGER,
  went_down_at INTEGER,
  recovered_first_hb_at INTEGER,
  last_config_hash TEXT,
  proxy_port INTEGER NOT NULL DEFAULT 8080,    -- v42: Per-Peer Proxy-Port (Synology DSM nutzt 18080)
  created_at INTEGER NOT NULL
);

REST-API (Auszug)

GET    /api/v1/gateway-pools                                List pools
POST   /api/v1/gateway-pools                                Create pool
GET    /api/v1/gateway-pools/:id                            Get pool with members
PUT    /api/v1/gateway-pools/:id                            Update pool
DELETE /api/v1/gateway-pools/:id                            Delete pool (rejected if in use)

GET    /api/v1/gateway-pools/:id/members                    List members
POST   /api/v1/gateway-pools/:id/members                    Add single member
PUT    /api/v1/gateway-pools/:id/members                    Bulk replace member set (atomar)
PUT    /api/v1/gateway-pools/:id/members/:peerId            Set priority of single member
DELETE /api/v1/gateway-pools/:id/members/:peerId            Remove member

GET    /api/v1/gateway-pools/migration-candidates           List peer-pinned routes + pools (für UI)
POST   /api/v1/gateway-pools/:id/migrate-routes             { route_ids: [...] } — bulk migrate

Konfiguration

gateway_down_threshold_s (in settings-Tabelle) — global, default 90 s. Niedriger = schnellere Reaktion auf Ausfälle, aber empfindlicher gegen Netzwerk-Hicks. Über /settings → "Gateway-Failover" einstellbar (UI-Limit: 30–600 s).

Active-Active-Pattern (zwei Pools mit gespiegelter Priorität)

Ein Pool hat genau einen Modus. Active-Active entsteht durch zwei Pools mit gespiegelter Priorität:

Pool GW1-Prio GW2-Prio
Heimnetz-A 1 2
Heimnetz-B 2 1

Routen für Service-Gruppe A werden auf Pool-A gemappt (primär GW1), Routen für Service-Gruppe B auf Pool-B (primär GW2). Bei Ausfall eines Gateways übernimmt der jeweils andere Routen aus beiden Pools — die Last verteilt sich im Normalbetrieb, Failover ist trotzdem voll abgedeckt.


Beispiel-Setup: Heimnetz-Failover

Typische Heim-Konfiguration mit zwei Gateways: ein Synology NAS (DS918, 192.168.2.151) und ein Linux-Mini-PC ("Home", 192.168.2.5). Beide auf demselben LAN, beide erreichen alle Backend-Hosts.

1. /peers → "Home Gateway" und "DS918 Gateway" anlegen, beide enabled
2. /gateway-pools → Pool "Heimnetz" anlegen:
   - Modus: Failover
   - Failback-Cooldown: 900s (NAS-Preset, weil DS918 längere Updates hat)
   - Members: Home (Position #1), DS918 (Position #2)
3. Bestehende Routen brauchen NICHTS — Failover greift automatisch
4. Test:
   - DS918 reboot → Home als Primär bleibt online → keine Auswirkung
   - Home reboot → Routen wandern zu DS918 → Services bleiben erreichbar
   - Home recovered → nach 15 min Cooldown zurück zu Home

Bei reinem Failover bleibst Du bei target_peer_id-Pinning. Erst wenn Du Load-Balancing willst (z. B. Streaming + Backup parallel über beide Gateways), ist die "Routen migrieren"-Funktion nötig.

Cookie Settings

Wir verwenden Cookies, um Ihre Erfahrung zu verbessern. Essentielle Cookies sind immer aktiv.

Datenschutzerklärung
ESC
↑↓ navigate open esc close