Home Gateway
Overview
A Home Gateway is a companion container that runs in a LAN (home network, branch office, VPS subsidiary) and handles two tasks: it terminates a WireGuard connection to the GateControl server and exposes the LAN devices behind it in a way that the server can reach them — without every LAN device needing its own WireGuard peer and without port forwarding on the home router.
The problem it solves: a customer wants to expose their NAS, their surveillance camera, and their Synology panel publicly via GateControl. With pure WG peers, each device would need a WireGuard client installed (NAS yes, camera no). Alternatively, the home router would have to port-forward to the GateControl server, which doesn't work with CG-NAT anyway. The Home Gateway sits in the LAN once as a WG peer, establishes the tunnel itself, and accepts HTTP/TCP on the LAN side.
The gateway is its own project (gatecontrol-gateway under /root/gatecontrol-gateway/), which in turn pulls configuration Caddy-style, forwards routes to the LAN side, and reports its own health state to the server. The server sees the gateway as a special peer (peer_type='gateway') with additional tables (gateway_meta) and uses it as an upstream indirection in Caddy.
Architecture
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) │
────────────────────────────▶ │
Participating roles
| Role | Where | Task |
|---|---|---|
| Gateway peer (DB record) | Server | peers row with peer_type='gateway' + gateway_meta |
| Gateway companion | Customer network | Docker container from gatecontrol-gateway |
| WireGuard tunnel | Between the two | Gateway = /32, server = /32 — both sides know exactly one peer |
| Caddy indirection | Server | Gateway routes are reverse_proxy → 10.8.0.X:8080 |
| HTTP proxy | Gateway | Listens on :8080, dispatches by X-Gateway-Target |
| TcpProxyManager | Gateway | Listens for L4 routes on dedicated ports |
Data model
Table peers (relevant columns)
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)
...
)
Table 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
)
The two tokens have separate responsibilities:
| Token | Direction | Usage |
|---|---|---|
api_token |
Gateway → server | Bearer auth for /api/v1/gateway/* endpoints |
push_token |
Server → gateway | Header X-Gateway-Token for push channel (/api/wol, /api/config-changed) |
The API token is stored as SHA-256 (like all bearer tokens). The push token must be retrievable by the server in plaintext, hence AES-GCM-encrypted. The plaintext is emitted once on create or rotate.
Provisioning flow
- Admin calls
POST /api/v1/peerswithis_gateway: true. - Server checks the
gateway_peerslicense limit, generates a WG keypair, writes apeersrow withpeer_type='gateway'. createGateway()generates an API token and push token (32 random bytes each), stores the hash or encrypted form respectively.- Server builds an
.envfile (template inbuildEnvContent()) with:- Server URL, API token, push token (plaintext, only visible now)
- WireGuard private key (from the peer, decrypted from the DB)
WG_ADDRESS=10.8.0.X/32,WG_ALLOWED_IPS=10.8.0.1/32— intentionally /32, so that the gateway host doesn't have two competing /24 routes for the same VPN (see code comment inbuildEnvContent).
- Admin downloads the .env, places it in the
gatecontrol-gatewaycompose stack, and runsdocker compose up -d. - Gateway container establishes the WG tunnel, sends the first heartbeat. The state machine transitions to
onlineafter 4 out of 5 successes.
Token rotation
POST /api/v1/peers/:id/gateway-env/rotate invokes rotateGatewayTokens():
- New tokens + hashes are generated.
gateway_meta.api_token_hashandpush_token_encryptedare overwritten.needs_repair=0(if the gateway was previously marked repair-needed).buildEnvContent()returns the new .env file.
The running container is now locked out of the server with its old API token. The admin must roll out the new .env and restart.
Heartbeat and health
Protocol
The gateway sends every ~30 seconds (GC_HEARTBEAT_INTERVAL_S) a POST /api/v1/gateway/heartbeat with 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}
]
}
Server-side side effects
handleHeartbeat(peerId, health) in src/services/gateways.js:
- Update
gateway_meta.last_seen_atandlast_health. _getSm(peerId).recordHeartbeat(healthy)— state machine ticks.- On status transition:
_onStatusTransition→ Caddy partial patch, activity log, email alert, webhook. - On flap (>4 transitions/h): log
gateway_flap_warning. - If
internal_dnsis active: the hostname field is reported as an agent report topeers.setHostname(peerId, body.hostname, 'agent'). The sticky-admin policy is respected (see internal-dns.md).
_isHeartbeatHealthy — why this priority
The function decides whether a heartbeat triggers a "healthy tick". Priority order:
route_reachability— if present and not empty: all entries must bereachable:true.- Self-check fallback (
http_proxy_healthy+tcp_listeners) — if no reachability probe ran:http_proxy_healthymust be true and nolistener_failed. - Bare heartbeat — if neither reachability nor self-check fields are present: count as healthy. The process is obviously alive.
Background: in NAS1-style deployments, the HTTP proxy binds on a specific network interface, while the self-check goes against localhost and fails — even though the configured LAN targets actually respond. The old logic would have incorrectly marked these gateways offline. route_reachability is the empirical ground truth; the self-check is only a fallback.
State machine
src/services/gatewayHealth.js — StateMachine class:
| Parameter | Default | Meaning |
|---|---|---|
windowSize |
5 | Size of the sliding window |
offlineThreshold |
3 | Fails in the window → offline |
onlineThreshold |
4 | Successes in the window → online |
cooldownMs |
5 min | Minimum time between transitions |
- From
unknown, it goes to online/offline without cooldown. - Stable state → stable state only after cooldown, to dampen flapping.
flapCountLastHour()counts transitions in the last 60-minute window (excluding theunknownwarmup transition).- State machines are kept in
_smCacheperpeerId(memory, not persisted). After a server restart the state goes tounknownuntil the first heartbeats arrive.
Config pull
The gateway pulls its config rather than receiving it pushed:
GET /api/v1/gateway/config/check?hash=sha256:…— gateway sends the hash of its current config.- Server calls
computeConfigHash(peerId). On match: 304 Not Modified. Otherwise: 200 + new hash. - On mismatch, the gateway pulls
GET /api/v1/gateway/configand adopts the JSON payload.
getGatewayConfig(peerId) returns all active HTTP and L4 routes with target_peer_id = peerId. The JSON schema is versioned via CONFIG_HASH_VERSION and implemented in a shared library @callmetechie/gatecontrol-config-hash — the gateway uses the same library and computes its local hash byte-identically.
The polling interval (GC_POLL_INTERVAL_S=300) is the fallback frequency. For faster updates, the server uses the push channel.
Push channel
The server can actively tell the gateway that something happened — via its API port (default 9876), which is reachable through the WG tunnel back to the gateway container.
notifyConfigChanged(peerId)
Fires after every route save that affects a gateway. Body-less POST to http://<gateway-ip>:<api_port>/api/config-changed with header X-Gateway-Token: <push-token>. The gateway debounces 500ms and then pulls fresh config.
Best-effort: timeout 2s, errors are logged but not retried. The next normal poll interval would take care of it anyway.
notifyWol(peerId, {mac, lan_host, timeout_ms})
Fires when a route with wol_enabled=true is invoked or the admin explicitly triggers WoL. POST to /api/wol with JSON body; the gateway sends the magic packet on the LAN and responds with {success, elapsed_ms}.
Timeout is timeout_ms + 5000 (the gateway needs time to wait for the boot response).
Caddy integration
In caddyConfig.buildCaddyConfig():
- For
target_kind='gateway', the upstream is set togatewayPeerIp:8080(gatewayProxyPort, configurable). - Headers are injected:
X-Gateway-Target: <lan_host>:<lan_port>,X-Gateway-Target-Domain: <domain>. backend_httpsis deliberately ignored, because HTTP is always spoken to the gateway port. If the LAN target wants HTTPS, the gateway handles that itself (via thebackend_httpsflag in the gateway config, which then speaks TLS on the LAN side withinsecure_skip_verify).- On status transition,
patchGatewayRouteHandlerspatches the live Caddy: offline →static_response502 with maintenance HTML, online →revert.
L4 gateway routes
L4 gateway routes do NOT run through Caddy's HTTP server, but through apps.layer4. The upstream is gateway-peer-ip:l4_listen_port — the gateway container has its own TCP listener on this port (TcpProxyManager) that forwards to the LAN host. The server Caddy only passes the TCP connection through. See routing.md for details.
Config hash
Central instrument for "are server and gateway in sync?". computeConfigHash(peerId) calls getGatewayConfig(peerId) and delegates to the shared library, which builds a canonical form from the JSON (sorted keys, no numeric type drift) and hashes it with SHA-256. Server and gateway use the same library — on mismatch you know one of the two is out of sync.
In the admin UI, the hash is shown as a short abbreviation (first 8 characters) next to the gateway panel.
Licensing
gateway_peers— limit on the number of gateway peers. Enforced increateGateway().gateway_http_targets— limit on HTTP routes per gateway (enforced in route create).gateway_tcp_routing— feature flag for L4 routes on gateways.gateway_wol— feature flag for WoL trigger via gateway.
See licensing.md.
See also
- routing.md — how Caddy renders gateway upstreams.
- internal-dns.md — hostname reports via heartbeat.
- rdp-routes.md — RDP gateway mode uses the push channel for WoL and creates L4 gateway routes.
- licensing.md —
gateway_*feature flags. - ../API.md —
/api/v1/gateway/*companion endpoints and/api/v1/gatewaysadmin view.
Source files
src/services/gateways.js—createGateway,handleHeartbeat,_isHeartbeatHealthy,notifyWol,notifyConfigChanged,computeConfigHash,buildEnvContent,rotateGatewayTokens.src/services/gatewayHealth.js—StateMachineclass with sliding-window hysteresis.src/routes/api/gateway.js— Companion endpoints (/heartbeat,/config,/config/check,/status,/probe).src/routes/api/gateways.js— Admin view/api/v1/gatewayswith status + routes per gateway.src/middleware/gatewayAuth.js— Bearer token auth for companion endpoints.src/db/migrations.js— Migration 36 (add_gateway_support), 37 (gateway_meta_last_health)./root/gatecontrol-gateway/— Companion project (separate repo).