Home-Gateway
Überblick
Ein Home-Gateway ist ein Companion-Container, der in einem LAN (Heimnetz, Zweigstelle, VPS-Niederlassung) läuft und zwei Aufgaben übernimmt: Er terminiert eine WireGuard-Verbindung zum GateControl-Server und stellt den LAN-Geräten hinter sich so zur Verfügung, dass der Server sie erreichen kann — ohne dass jedes LAN-Gerät einen eigenen WireGuard-Peer braucht und ohne Port-Forwarding im Heimrouter.
Das Problem, das er löst: Ein Kunde will seinen NAS, seine Überwachungskamera und sein Synology-Panel über GateControl öffentlich machen. Mit reinen WG-Peers müsste auf jedem Gerät ein WireGuard-Client installiert werden (NAS ja, Kamera nein). Alternativ müsste der Heimrouter Port-Forwarding zum GateControl-Server machen, was bei CG-NAT sowieso nicht geht. Der Home-Gateway sitzt einmal im LAN als WG-Peer, baut von sich aus den Tunnel auf und nimmt LAN-seitig HTTP/TCP entgegen.
Der Gateway ist ein eigenes Projekt (gatecontrol-gateway unter /root/gatecontrol-gateway/), das wiederum Caddy-artig Konfiguration pulled, Routen zur LAN-Seite forwarded und seinen eigenen Health-State an den Server meldet. Der Server sieht den Gateway als speziellen Peer (peer_type='gateway') mit Zusatztabellen (gateway_meta) und verwendet ihn als Upstream-Indirection in Caddy.
Architektur
Client ──HTTPS──▶ Caddy (Server) ──WG-Tunnel──▶ Gateway-Container ──LAN──▶ LAN-Host
│ │
│ reverse_proxy │ HTTP-Proxy :8080
│ → 10.8.0.X:8080 │ (liest X-Gateway-Target
│ + X-Gateway-Target │ und dispatcht)
│ + X-Gateway-Target-Domain │
│ │ TcpProxyManager
│ (L4-Routen) ─────────────────▶ :l4_listen_port
│ │ → lan_host:lan_port
▼ │
GateControl-Server │
▲ │
│ Heartbeat, Config-Check, │
│ Probe │
◀──── Gateway pulls über WG ───┘
│
│ Push-Kanal (notifyWol, │
│ notifyConfigChanged) │
────────────────────────────▶ │
Beteiligte Rollen
| Rolle | Wo | Aufgabe |
|---|---|---|
| Gateway-Peer (DB-Record) | Server | peers-Zeile mit peer_type='gateway' + gateway_meta |
| Gateway-Companion | Kundennetz | Docker-Container aus gatecontrol-gateway |
| WireGuard-Tunnel | zwischen beiden | Gateway = /32, Server = /32 — beide Seiten kennen genau einen Peer |
| Caddy-Indirection | Server | Gateway-Routen sind reverse_proxy → 10.8.0.X:8080 |
| HTTP-Proxy | Gateway | lauscht auf :8080, dispatcht nach X-Gateway-Target |
| TcpProxyManager | Gateway | lauscht für L4-Routen auf dedizierten Ports |
Datenmodell
Tabelle peers (relevante Spalten)
peers (
id, name, public_key, private_key_encrypted, preshared_key_encrypted,
allowed_ips, -- "10.8.0.X/32"
peer_type TEXT DEFAULT 'regular', -- 'regular' | 'gateway'
hostname, -- (siehe internal-dns.md)
...
)
Tabelle gateway_meta
gateway_meta (
peer_id INTEGER PRIMARY KEY REFERENCES peers(id) ON DELETE CASCADE,
api_port INTEGER NOT NULL DEFAULT 9876,
api_token_hash TEXT NOT NULL, -- sha256: vom API-Token
push_token_encrypted TEXT NOT NULL, -- AES-GCM-verschlüsselt
needs_repair INTEGER DEFAULT 0, -- Pairing-Invalid-Marker
last_seen_at INTEGER, -- epoch ms des letzten Heartbeats
last_health TEXT, -- JSON des letzten Heartbeat-Bodies
last_config_hash TEXT, -- letzter Hash aus /config/check
created_at INTEGER
)
Die beiden Tokens haben getrennte Aufgaben:
| Token | Richtung | Verwendung |
|---|---|---|
api_token |
Gateway → Server | Bearer-Auth für /api/v1/gateway/* Endpoints |
push_token |
Server → Gateway | Header X-Gateway-Token für Push-Kanal (/api/wol, /api/config-changed) |
Der API-Token wird als SHA-256 gespeichert (wie alle Bearer-Tokens). Der Push-Token muss der Server im Klartext abrufen können, darum AES-GCM-verschlüsselt. Der Klartext wird einmal beim Create oder Rotate ausgegeben.
Provisioning-Flow
- Admin ruft
POST /api/v1/peersmitis_gateway: trueauf. - Server prüft Lizenzlimit
gateway_peers, erzeugt WG-Keypair, schreibtpeers-Zeile mitpeer_type='gateway'. createGateway()generiert API-Token und Push-Token (je 32 Random Bytes), speichert Hash bzw. verschlüsselt.- Server baut eine
.env-Datei (Template inbuildEnvContent()) mit:- Server-URL, API-Token, Push-Token (Klartext, nur jetzt einsichtbar)
- WireGuard-Privatekey (vom Peer, entschlüsselt aus DB)
WG_ADDRESS=10.8.0.X/32,WG_ALLOWED_IPS=10.8.0.1/32— bewusst /32, damit der Gateway-Host nicht zwei konkurrierende /24-Routen für dasselbe VPN hat (siehe Code-KommentarbuildEnvContent).
- Admin lädt die .env, legt sie in den
gatecontrol-gateway-Compose-Stack und startetdocker compose up -d. - Gateway-Container baut WG-Tunnel auf, sendet ersten Heartbeat. State-Machine geht nach 4/5 Erfolgen auf
online.
Token-Rotation
POST /api/v1/peers/:id/gateway-env/rotate ruft rotateGatewayTokens() auf:
- Neue Tokens + Hashes werden generiert.
gateway_meta.api_token_hashundpush_token_encryptedwerden überschrieben.needs_repair=0(falls der Gateway vorher als repair-needed markiert war).buildEnvContent()liefert die neue .env-Datei.
Der laufende Container ist ab sofort mit seinem alten API-Token gegen den Server gesperrt. Der Admin muss die neue .env ausrollen und neustarten.
Heartbeat und Health
Protokoll
Der Gateway sendet alle ~30 Sekunden (GC_HEARTBEAT_INTERVAL_S) einen POST /api/v1/gateway/heartbeat mit Bearer-Auth:
{
"uptime_s": 12345,
"hostname": "nas1",
"http_proxy_healthy": true,
"tcp_listeners": [
{"port": 3389, "status": "listening"},
{"port": 8000, "status": "listener_failed", "error": "EADDRINUSE"}
],
"route_reachability": [
{"route_id": 17, "target": "192.168.1.50:80", "reachable": true, "elapsed_ms": 3}
]
}
Seiteneffekte im Server
handleHeartbeat(peerId, health) in src/services/gateways.js:
gateway_meta.last_seen_atundlast_healthaktualisieren._getSm(peerId).recordHeartbeat(healthy)— StateMachine tickt.- Bei Status-Transition:
_onStatusTransition→ Caddy-Partial-Patch, Activity-Log, Email-Alert, Webhook. - Bei Flap (>4 Transitions/h):
gateway_flap_warningloggen. - Bei aktivem
internal_dns: Hostname-Feld als Agent-Report anpeers.setHostname(peerId, body.hostname, 'agent'). Sticky-Admin-Policy bleibt eingehalten (siehe internal-dns.md).
_isHeartbeatHealthy — warum diese Priorität
Die Funktion entscheidet, ob ein Heartbeat einen "healthy tick" auslöst. Die Prioritätsreihenfolge:
route_reachability— wenn vorhanden und nicht leer: alle Einträge müssenreachable:truesein.- Self-Check-Fallback (
http_proxy_healthy+tcp_listeners) — wenn keine Reachability-Probe ging:http_proxy_healthymuss true sein und keinlistener_failed. - Bare Heartbeat — wenn weder Reachability noch Self-Check-Feld da ist: als healthy werten. Der Prozess ist offensichtlich am Leben.
Hintergrund: In NAS1-artigen Deployments bindet der HTTP-Proxy auf einer bestimmten Netzwerk-Schnittstelle, während der Self-Check auf Localhost geht und schlägt fehl — obwohl die konfigurierten LAN-Targets tatsächlich antworten. Die alte Logik hätte diese Gateways fälschlich offline markiert. route_reachability ist die empirische Ground-Truth; der Self-Check nur Fallback.
State-Machine
src/services/gatewayHealth.js — StateMachine-Klasse:
| Parameter | Default | Bedeutung |
|---|---|---|
windowSize |
5 | Grösse des Sliding-Window |
offlineThreshold |
3 | Fails im Fenster → offline |
onlineThreshold |
4 | Erfolge im Fenster → online |
cooldownMs |
5 min | Mindestzeit zwischen Transitions |
- Aus
unknowngeht es ohne Cooldown nach online/offline. - Stable-state → stable-state nur nach Cooldown, um Flapping zu dämpfen.
flapCountLastHour()zählt Transitions im letzten 60-Minuten-Fenster (ohne dieunknown-Warmup-Transition).- StateMachines werden in
_smCachepropeerIdgehalten (Memory, nicht persistiert). Nach Server-Restart geht der State aufunknownbis erste Heartbeats kommen.
Config-Pull
Der Gateway pulled seine Config, statt sie gepusht zu bekommen:
GET /api/v1/gateway/config/check?hash=sha256:…— Gateway sendet den Hash seiner aktuellen Config.- Server ruft
computeConfigHash(peerId)auf. Bei Match: 304 Not Modified. Sonst: 200 + neuer Hash. - Bei Mismatch pulled der Gateway
GET /api/v1/gateway/configund übernimmt das JSON-Payload.
getGatewayConfig(peerId) liefert alle aktiven HTTP- und L4-Routen mit target_peer_id = peerId. Das JSON-Schema ist per CONFIG_HASH_VERSION versioniert und in einer geteilten Library @callmetechie/gatecontrol-config-hash implementiert — der Gateway nutzt dieselbe Bibliothek und berechnet seinen lokalen Hash byte-identisch.
Das Polling-Intervall (GC_POLL_INTERVAL_S=300) ist die Fallback-Frequenz. Für schnellere Updates nutzt der Server den Push-Kanal.
Push-Kanal
Der Server kann dem Gateway aktiv sagen, dass was passiert ist — über seinen API-Port (Default 9876), der über den WG-Tunnel zurück zum Gateway-Container erreichbar ist.
notifyConfigChanged(peerId)
Feuert nach jedem Route-Save, das einen Gateway betrifft. Body-less POST an http://<gateway-ip>:<api_port>/api/config-changed mit Header X-Gateway-Token: <push-token>. Der Gateway debounced 500ms und pulled dann frische Config.
Best-effort: Timeout 2s, Fehler werden geloggt aber nicht retried. Das nächste normale Poll-Interval würde es sowieso erledigen.
notifyWol(peerId, {mac, lan_host, timeout_ms})
Feuert, wenn eine Route mit wol_enabled=true aufgerufen wird oder der Admin explizit WoL triggert. POST an /api/wol mit JSON-Body; der Gateway sendet im LAN das Magic-Packet und antwortet mit {success, elapsed_ms}.
Timeout ist timeout_ms + 5000 (Gateway braucht Zeit zum Warten auf Boot-Response).
Caddy-Integration
In caddyConfig.buildCaddyConfig():
- Bei
target_kind='gateway'wird der Upstream aufgatewayPeerIp:8080gesetzt (gatewayProxyPort, konfigurierbar). - Headers werden injiziert:
X-Gateway-Target: <lan_host>:<lan_port>,X-Gateway-Target-Domain: <domain>. backend_httpswird bewusst ignoriert, weil zum Gateway-Port immer HTTP spricht. Wenn das LAN-Target HTTPS will, regelt das der Gateway selbst (via Flagbackend_httpsim Gateway-Config, der dann LAN-seitig TLS spricht mitinsecure_skip_verify).- Bei Status-Transition patcht
patchGatewayRouteHandlersden Live-Caddy: offline →static_response502 mit Maintenance-HTML, online →revert.
L4-Gateway-Routen
L4-Gateway-Routen laufen NICHT über Caddys HTTP-Server, sondern über apps.layer4. Upstream ist gateway-peer-ip:l4_listen_port — der Gateway-Container hat auf diesem Port seinen eigenen TCP-Listener (TcpProxyManager), der zum LAN-Host forwardet. Der Server-Caddy reicht nur die TCP-Verbindung weiter. Siehe routing.md für Details.
Config-Hash
Zentrales Instrument für "sind Server und Gateway in sync?". computeConfigHash(peerId) ruft getGatewayConfig(peerId) und delegiert an die geteilte Library, die aus dem JSON eine kanonische Form (Keys sortiert, keine Zahlentypen-Drift) baut und SHA-256 darüber bildet. Server und Gateway benutzen dieselbe Library — bei Mismatch weiss man, dass einer von beiden out-of-sync ist.
Im Admin-UI wird der Hash als kurzes Kürzel (erste 8 Zeichen) neben dem Gateway-Panel angezeigt.
Lizenzierung
gateway_peers— Limit für Anzahl Gateway-Peers. Enforced increateGateway().gateway_http_targets— Limit für HTTP-Routen pro Gateway (enforced im Route-Create).gateway_tcp_routing— Feature-Flag für L4-Routen auf Gateways.gateway_wol— Feature-Flag für WoL-Trigger via Gateway.
Siehe licensing.md.
Siehe auch
- routing.md — wie Caddy Gateway-Upstreams rendert.
- internal-dns.md — Hostname-Reports via Heartbeat.
- rdp-routes.md — RDP-Gateway-Mode nutzt den Push-Kanal für WoL und erzeugt L4-Gateway-Routen.
- licensing.md —
gateway_*-Feature-Flags. - ../API.md —
/api/v1/gateway/*Companion-Endpoints und/api/v1/gatewaysAdmin-View.
Quelldateien
src/services/gateways.js—createGateway,handleHeartbeat,_isHeartbeatHealthy,notifyWol,notifyConfigChanged,computeConfigHash,buildEnvContent,rotateGatewayTokens.src/services/gatewayHealth.js—StateMachine-Klasse mit Sliding-Window-Hysterese.src/routes/api/gateway.js— Companion-Endpoints (/heartbeat,/config,/config/check,/status,/probe).src/routes/api/gateways.js— Admin-View/api/v1/gatewaysmit Status + Routes pro Gateway.src/middleware/gatewayAuth.js— Bearer-Token-Auth für Companion-Endpoints.src/db/migrations.js— Migration 36 (add_gateway_support), 37 (gateway_meta_last_health)./root/gatecontrol-gateway/— Companion-Projekt (separates Repo).