tollan docs

ESP32 — forwarding (LAN proxy)#

Where handler mode makes the board itself the service, forwarding mode turns the ESP32 into a device-side proxy for other hosts on its network. When the relay opens a channel for an allowlisted target, the board dials it over plain TCP and pipes bytes both ways — exactly like the Go agent.

That turns a cheap board into a tunnel gateway for a printer, a NAS admin page, a PLC, an IP camera — anything on the same LAN — with no inbound ports and no router port-forwarding.

text
  Internet ──▶ relay ──(mTLS WSS)──▶ ESP32 ──(plain TCP)──▶ 192.168.1.50:80  (NAS)
                                     forwards                192.168.1.60:9100 (printer)

Enable it#

Set cfg.forward = true and give the board an allowlist of host:port targets. No onData/onClose handlers are needed — in forwarding mode the library owns the channels.

cpp
#include <WiFi.h>
#include <TollanAgent.h>
using namespace tollan;

// LAN hosts this board may proxy to — keep in lockstep with the device's
// route internal-targets in the console. Anything not listed is refused.
static const char* const LAN_TARGETS[] = {
    "192.168.1.50:80",    // e.g. a NAS / router web UI
    "192.168.1.60:9100",  // e.g. a network printer
};

TollanAgent agent;

void setup() {
  Serial.begin(115200);
  WiFi.mode(WIFI_STA);
  WiFi.begin("your-wifi", "your-wifi-password");
  while (WiFi.status() != WL_CONNECTED) delay(300);

  TollanConfig cfg;
  cfg.host = TOLLAN_RELAY_HOST;
  cfg.port = TOLLAN_RELAY_PORT;
  cfg.caCert = CA_CERT;
  cfg.clientCert = DEVICE_CERT;
  cfg.clientKey = DEVICE_KEY;

  cfg.forward = true;
  cfg.defaultTarget = LAN_TARGETS[0];   // used if an OPEN carries no target
  cfg.forwardTargets = LAN_TARGETS;
  cfg.forwardTargetCount = sizeof(LAN_TARGETS) / sizeof(LAN_TARGETS[0]);

  agent.begin(cfg);   // full sketch: examples/LanProxy
}

void loop() {
  agent.loop();
  delay(2);
}

The allowlist is the security boundary#

The board only dials allowlisted targets. The effective allowlist is defaultTarget ∪ forwardTargets. If the relay names a target that isn't on it, the board refuses the channel and closes it.

This mirrors the Go agent: the relay can name a target, but only the operator authorizes it. A compromised or misconfigured relay can't pivot the board to an arbitrary host on your LAN — the board simply won't dial anything you didn't list.

Keep the sketch's list in lockstep with the device's route internal-targets in the console. The relay only ever opens channels for configured routes anyway, so the two lists should mirror each other.

Warning

cfg.forwardAllowAny = true dials any target the relay names, skipping the allowlist entirely. It's a convenience for a single-tenant setup where you fully trust your own relay — but it drops the allowlist protection. Leave it off for shared or untrusted relays. defaultTarget still applies to an OPEN with no target.

Configure a route to match#

For each LAN host you want to reach:

  1. In the console, add a route on this device with the internal target set to the LAN host:port (e.g. 192.168.1.50:80).
  2. Add that same host:port to LAN_TARGETS in the sketch.
  3. Give the route a public hostname or port and hit it — the relay opens a channel naming that target, and the board proxies it.

Tuning#

All on TollanConfig:

FieldDefaultPurpose
localConnectTimeoutMs4000TCP dial timeout to the LAN host
localReadChunk256Max LAN→relay bytes per channel per loop()
defaultTargetnullptrTarget for an OPEN with no explicit target
forwardAllowAnyfalseBypass the allowlist (see warning above)

Compile-time (define before including TollanAgent.h):

MacroDefaultPurpose
TOLLAN_MAX_FWD_CHANNELS6Max concurrent forwarded connections. Each uses one lwIP socket — keep it under the core's socket cap (~10–16)
TOLLAN_FWD_BUF2048Per-channel relay→local staging buffer (bytes). Must hold at least one inbound frame (maxFrameBytes)

How it stays non-blocking#

Each forwarded connection has a small staging buffer of relay→local bytes not yet accepted by the socket. The agent only writes what the socket's availableForWrite() allows and stages the rest, so a slow LAN host can never block the single loop() and starve the tunnel keepalive. A channel whose local socket can't keep up and whose stage would overflow is dropped, protecting the shared tunnel.

That's the design constraint of a microcontroller: one cooperative loop, bounded RAM, and no thread to hide a blocking socket behind.


Next: the full API reference, or TLS/clock troubleshooting.