CallMeTechie
DE Login
Home Products Blog About Contact

Gateway Routes on Layer 4

⚙️ Advanced · Updated 1 month ago

Since v1.42.0, L4 routes with target_kind=gateway work end-to-end. This lets you publish any TCP or UDP port of a LAN device behind the gateway container through the public server — regardless of the application-layer protocol.

Typical use cases:

  • RDP on port 3389 (Windows server, workstation)
  • SSH on port 22 (Linux server, switches)
  • Databases (MySQL 3306, PostgreSQL 5432, MSSQL 1433)
  • Mail servers (IMAPS 993, SMTP 587)
  • Proprietary protocols (MQTT 1883, Modbus 502, industrial controllers)
  • Game servers (Minecraft 25565, Valheim 2456, etc.)
  • Media servers (Plex 32400, Jellyfin 8096 via TCP instead of reverse proxy)
  • Anything that is not HTTP and still needs to reach the LAN

Data flow

Internet client
    │
    ├─ TCP connect → server public IP:3389
    ▼
Caddy Layer-4 plugin (VPS)
    │
    │ Layer-4 proxy (no HTTP parsing, just a byte stream)
    │ Upstream: gateway-tunnel-IP:3389
    ▼
WireGuard tunnel
    │
    ▼
Gateway container TcpProxyManager (home network, tunnel_ip:3389)
    │
    │ Forwarded to target_lan_host:target_lan_port
    ▼
LAN device (e.g. 192.168.2.100:3389 = Windows RDP)

The listen port (on which the server listens on the internet) and the port the gateway container opens on the tunnel IP are identical — 1:1 mapping. The LAN host and LAN port behind it can be chosen freely and may differ (e.g. listen 2222 → LAN 192.168.2.100:22 if you don't want to occupy the server's SSH port 22).

Prerequisites

  • Pro license: The feature flag gateway_tcp_routing is set to false in the community fallback. Without a Pro key, the API handler blocks route creation with 403 gateway_tcp_routing not licensed.
  • Listen port must not be on the blocklist. Default: 80, 443, 2019, 3000, 51820 (HTTP, HTTPS, Caddy admin API, Node app, WireGuard). Configurable via env GC_L4_BLOCKED_PORTS.
  • Listen port must not be used twice with the same TLS setting. Conflicts are detected in buildCaddyConfig() and thrown as errors (route creation rolls back).
  • The gateway container must be reachable — meaning WG tunnel is up and the handshake is active. No tunnel, no L4 forwarding.

Creating in the Admin UI

  1. Routes → New route (or edit an existing one)
  2. Switch Type to L4 (the HTTP/L4 toggle at the top of the create form)
  3. Set Target type to Home Gateway (LAN)
  4. Choose a Home Gateway (the gateway peer through which traffic is routed)
  5. Enter the LAN host (the IP of the target device on the home network, e.g. 192.168.2.100)
  6. Enter the LAN port (e.g. 3389 for RDP)
  7. Choose the L4 protocol: TCP or UDP
  8. Enter the L4 listen port. Usually identical to the LAN port (e.g. 3389). It can differ, however, if the server port is already in use or you deliberately want to expose a different port externally.
  9. L4 TLS mode:
    • None: Caddy forwards the stream without any TLS handling. Suitable for RDP, SSH, DB ports, anything that does not natively speak TLS OR handles its own TLS termination.
    • Passthrough: Caddy reads only the SNI from the Client Hello for routing and forwards the entire TLS handshake. Suitable when the LAN target does HTTPS and you want to keep the cert on the target device. Requires a domain.
    • Terminate: Caddy terminates TLS on the server and speaks plain TCP to the gateway. ACME cert via DNS/HTTP-01. Requires a domain.
  10. Domain (only for TLS modes ≠ None): FQDN used for SNI-based routing, e.g. rdp.example.com.
  11. Save

The gateway container receives the change via config sync instantly (push notification from server → /api/config-changed → gateway polls → TcpProxyManager.setRoutes() starts the new listener).

Port ranges

If you need a contiguous port range (e.g. FTP passive mode, or multiple consecutive game server ports), enter the range as the L4 listen port:

L4 listen port: 2000-2099
LAN port:       2000

Caddy then opens all 100 ports on the server and forwards each one 1:1 to the gateway. The gateway starts a single listener that forwards all incoming connections to the LAN target address.

Example game server with many worker ports:

L4 listen port: 27015-27020
LAN host:       192.168.2.50
LAN port:       27015
L4 protocol:    UDP

Wildcard "all ports of a device" is not possible

This is a deliberate design decision:

  • The server itself needs its own ports (TLS, admin UI, SSH, WireGuard). A "swallow everything" would make the server machine itself unreachable.
  • Port conflicts with other L4 routes (e.g. to a second gateway) would no longer be resolvable.
  • DDoS exposure would be enormous.

Instead: explicitly enumerate the ports you actually need. Each port = one route = equivalent to one firewall rule.

Multiple gateways, same port

If you have two gateways that both want to expose, for example, SSH (port 22), you must assign different listen ports:

gateway-a.domain:  listen port 2222 → Gateway-A tunnel-IP:22 → LAN-A:22
gateway-b.domain:  listen port 2223 → Gateway-B tunnel-IP:22 → LAN-B:22

For HTTP/S routes, this works via SNI (domain-based routing on 443). For L4 without a TLS mode, Caddy has no way to tell the streams apart — which is why exactly one target per server listen port is allowed.

Alternatively: L4 with TLS mode passthrough or terminate — then Caddy can distinguish between multiple services on the same listen port via SNI (e.g. rdp1.example.com and rdp2.example.com both on 3389, but the SNI distinguishes them).

Wake-on-LAN

L4 gateway routes support the WoL flag just like HTTP gateway routes. If the gateway receives ECONNREFUSED from the LAN target on a connect attempt and the WoL flag is set on the route, it sends a magic packet to the configured MAC. Combined with the client's automatic retry (TCP SYN retransmission), the connection usually succeeds on the second attempt — the target device has then woken up from standby.

Blocked ports

Server ports that cannot be used as an L4 listen port (because the server uses them itself or they are security-critical):

Port Purpose
22 SSH daemon of the VPS (almost always in use)
53 systemd-resolved / host-dnsmasq (shared with network_mode: host)
80 HTTP auto-HTTPS (Caddy)
443 HTTPS (Caddy)
2019 Caddy admin API (internal)
3000 Node app (internal admin API)
51820 WireGuard server endpoint

Overridable via env: GC_L4_BLOCKED_PORTS="22,53,80,443,2019,3000,51820".

Practical tip: If you have a LAN service on a blocked standard port (e.g. SSH on 22), pick a different listen port externally (e.g. 2222) and keep the LAN port the same:

Listen port:  2222   (external, freely chosen)
LAN port:     22     (SSH on the target device)

The client then calls ssh -p 2222 user@example.com. Softening the defaults is not recommended — it would cause port conflicts with your own infrastructure.

TLS modes in detail

none (TCP passthrough)

Simplest case. Caddy is a pure byte forwarder. No TLS handling. Domain field optional (no SNI routing). Only one target per listen port allowed (no ambiguity to resolve).

Client ──TCP──▶ Server:3389 ──TCP──▶ Gateway:3389 ──TCP──▶ LAN:3389

passthrough (SNI sniffing without termination)

Caddy reads the Client Hello to extract the SNI and routes based on it. The full TLS handshake + encrypted traffic goes to the LAN target. The TLS cert lives on the LAN device.

Multiple targets on the same listen port are possible if they use different domains.

Client ──TLS+SNI──▶ Caddy (reads SNI, byte-forwards the rest) ──▶ LAN

terminate (TLS termination on the server)

Caddy terminates TLS and forwards the decrypted stream to gateway/LAN. The ACME cert is issued on the server via DNS-/HTTP-01 challenge.

Client ──TLS──▶ Caddy (decrypt) ──TCP──▶ Gateway ──TCP──▶ LAN

Suitable when the LAN target cannot speak HTTPS or you want to keep cert management centralized.

Compat / hashing

L4 routes have been part of the config hash since the first gateway version. The bugfix in v1.42.0 only changes the Caddy config on the server — the gateway itself receives the same L4 route payload as before. No hash bump, no breaking changes, no migration required.

Relevant files

Server

  • src/services/l4.js — builds Caddy Layer-4 config (buildL4Servers, buildL4Route, validatePortConflicts)
  • src/services/caddyConfig.js — pre-processing: for gateway L4 routes sets target_ip = <gateway-tunnel-ip> and target_port = <l4_listen_port> before buildL4Servers runs
  • src/services/gateways.jsgetGatewayConfig() delivers l4_routes[] to the gateway
  • src/routes/api/routes.js — license gate gateway_tcp_routing in the POST + PUT handler

Gateway

  • src/proxy/tcp.jsTcpProxyManager starts listeners on tunnel_ip:listen_port and forwards to target_lan_host: target_lan_port, dual-bind overlap on port changes
  • src/bootstrap.jstcpMgr.setRoutes(cfg.l4_routes) in the config-change event handler

Tests

  • tests/caddyConfig_gateway.test.js pins the new behavior: "L4 gateway route forwards to gateway-peer-ip:listen_port (not the 127.0.0.1 placeholder)"
  • tests/gateways_getConfig.test.js covers the L4 sync payload
  • Gateway: tests/proxy_tcp.test.js tests the TcpProxyManager

Troubleshooting

Route shows gateway_tcp_routing not licensed on save

You have no Pro license or the community fallback is active. The feature is Pro-only.

Client gets connection refused

  • Gateway offline? Check docker ps and sudo docker logs gatecontrol-gateway
  • WG tunnel down? On the server wg show wg0 | grep handshake — the line for the gateway peer should be <2 minutes old
  • LAN target offline? The gateway does not check this in advance — it only attempts the TCP connection on client connect. Enable WoL on the route if this is about waking up the target.

"Port conflict" on save

Another L4 route is already using the same listen port. The server lists both IDs in the error. Either: pick a different listen port, or — if both routes use TLS — make sure they have different domains via SNI.

Connection works from outside but not from within the VPN

The client is already in the same WG subnet as the gateway — use the LAN path directly to the target device instead of the detour through the public Caddy. Split-horizon DNS + direct WG peering exist for exactly this.

"tls: first record does not look like a TLS handshake"

You set TLS mode to terminate, but the LAN target wants to terminate TLS itself (i.e. it only speaks HTTPS). Switch to passthrough or none.

Cookie Settings

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

Privacy Policy
ESC
↑↓ navigate open esc close