Gateway-Pools
Inhaltsverzeichnis
- Problem & Motivation
- Konzepte
- Setup
- Failover-Mode im Detail
- Load-Balancing-Mode im Detail
- Health-Monitoring
- Edge-Cases
- Troubleshooting
- 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.5etc.) 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_iddirekt aus der DB - Bei Ausfall:
gatewayHealth._onTransitionschreibttarget_peer_idauf den nächst-höchst-priorisierten alive Pool-Member um, merkt sich den Original inoriginal_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":
- Name: Aussagekräftiger Bezeichner (z. B.
Heimnetz) - Modus:
Failover (priorisiert)— primärer Gateway bedient, Backup nimmt nur bei AusfallLoad-Balancing— alle alive Members bedienen parallel (lizenzabhängig)
- LB-Policy (nur Load-Balancing):
round_robin— gleichverteilt nach Reihenfolgeleast_conn— Member mit wenigsten aktiven Verbindungenip_hash— Sticky pro Client-IP (gleicher Client → gleicher Member)
- Failback-Cooldown: Wie lange nach Recovery gewartet wird, bevor Routen zurück wandern. Presets:
60 s— Linux-Container (LXC), schneller Reboot180 s— Linux-VM600 s(10 min) — Proxmox-Host900 s(15 min) — Synology / QNAP NAS1800 s(30 min) — Windows-Server3600 s(60 min) — Konservativ
- Outage-Nachricht (optional): Custom 503-Body, wenn ALLE Member down
3. Member hinzufügen
Im Modal rechts:
- Gateway aus Dropdown wählen → "Hinzufügen"
- Position #1 ist primär (höchste Priorität). Per Drag & Drop sortieren.
- Bereits hinzugefügte Gateways verschwinden aus dem Dropdown (= keine Doppelung möglich)
- "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":
- Ziel-Pool auswählen
- Liste zeigt alle Gateway-pinned Routen, gruppiert nach Quell-Peer
- Loopback-Routen (
127.0.0.1) sind gelb markiert + standardmäßig ungecheckt — Begründung:ssh.example.com → 127.0.0.1:22bedeutet "auf der Gateway-Maschine selbst". Wenn das auf einen anderen Member migriert wird, ändert sich die Zielmaschine. - 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:
- Für jeden offline Pool-Member: alive Sibling finden, Routen pivoten
- Für jede Route mit
original_peer_id != NULLderen 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 Peerpool_failover_activated— Routes wurden auf Sibling pivotet (mitfromPeerId/toPeerId)pool_failover_restored— Routes zurück auf originalen Peerpool_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:
route.target_pool_idgesetzt (NICHTtarget_peer_id)- Pool-
mode = 'load_balancing' - 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=3solchen Fehlern wird X fürfail_duration=30saus 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üftnow - last_seen_atgegengateway_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 pivotendown_to_cooldown— Peer war down, erster Heartbeat angekommen → cooldown-Timer startencooldown_to_alive— Cooldown-Zeit abgelaufen ohne Heartbeat-Lücke → Recovery, Routen restorencooldown_reset— Während Cooldown gab's eine Heartbeat-Lücke → zurück zu down (kein Restore!)first_alive— Allererster Heartbeat ohnewent_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: true → caddyConfig 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_useError, 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 NULLmatcht 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.