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.
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.
#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:
- 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). - Add that same
host:porttoLAN_TARGETSin the sketch. - 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:
| Field | Default | Purpose |
|---|---|---|
localConnectTimeoutMs | 4000 | TCP dial timeout to the LAN host |
localReadChunk | 256 | Max LAN→relay bytes per channel per loop() |
defaultTarget | nullptr | Target for an OPEN with no explicit target |
forwardAllowAny | false | Bypass the allowlist (see warning above) |
Compile-time (define before including TollanAgent.h):
| Macro | Default | Purpose |
|---|---|---|
TOLLAN_MAX_FWD_CHANNELS | 6 | Max concurrent forwarded connections. Each uses one lwIP socket — keep it under the core's socket cap (~10–16) |
TOLLAN_FWD_BUF | 2048 | Per-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.