Routes & HTTPS
Overview
GateControl doesn't speak HTTP to the outside itself — public termination is handled by Caddy. The Node process builds a Caddy JSON configuration from its database and reloads it via the Caddy admin API. There are two route types (http, l4) and two target kinds (peer, gateway). These two dimensions yield four combinations, which are rendered differently in the config builder.
The central insight, without which much is hard to understand: all HTTP routes land in a single Caddy server srv0 with listen: [':443', ':80']. The per-route listen field is discarded during assembly. Caddy's Automatic HTTPS provisions certificates for every domain in srv0, regardless of the https_enabled flag of the route. This flag only serves as a hint for redirects.
The routing core lives in src/services/caddyConfig.js (~900 lines) and src/services/l4.js. The CRUD part of the routes table sits in src/services/routes.js and delegates the Caddy side entirely to caddyConfig.syncToCaddy.
Architecture
Client ──HTTPS──▶ Caddy (srv0, :443/:80)
│
├──▶ reverse_proxy → Peer-IP:Port (target_kind=peer)
│
├──▶ reverse_proxy → Gateway-IP:8080 (target_kind=gateway)
│ mit Headers X-Gateway-Target=…
│ │
│ ▼
│ Gateway-HTTP-Proxy → LAN-Host:Port
│
└──▶ apps.layer4 (eigene L4-Server pro Port)
│
├──▶ proxy → Peer-IP:Port
└──▶ proxy → Gateway-IP:ListenPort
│
▼
Gateway-TcpProxy → LAN-Host:Port
Route types and target kinds
route_type |
target_kind |
Upstream in Caddy | Backend |
|---|---|---|---|
http |
peer |
peer.allowed_ips:route.target_port |
Peer WG IP, direct TCP |
http |
gateway |
gateway.allowed_ips:8080 |
Gateway container over WG tunnel, HTTP proxy |
l4 |
peer |
peer.allowed_ips:route.target_port |
Peer WG IP, L4 proxy |
l4 |
gateway |
gateway.allowed_ips:l4_listen_port |
Gateway container, own L4 listener |
The target_kind column in routes controls the upstream build; target_peer_id points to the gateway peer for gateway routes, and target_lan_host + target_lan_port are passed as instructions for the gateway in the header.
Data model (excerpt)
routes (
id, domain, route_type, -- 'http' | 'l4'
enabled, https_enabled,
-- Peer-Routen
peer_id, -- FK peers.id, Upstream-Peer
target_ip, target_port,
-- Gateway-Routen
target_kind, -- 'peer' | 'gateway'
target_peer_id, -- FK peers.id, Gateway-Peer
target_lan_host, target_lan_port, -- LAN-Adresse HINTER dem Gateway
-- HTTP-Features
basic_auth_enabled, basic_auth_user, basic_auth_password_hash,
acl_enabled, ip_filter_enabled,
rate_limit_enabled, rate_limit_requests, rate_limit_window,
compress_enabled, retry_enabled, retry_count,
circuit_breaker_enabled, circuit_breaker_status,
bot_blocker_enabled, bot_blocker_mode, bot_blocker_config,
backend_https, -- nur peer-target
custom_headers, mirror_enabled, mirror_targets,
sticky_enabled, backends, -- Load-Balancing
-- L4-Felder
l4_protocol, l4_listen_port, l4_tls_mode,
-- WoL (hauptsächlich Gateway-Kontext)
wol_enabled, wol_mac
)
HTTP routing flow
buildCaddyConfig() (in caddyConfig.js) iterates over all enabled routes and builds a Caddy route object per domain. Steps per HTTP route:
-
Determine upstream
target_kind='gateway'→ upstream isgatewayPeerIp:8080.- Multiple
backends→ array for load balancing. - Otherwise peer IP or
target_ip+target_port.
-
Gateway offline fallback — when the gateway is offline (flag
gateway_offline), astatic_responsewith 502 + maintenance HTML is rendered instead ofreverse_proxy(templategateway-offline.njk). SeepatchGatewayRouteHandlersfor runtime patches via admin API. -
Gateway headers — for
target_kind='gateway', these are set in thereverse_proxyheaders:X-Gateway-Target: target_lan_host:target_lan_portX-Gateway-Target-Domain: route.domain
The gateway container interprets these headers and forwards to the LAN address. See home-gateway.md.
-
Backend HTTPS —
backend_https=trueenablesinsecure_skip_verifyas transport. Only fortarget_kind='peer', never for gateway routes: HTTP always goes to the gateway port 8080, TLS would trigger a 502. -
Build the handler chain (in order):
bot_blocker (defender) → trace (debug) → custom_headers (request) → rate_limit → mirror → compress → reverse_proxy -
Circuit breaker — if
status='open', the entire chain is replaced by astatic_response503 (withRetry-Afterheader). -
Peer ACL —
match.remote_ip.rangesfrom theroute_peer_acltable. Only peers in the allow list may pass. -
Route auth / forward-auth — see below.
-
Mount route config under
caddyRoutes[route.domain].
Route auth (forward-auth)
Route auth inserts a second route BEFORE the main chain:
caddyRoutes[domain] = {
routes: [
routeAuthProxy, // match /route-auth/* → Node-Process (Login-UI + /verify)
routeConfig // match anything else → forward_auth-Subrequest → upstream
]
}
The forwardAuthSubrequest calls 127.0.0.1:3000/route-auth/verify with the request metadata as headers. On 2xx, the request is continued; on 4xx/5xx, Caddy returns a 302 to /route-auth/login?route=…&redirect=…. After login, the Node server sets a session cookie, which the next subrequest accepts again.
Only /route-auth/* is intercepted — earlier versions also claimed /css, /js, /fonts for the login asset bundle; that collided with upstream panels (Synology, Speedport, TR-064). See the comment in caddyConfig.js starting around line 420.
Load balancing
backends is a JSON array with {peer_id, port, weight}. At build time, peer IPs are resolved and disabled peers are filtered out.
| Mode | Policy |
|---|---|
sticky_enabled=true |
cookie + configurable cookie name + TTL |
| All weights equal | round_robin |
| Different weights | weighted_round_robin |
retry_enabled additionally increases load_balancing.retries = retry_count.
Server assembly
After the per-route build, all domains are merged into a single server srv0:
caddyConfig.apps.http.servers.srv0 = {
listen: [':443', ':80'],
routes: serverRoutes, // [{match: {host: [domain]}, handle: [...]}]
protocols: ['h1', 'h2'],
}
Individual routes are mounted via {match: {host: [domain]}, handle: [...], terminal: true}. Compound routes (route auth) are wrapped in a subroute handler so that the internal route list is preserved.
The management UI route (from GC_BASE_URL) is also inserted automatically, with 127.0.0.1:{config.app.port} as upstream — so the Node process serves its own admin interface through Caddy.
Consequence for TLS
Since srv0 listens on :443 and all host matchers are contained within it, Caddy's Automatic HTTPS provisions a certificate for every domain, regardless of the https_enabled flag of the individual route. In the client UI the flag now only serves as a hint as to whether the admin expects a TLS endpoint — it controls no listener.
L4 routing
L4 routes live under apps.layer4.servers.<name> (Caddy L4 plugin). buildL4Servers in src/services/l4.js groups routes by (protocol, listen_port, tls_mode) and creates a server per group:
servers['l4-tls-8443'] = {
listen: ['tcp/:8443'],
routes: [
{ match: [{tls: {sni: ['app.example.com']}}], handle: [{handler: 'tls'}, proxyHandler] },
...
]
}
TLS modes
l4_tls_mode |
Behavior |
|---|---|
none |
Pass raw TCP/UDP through, no match, no TLS |
passthrough |
Match via SNI, but Caddy does NOT terminate — certificate lives at the backend |
terminate |
Match via SNI, Caddy terminates TLS (certificate is provisioned), backend sees plaintext |
Port conflicts
validatePortConflicts() checks:
- Reserved ports (via
isPortBlocked) — e.g. 22, 53, 443, 51820. - Duplicate TLS-none routes on the same port.
- Overlapping port ranges (
8000-8100vs8050-8150).
Conflicts throw and abort the config push.
Gateway L4
For target_kind='gateway' + route_type='l4', the upstream is set to gateway-peer-ip:l4_listen_port. The gateway container itself listens on this port (via its TcpProxyManager) and forwards to the LAN host. Caddy on the server only passes the TCP connection through to the gateway.
Sync flow and self-healing
syncToCaddy() in caddyConfig.js:
- Previous config fetched via
GET /config/(for rollback). - Build new config with
buildCaddyConfig(). - Write runtime.json to
/data/caddy/runtime.json(atomic rename). This file is the source of truth at Caddy boot viaentrypoint.sh. - Live reload via
POST /load. - Verify via
GET /config/— the config must have arrived. - TLS canary —
_verifyLocalTls(managementHost)opens a TLS connection to127.0.0.1:443with the management UI domain as SNI. If that fails even though/loadwas ok, Caddy's listener state is corrupt. Then: - Caddy restart via
pkill -TERM -x caddy. Supervisord starts Caddy again fromruntime.json. - On verify failure without TLS issue: rollback to
previousConfig.
The TLS canary addresses a concrete bug: /load can in certain situations (listener rebind, new cert) respond successfully while the server still aborts every TLS handshake with internal error. A supervisord restart reliably resolves the state.
Partial patches for gateway state
patchGatewayRouteHandlers({peerId, offline, ...}) is called by the gatewayHealth state machine when a gateway transitions from online to offline (or back). Every route gets an @id marker (gc_route_<id>) during assembly, so that PATCH /id/gc_route_<id>/handle can swap out the handler in the live config — without a full /load round trip.
offline=true→ handler tostatic_response502 + maintenance HTML.offline=false→/revert(handler back to original).
See also
- home-gateway.md — gateway architecture, config hash, heartbeat.
- licensing.md — which routing features lie behind which flags.
- rdp-routes.md — how RDP gateway mode auto-creates an L4 route.
- ../HTTP-VS-L4-ROUTING.md — user-facing decision guide HTTP vs L4.
- ../API.md —
/api/v1/routesendpoints and payload schema.
Source files
src/services/caddyConfig.js— Config builder, sync, self-heal, partial patches.src/services/l4.js—buildL4Servers,buildL4Route,validatePortConflicts.src/services/routes.js— CRUD, delegates sync tocaddyConfig.syncToCaddy.src/services/routeAuth.js— Route-auth lookup helper for the forward-auth handler.templates/gateway-offline.njk— Maintenance page for offline gateways.entrypoint.sh— Caddy boot from/data/caddy/runtime.json.