CallMeTechie
DE Login
Home Products Blog About Contact

Home Gateway

v1.0 · Updated 1 month ago

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

  1. Admin calls POST /api/v1/peers with is_gateway: true.
  2. Server checks the gateway_peers license limit, generates a WG keypair, writes a peers row with peer_type='gateway'.
  3. createGateway() generates an API token and push token (32 random bytes each), stores the hash or encrypted form respectively.
  4. Server builds an .env file (template in buildEnvContent()) 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 in buildEnvContent).
  5. Admin downloads the .env, places it in the gatecontrol-gateway compose stack, and runs docker compose up -d.
  6. Gateway container establishes the WG tunnel, sends the first heartbeat. The state machine transitions to online after 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_hash and push_token_encrypted are 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:

  1. Update gateway_meta.last_seen_at and last_health.
  2. _getSm(peerId).recordHeartbeat(healthy) — state machine ticks.
  3. On status transition: _onStatusTransition → Caddy partial patch, activity log, email alert, webhook.
  4. On flap (>4 transitions/h): log gateway_flap_warning.
  5. If internal_dns is active: the hostname field is reported as an agent report to peers.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:

  1. route_reachability — if present and not empty: all entries must be reachable:true.
  2. Self-check fallback (http_proxy_healthy + tcp_listeners) — if no reachability probe ran: http_proxy_healthy must be true and no listener_failed.
  3. 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.jsStateMachine 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 the unknown warmup transition).
  • State machines are kept in _smCache per peerId (memory, not persisted). After a server restart the state goes to unknown until the first heartbeats arrive.

Config pull

The gateway pulls its config rather than receiving it pushed:

  1. GET /api/v1/gateway/config/check?hash=sha256:… — gateway sends the hash of its current config.
  2. Server calls computeConfigHash(peerId). On match: 304 Not Modified. Otherwise: 200 + new hash.
  3. On mismatch, the gateway pulls GET /api/v1/gateway/config and 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 to gatewayPeerIp:8080 (gatewayProxyPort, configurable).
  • Headers are injected: X-Gateway-Target: <lan_host>:<lan_port>, X-Gateway-Target-Domain: <domain>.
  • backend_https is deliberately ignored, because HTTP is always spoken to the gateway port. If the LAN target wants HTTPS, the gateway handles that itself (via the backend_https flag in the gateway config, which then speaks TLS on the LAN side with insecure_skip_verify).
  • On status transition, patchGatewayRouteHandlers patches the live Caddy: offline → static_response 502 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 in createGateway().
  • 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.mdgateway_* feature flags.
  • ../API.md/api/v1/gateway/* companion endpoints and /api/v1/gateways admin view.

Source files

  • src/services/gateways.jscreateGateway, handleHeartbeat, _isHeartbeatHealthy, notifyWol, notifyConfigChanged, computeConfigHash, buildEnvContent, rotateGatewayTokens.
  • src/services/gatewayHealth.jsStateMachine class 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/gateways with 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).

Cookie Settings

We use cookies to improve your experience. Essential cookies are always active.

Privacy Policy
ESC
↑↓ navigate open esc close