DEV Community

ARNAB PACHAL
ARNAB PACHAL

Posted on

Building a Zero-Trust Reverse Tunnel in Go (and Bypassing Cloudflare Port Blocks)

As a 3rd-year CS student at NIT Durgapur focusing heavily on backend architecture, I spend a lot of time working with local distributed systems. But every time I needed to test webhooks or share a local environment, I hit the same wall: connection limits and paywalls on reverse proxies like Ngrok.

I didn't want to pay a subscription just to expose localhost, and standard SSH tunnels felt too rigid for multi-tenant setups.

So, I decided to build my own.

The result is FolderExposer: an open-source, zero-trust tunneling gateway built entirely in Go. It uses HTTP Hijacking, custom TCP socket streaming, and ephemeral TLS-in-TLS routing. But building it wasn't just a matter of piping standard I/O—I ran into a massive architectural hurdle when dealing with Cloudflare's strict port firewalls and SSL termination.

Here is the engineering "war story" of how I built the data bridge and bypassed the dreaded SSL_ERROR_RX_RECORD_TOO_LONG error.

The Architecture: Control Channel vs. Data Channel
To build a reliable tunnel, the system needs to maintain a persistent connection between the user's laptop (the Client) and the VPS gateway (the Server) without blocking incoming public traffic.

I separated the network flow into three distinct layers:

The Control Channel (Port 9000): A lightweight, persistent TCP connection. The client dials in and says, "I'm online, here is my subdomain." The server registers this and holds the line.

The Public Gateway (Port 80/443): This is where external web traffic hits the VPS. Cloudflare sits in front of this to handle DNS and DDoS protection.

The Data Channel (Port 9001): When a public request hits the gateway, the server pings the client via the Control Channel. The client then rapidly opens a new, dedicated socket to stream the actual file data.

The Collision: SSL_ERROR_RX_RECORD_TOO_LONG
The architecture looked great on paper, but the moment I put Cloudflare in front of the VPS, the whole system crashed. Browsers were throwing the infamous SSL_ERROR_RX_RECORD_TOO_LONG error.

Why did this happen?
By default, I was running my Go public gateway on standard HTTP. But Cloudflare's "Full" encryption mode forces an HTTPS handshake on the origin server. When Cloudflare sent encrypted TLS packets to my Go relay, my server tried to read them as plain-text HTTP requests. The protocol collision caused the parser to panic and drop the connection.

The Solution: Segmented Encryption and TLS-in-TLS
I needed a clean https:// public URL, but I couldn't rely on Cloudflare proxying my raw TCP data channel (Port 9001), because Cloudflare blocks non-standard web ports on their free tier.

I had to decouple the web encryption from the tunnel encryption:

  1. The Web Layer (Flexible SSL)
    I reconfigured Cloudflare to Flexible mode. This allows Cloudflare to serve the secure https:// padlock to the browser, decrypt the request at the edge, and forward it to my VPS on Port 80 as standard HTTP. My Go relay could now parse the headers cleanly.

  2. The Tunnel Layer (Ephemeral TLS)
    But I couldn't stream raw, unencrypted user data from the VPS back to the client's laptop over the open internet. Since Cloudflare wasn't proxying Port 9001, I built my own ephemeral TLS wrapper.

When the server signals a NEW_REQUEST, the Go client initiates a secure tls.Dial directly to the raw VPS IP, bypassing Cloudflare entirely for the payload transport.

Go
// The Data Channel Bridge
tlsConfig := &tls.Config{InsecureSkipVerify: true}

// Robust retry logic for the data channel to ensure the VPS has time to bind
var vpsDataConn *tls.Conn
var err error
for i := 0; i < 3; i++ {
vpsDataConn, err = tls.Dial("tcp", vpsIP+":9001", tlsConfig)
if err == nil { break }
time.Sleep(500 * time.Millisecond)
}
Ensuring Clean Teardowns with sync.WaitGroup
Network sockets are fragile. If the user closed their browser, or if the local file server finished sending data, the sockets would often hang, causing memory leaks on the VPS.

To handle the bidirectional streaming safely, I implemented a sync.WaitGroup. This ensures that both sides of the io.Copy stream stay open exactly as long as they need to, and automatically garbage-collect the sockets the moment either side terminates the connection.

Go
go func() {
var wg sync.WaitGroup
wg.Add(2)

// Stream VPS -> Client
go func() { 
    defer wg.Done()
    io.Copy(vpsDataConn, localApp) 
}()

// Stream Client -> VPS
go func() { 
    defer wg.Done()
    io.Copy(localApp, vpsDataConn) 
}()

wg.Wait() // Wait for both streams to conclude

vpsDataConn.Close()
localApp.Close()
fmt.Println("[DEBUG] Bridge torn down cleanly.")
Enter fullscreen mode Exit fullscreen mode

}()
Conclusion
Building FolderExposer was a masterclass in protocol layers and socket management. By separating the Control and Data channels, and implementing custom TLS bridges, the system can reliably tunnel traffic through strict edge networks.

It is completely open-source. If you are interested in low-level networking in Go, or just want a free alternative to Ngrok to expose your local projects, you can grab the code (or install the CLI) here: https://github.com/Arnab-Pachal1234/FolderExposer

Top comments (0)