• There is NO official Otland's Discord server and NO official Otland's server list. The Otland's Staff does not manage any Discord server or server list. Moderators or administrator of any Discord server or server lists have NO connection to the Otland's Staff. Do not get scammed!
  • 2026 staff recruitment is open! Check it out and consider applying!

Cloudflare WebSocket Tunnel - Free for the Open Tibia community

Aizendazai

The High Chancellor
Premium User
Joined
Aug 31, 2022
Messages
56
Reaction score
139
Location
UK
I recently got my hands on this Cloudflare WebSocket tunnel, and after testing it, I realized how useful it can be for protecting servers.
This system cost around $400 for the compiled binary (not the source code), but instead of keeping it private, I decided to release it so more people can benefit from it, especially with the increasing DDoS attacks and threats many server owners are facing.

This tool helps route game traffic through WebSocket over Cloudflare, hiding your real server IP, forwarding the real client IP using PROXYv2, and adding an extra layer of protection against DDoS. Special thanks to @Nekiro for the original idea behind this system. This isn’t about money, it is about helping the community run their servers more safely and reliably. I hope this contributes to reducing DDoS issues and makes things better for everyone hosting their servers.

Feel free to try it out and share your feedback.

Tunnel Source Code:
Code:
package main

import (
    "encoding/binary"
    "encoding/hex"
    "fmt"
    "io"
    "log"
    "net"
    "net/http"
    "os"
    "os/signal"
    "strings"
    "sync"
    "syscall"
    "time"

    "github.com/gorilla/websocket"
)

// Configuration - separate ports for game and login

const (
    GameWsPort  = ":7575" // WebSocket port for game server
    LoginWsPort = ":9950" // WebSocket Port for login server
    LoginTcpHost = "168.212.119.171:7171" // TFS login server
    GameTcpHost = "168.212.119.171:7172" // TFS login server
    EnableLogging = false
    BufferSize = 327680
)

var upgrader = websocket.Upgrader{
    ReadBufferSize: BufferSize,
    WriteBufferSize: BufferSize,
    CheckOrigin: func(r *http.Request) bool {
        return true
    },
}

// packetBufPool recycles packet body buffers across connections.
// At 5k connections each buffer is up to 65535 bytes; pooling avoids
// constant GC pressure from per-packet allocations in copyTCPtoWS

var packetBufPool = sync.Pool{
    New: func() any {
        buf := make([]byte, 65535)
        return &buf
    },
}

// wsReader wraps a WebSocket connection and implements io.Reader.
//
// Uses NextReader instead of ReadMessage to avoid per-packet allocations:
// NextReader returns an io.Reader over the raw frame data, so io.Copy
// pulls bytes directly from the WebSocket frame into the TCP write buffer
// with zero extra allocation.
//
// gorilla allows exactly one concurrent reader - safe here as this is
// only used from the WS -> TCP goroutine.

type wsReader struct {
    conn *websocket.Conn
    reader io.Reader // current frame reader, nil when between frames
}

func (r *wsReader) Read(p []byte) (int, error) {
    for {
        // If we have an active frame reader, drain it first
        if r.reader != nil {
            n, err := r.reader.Read(p)
            if err == io.EOF {
                // Frame fully consumed, clear and loop to get next frame
                r.reader = nil
                if n > 0 {
                    return n, nil
                    }
                    continue
                }
                return n, err
            }

            // No active frame - get the next one
            messageType, reader, err := r.conn.NextReader()
            if err != nil {
                return 0, err
            }
            if messageType != websocket.BinaryMessage {
             // Skip non-binary frames; ping/pong handled internally by gorilla
             continue
        }
        r.reader = reader
    }
}

// copyTCPtoWS reads complete Tibia packets from TCP and forwards each as a
// single WebSocket binary frame. This prevents io.Copy's internal 32KB buffer
// from splitting a packet across multiple frames, which would corrupt the
// client's parser if it expects one frame = one complete message.
//
// Tibia packet layout: [len_lo][len_hi][...payload of len bytes...]
// The 2-byte header is included in the forwarded frame so the client receives
// exactly what TFS sent, with the same framing it already expects.
//
// Body buffers are recycled via packetBufPool - at 5k connections this keeps
// allocations off the hot path and reduces GC pressure significantly.

func copyTCPtoWS(wsConn *websocket.Conn, tcpConn net.Conn) error {
    var headerBuf [2]byte

    for {
        // Read the 2-byte little-endian Tibia packet length
        if _, err := io.ReadFull(tcpConn, headerBuf[:]); err != nil {
            return err
        }

        packetLen := binary.LittleEndian.Uint16(headerBuf[:])
        if packetLen == 0 {
            continue
        }

        // Borrow a buffer from the pool; grow only when the packet is larger
        // than the pooled slice (rare - Tibia packets are almost always <8KB)
        bufPtr := packetBufPool.Get().(*[]byte)
        if int(packetLen) > len(*bufPtr) {
            *bufPtr = make([]byte, packetLen)
        }

        body := (*bufPtr)[:packetLen]

        // Read exactly packetLen bytes - one complete Tibia packet body
        if _, err := io.ReadFull(tcpConn, body); err != nil {
            packetBufPool.Put(bufPtr)
            return err
        }
        // Build the complete frame: [len_lo, len_hi, ...body...]
        // Allocating a fresh frame slice here is intentional: WriteMessage holds
        // a reference to the slice until the write completes, so we cannot reuse
        // the pool buffer without a copy anyway.
        frame := make([]byte, 2+int(packetLen))
        copy(frame[0:2], headerBuf[:])
        copy(frame[2:], body)

        packetBufPool.Put(bufPtr)

        // One complete Tibia packet = one WebSocket frame, always
        if err := wsConn.WriteMessage(websocket.BinaryMessage, frame); err != nil {
            return err
        }
    }
}

// Bridge represents a connection bridge between WebSocket client and TCP server

type Bridge struct {
    wsConn *websocket.Conn
    tcpConn net.Conn
    clientIP string
    tcpHost string
    closeChan chan struct{}
    closeOnce sync.Once
}

// NewBridge creates a new bridge instance
func NewBridge(ws *websocket.Conn, clientIP string, tcpHost string) *Bridge {
    return &Bridge {
        wsConn: ws,
        clientIP: clientIP,
        tcpHost: tcpHost,
        closeChan: make(chan struct{}),
    }
}

// Start initializes the bridge

func (b *Bridge) Start() {
    logMsg("Bridge", fmt.Sprintf("Connecting to %s for client: %s", b.tcpHost, b.clientIP))

    tcpConn, err := net.DialTimeout("tcp", b.tcpHost, 5*time.Second)
    if err != nil {
        logMsg("Error", fmt.Sprintf("Failed to connect to TFS (%s): %v", b.tcpHost, err))
        b.Close()
        return
    }

    // Disable Nagle - critical for real-time game traffic
    if tcp, ok := tcpConn.(*net.TCPConn); ok {
        tcp.SetNoDelay(true)
        tcp.SetKeepAlive(true)
        tcp.SetKeepAlivePeriod(60 * time.Second)
    }

    b.tcpConn = tcpConn
    logMsg("Bridge", fmt.Sprintf("TCP connected to %s for client: %s", b.tcpHost, b.clientIP))

    // Send PROXYv2 header for real IP forwarding to TFS
    if proxyHeader := b.buildPROXYv2Header(); len(proxyHeader) > 0 {
        if _, err := b.tcpConn.Write(proxyHeader); err != nil {
            logMsg("Error", fmt.Sprintf("Failed to send PROXYv2 header: %v", err))
            b.Close()
            return
        }
        logMsg("Proxy", fmt.Sprintf("Sent PROXYv2 header for client: %s", b.clientIP))
    }
    // WS → TCP
    // wsReader streams frame data directly via NextReader - zero allocations per packet.
    go func() {
        defer b.Close()
        _, err := io.Copy(b.tcpConn, &wsReader{conn: b.wsConn})
        if err != nil && !b.isClosed() {
            logMsg("WS→TCP Error", fmt.Sprintf("%s: %v", b.clientIP, err))
        }
    } ()

    // TCP → WS
    // copyTCPtoWS reads complete Tibia packet and sends each as one WS frame,
    // preventing io.Copy's 32KB chunking from splitting packets mid-message.
    go func() {
        defer b.Close()
        err := copyTCPtoWS(b.wsConn, b.tcpConn)
        if err != nil && !b.isClosed() {
            logMsg("TCP→WS Error", fmt.Sprintf("%s: %v", b.clientIP, err))
        }
    }()
}

// isClosed reports whether the bridge has been closed.

func (b *Bridge) isClosed() bool {
    select {
    case <-b.closeChan:
        return true
    default:
        return false
    }
}

// Close shuts down both sides of the bridge exactly once.
// Closing the connections unblocks any blocked io.Copy immediately.

func (b *Bridge) Close() {
    b.closeOnce.Do(func(){
        close(b.closeChan)

        if b.wsConn != nil {
            b.wsConn.Close()
        }
        if b.tcpConn != nil {
            b.tcpConn.Close()
        }

        logMsg("Bridge", fmt.Sprintf("Closed connection for client: %s", b.clientIP))
    })
}

// buildPROXYv2Header builds a HAProxy PROXYv2 binary protocol header
// so TFS receives the real client IP rather than 127.0.0.1.

func (b *Bridge) buildPROXYv2Header() []byte {
    if b.clientIP == "" {
        return nil
    }

    ip := net.ParseIP(b.clientIP)
    if ip == nil {
        logMsg("Warning", fmt.Sprintf("Invalid IP: %s", b.clientIP))
        return nil
    }

    ipv4 := ip.To4()
    if ipv4 == nil {
        if strings.Contains(b.clientIP, ":") {
            parts := strings.Split(b.clientIP, ":")
            if len (parts) > 0 {
                ip = net.ParseIP(parts[len(parts)-1])
                if ip != nil {
                    ipv4 = ip.To4()
                }
            }
        }
        if ipv4 == nil {
            logMsg("Warning", fmt.Sprintf("Cannot convert to IPv4: %s", b.clientIP))
            return nil
        }
    }

    header := make([]byte, 28)
    copy(header[0:12], []byte{0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A})
    header[12] = 0x21 // Version 2, PROXY command
    header[13] = 0x11 // AF_INET, STREAM
    binary.BigEndian.PutUint16(header[14:16], 12) // Address block length
    copy(header[16:20], ipv4) // Source IP (real client)
    copy(header[20:24], []byte{0, 0, 0, 0}) // Destination IP (placeholder)
    binary.BigEndian.PutUint16(header[24:26], 0) // Source port (placeholder)

    var dstPort uint16
    if strings.Contains(b.tcpHost, "7171") {
        dstPort = 7171
    } else {
        dstPort = 7172
    }
    binary.BigEndian.PutUint16(header[26:28], dstPort)

    if EnableLogging {
        logMsg("Proxy", fmt.Sprintf("PROXYv2 for %s → IP bytes: %s", b.clientIP, hex.EncodeToString(header[16:20])))
    }

    return header
}

// extractClientIP extracts the real client IP from proxy/CDN headers

func extractClientIP(r *http.Request) string {
    if cfIP := r.Header.Get("CF-Connecting-IP"); cfIP != "" {
        return cfIP
    }
    if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
        ips := strings.Split(xff, ",")
        if len(ips) > 0 {
            return strings.TrimSpace(ips[0])
        }
    }
    if xri := r.Header.Get("X-Real-IP"); xri != "" {
        return xri
    }

    clientIP := r.RemoteAddr
    if strings.Contains(clientIP, ":") {
        if host, _, err := net.SplitHostPort(clientIP); err == nil {
            clientIP = host
        }
    }
    clientIP = strings.TrimPrefix(clientIP, "::ffff:")

    if clientIP == "" || clientIP == "127.0.0.1" || clientIP == "::1" {
        logMsg("Warning", fmt.Sprintf("Using localhost IP: %s", clientIP))
    }

    return clientIP
}

// handleWebSocket upgrades the HTTP connection and starts a bridge.
// NOTE: http.Server ReadTimeout/WriteTimeout must not be set - they apply to
// the raw TCP connection and will kill long-lived WebSocket connections.

func handleWebSocket(tcpHost string) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        clientIP := extractClientIP(r)
        logMsg("Server", fmt.Sprintf("New connection from: %s → %s", clientIP, tcpHost))

        ws, err := upgrader.Upgrade(w, r, nil)
        if err != nil {
            logMsg("Error", fmt.Sprintf("WebSocket upgrade failed: %v", err))
            return
        }
        ws.EnableWriteCompression(false) // Never compress - adds latency, Tibia data is already packed
        ws.SetReadLimit(327680) // Match client SEND_BUFFER_SIZE to avoid false read-limit errors

        bridge := NewBridge(ws, clientIP, tcpHost)
        bridge.Start()
    }
}

func logMsg(tag, message string) {
    if EnableLogging {
        log.Printf("[%s] [%s] [%s]\n", time.Now().Format("2006-01-02 15:04:05.000"), tag, message)
    }
}

func main() {
    // IMPORTANT: No ReadTimeout or WriteTimeout on the http.Server.
    // These are applied to the raw net.Conn underneath, which kills WebSocket
    // connections after the timeout regardless of activity on the WS layer.
    gameServer := &http.Server{
        Addr: GameWsPort,
        Handler: handleWebSocket(GameTcpHost),
    }

    loginServer := &http.Server{
        Addr: LoginWsPort,
        Handler: handleWebSocket(LoginTcpHost),
    }

    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
    go func() {
        <-sigChan
        logMsg("Server", "Shutting down...")
        gameServer.Close()
        loginServer.Close()
        os.Exit(0)
    }()

    logMsg("Server", "OTClient WebSocket Bridge started")

    go func() {
        if err := loginServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatal("Login server error:", err)
        }
    }()

    if err := gameServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        log.Fatal("Game server error:", err)
    }
}

Here's a simple guide on how to compile and use it:

Open CMD inside the folder containing the tunnel files, then run:

Code:
winget install GoLang.Go
go mod init tunnel
go mod tidy
set GOOS=linux
set GOARCH=amd64
go build -o tunnel tunnel.go

This will generate the compiled binary for your server.

binary_gifs.gif

OTClient Changes:
Add the following files in: client/src/framework/net

- websocket_connection.cpp
- websocket_connection.h

Then declare them in:
otclient.vcxproj as follows:

below <ClInclude Include="..\src\framework\net\connection.h" />
add this <ClInclude Include="..\src\framework\net\websocket_connection.h" />

below
Code:
<ClCompile Include="..\src\framework\net\connection.cpp">
      <ExcludedFromBuild Condition="'$(Configuration)|$(Platform)'=='Debug_lib|Win32'">false</ExcludedFromBuild>
      <ExcludedFromBuild Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">true</ExcludedFromBuild>
 </ClCompile>

add this
Code:
<ClCompile Include="..\src\framework\net\websocket_connection.cpp">
      <ExcludedFromBuild Condition="'$(Configuration)|$(Platform)'=='Debug_lib|Win32'">false</ExcludedFromBuild>
      <ExcludedFromBuild Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">true</ExcludedFromBuild>
 </ClCompile>

Now declare the connection in:
client/src/framework/net/declarations.h

like this:
below class Connection;
add this class WebSocketConnection;

then below using ConnectionPtr = std::shared_ptr<Connection>
add this using WebSocketConnectionPtr = std::shared_ptr<WebSocketConnection>;

Then compile OTCv8.

Login protocol changes:

In client/modules/gamelib/protocollogin.lua below self.connectCallback = self.sendLoginPacket add this self.loginHost = host
then find function function ProtocolLogin:onConnect() and below self.gotConnection = true add this g_game.setLoginConnectionType(true, self.loginHost or "")

Enter Game Changes:

In client/modules/client_entergame/entergame.lua
Repalce this:
LUA:
local server_ip = server_params[1]
local server_port = 7171
if #server_params >= 2 then
    server_port = tonumber(server_params[2])
end
if #server_params >= 3 then
    G.clientVersion = tonumber(server_params[3])
end
if type(server_ip) ~= "string" or server_ip:len() <= 3 or not server_port or not G.clientVersion then
    return EnterGame.onError(
        "Invalid server, it should be in format IP:PORT or it should be http url to login script"
    )
end

With this:
LUA:
G.host = "ws://yourdomain.com"
local server_ip = "ws://yourdomain.com"
local server_port = 9950
G.clientVersion = 1098

Note: Change yourdomain.com to your domain registered on Cloudflare, pointing to your main host IP via DNS, similar to how you set up website IPs. Just make sure not to enable Under Attack Mode, and feel free to use different ports.

We are still in entergame.lua after this:

LUA:
if #server_params <= 3 and not g_game.getFeature(GameExtendedOpcode) then
    g_game.setCustomOs(2)
end

add this:

LUA:
for i = 4, #server_params do
    g_game.enableFeature(tonumber(server_params[i]))
end

In client/init.lua use 127.0.0.1:7171
In server/config.lua use 127.0.0.1

Then upload the compiled tunnel binary to your dedicated server and point your domain (Cloudflare) to it.
Your client will now connect through Cloudflare, keeping your real server IP hidden and adding protection against DDoS attacks.

Setup Complete: Your client will now connect through Cloudflare, keeping your real server IP hidden and adding protection against DDoS attacks.

Keep in mind: This setup hides your real server IP behind Cloudflare, adds strong protection against common DDoS attacks, and is relatively simple to set up once configured. It works well with OTCv8 and similar clients. At the same time, it may introduce slight latency depending on your setup, requires proper configuration to avoid packet-related issues, and performance can vary based on server location and routing.

If anyone has feedback, improvements, or real test results, feel free to share.

Scan Results (VirusTotal)

Just released an upgraded version of the websocket tunnel with several security and stability improvements for OTClient/TFS setups.
 

Attachments

Last edited:
amazing work, thanks for this huge bump on network infra around the ot server !
Thanks to Cblade*

1) The developer that gave you the code never asked for the latest because he doesn't use it, he proactively sold you something he know is worthless, so you were literally fooled for trying to bother with me, i wonder how you feel..

I just want to leave a warning that this version is very unstable and will cause lags
Use it on your own responsibility as you can see for yourself this man threatens me already and very envious haha

This code is missing:
1) Concurrency limitation to prevent overburst.
2) CORRECT management to tibia packets this one creates latency under stress (to test add 2 hotkeys that teleport you to different towns) = ping 500ms (so it freezes whenever you over stress the client) make sure you are aware this is the main cause of latency as it was fixed in later versions.
3) OTClient code is proactively running tibia packet sequence due to packing into WSFrames and unpacking from WSFrames, IGNORING extra packets after the first large packet in the WSFrame which may cause client desyncs.


(Im cblade again im not trying to hide it and never will since i never do anything wrong) im the one being stolen actually but there is not a single proof in the whole community that i scammed or did harm to anybody, i just exposed that OTCv8 Free version encryption was decryptable after @oen432 Decrypted asgard-ot client in 2020 and said he can use the wings i bought for 200usd back in 2020 ye that was huge to have wings in your OT, i felt sure that there is something fishy since he also said its secure 100%, i proofed the opposite & this is when rumors started circulating, but you will never find a proof, you may find proofs that im trust worthy even by the ppl that hates me the most already said it. i don't take what is not mine, im grateful enough for what im capable to do and i can make myself a living. i hope ppl understand that with that amount of skill life matters start being trivial you look forward into growing and creation, i went after one of their adopters OT to make the trouble that caused them to leak otcv8 because it was insane somebody paid 4500 usd and found his files in my hands with 5 mins code in 2021.

Aizen while he threatens me & the code files that was shared with "that developer" proofs its the same files, please don't use it its harmful and if you need more proofs on that subject (that its harmful) i can provide plenty, that the version created in feburary was very unstable (Before Exoria74 which had the first stable version of the WS)

Feel free to improve it if you want i was planning to share the full code but otland prefers the leaked.
#2 leak by me on otland maybe rename CBladeHand

1776473211662.webp
1776472938533.webp1776473182934.webp
 
Last edited:
Why not just run the OT server in Docker and put it behind Cloudflare Tunnel that way? It's how a lot of homelab people put their servers online without exposing their IP address. And any time Cloudflare has an outage, the server will be inaccessible. Putting it in front of Docker is more dynamic as you can even have backups in place and switch to other tunnel services in case on goes down. With this method you'd have to recompile the binary every time you want to make a change.

Don't reinvent the wheel if it's already rolling fine.

Just search "Docker and Cloudflare Tunnel" on Google or YouTube and you'll find hundreds of guides.
 
Doesnt fit my nodejs server, but interesting post

Code:
For a **Node.js server**, this is only a win in a narrow case.

If your Node app is already a normal **HTTP/HTTPS or WebSocket** service, this post is probably **not** the right enhancement. Cloudflare already proxies and protects normal web/WebSocket traffic, so adding a custom TCP-over-WebSocket bridge would mostly add complexity, latency, and failure modes. If your service is a **custom raw TCP protocol** and you want to hide the origin behind Cloudflare without paying for Spectrum, then the idea is interesting, but I would treat this exact implementation as **experimental, not production-grade**.

**What the security approach is**
It works by:
- Having clients connect to a **WebSocket endpoint** on a Cloudflare-proxied hostname.
- The bridge server then opens a **plain TCP connection** to your real backend.
- Cloudflare shields the public hostname, so users see Cloudflare IPs instead of your origin.
- The bridge tries to forward the real client IP to the backend using **PROXY v2**.

So the security value is mainly:
- **Origin IP masking**
- **Cloudflare edge absorbing some volumetric HTTP/WebSocket DDoS**
- **A smaller direct attack surface**, if you lock the origin down correctly

**What it can prevent**
It can help prevent or reduce:
- Direct attacks against your **public origin IP**
- Basic **L3/L4 floods** aimed at the hostname, because Cloudflare is in front
- Some low-effort scanning and opportunistic abuse of the real server

It does **not** prevent:
- Attacks if the **real origin IP is already known or leaked**
- App-layer abuse that reaches your backend through the tunnel
- Resource exhaustion from too many long-lived WebSocket connections
- Logic attacks, auth bypasses, protocol abuse, bad packets, account abuse, or ordinary app vulnerabilities

**Why I would be cautious with this exact code**
There are several red flags:
- `CheckOrigin: return true` accepts any origin.
- It trusts `CF-Connecting-IP`, `X-Forwarded-For`, and `X-Real-IP`; that is only safe if the origin is **strictly firewalled to Cloudflare only**.
- There is **no authentication**, no rate limiting, no connection cap, no per-IP limits, no idle timeout strategy, and no clear backpressure protection.
- The post itself includes a credible warning from another user about **latency/desync under stress**.
- It uses a custom packet-bridging design, which is exactly the kind of thing that can become unstable under load.

So from a security standpoint, the idea is valid; from an engineering standpoint, **this implementation looks rough**.

**Does it cost money?**
The post’s code itself is released in the thread, so the code is not necessarily the expensive part. But the broader answer is:

- **Cloudflare WebSockets on proxied HTTP traffic** can be used on normal plans.
- **Cloudflare Tunnel** is available on all plans.
- **Cloudflare Spectrum** is the official product for proxying arbitrary TCP/UDP apps, and it is **paid / plan-dependent**, with some features requiring Enterprise.

Sources:
- [Cloudflare WebSockets / HTTP headers behavior](https://developers.cloudflare.com/fundamentals/reference/http-headers/)
- [Cloudflare Tunnel docs](https://developers.cloudflare.com/tunnel/)
- [Cloudflare Spectrum overview](https://developers.cloudflare.com/spectrum/)
- [Cloudflare Spectrum proxy protocol](https://developers.cloudflare.com/spectrum/how-to/enable-proxy-protocol/)
- [Cloudflare Spectrum protocols per plan](https://developers.cloudflare.com/spectrum/protocols-per-plan/)

**Is it costly operationally?**
Yes, even if the traffic path is “free-ish,” it is costly in:
- **Complexity**
- **Debugging**
- **Client changes**
- **Extra latency**
- **Reliability risk**
- **Maintenance burden**

For a Node.js server, those operational costs usually outweigh the benefits unless you truly need to tunnel a non-HTTP protocol through Cloudflare.

**My verdict**
For a typical **Node.js API / web app / Socket.IO / WebSocket backend**: **not worth it**. Use Cloudflare normally, lock down the origin, add rate limiting, auth hardening, and maybe Cloudflare Tunnel.

For a **custom TCP service** where hiding origin IP is a top priority and you are avoiding Spectrum fees: **the concept can be a win**, but I would **not trust this exact forum code without a serious hardening pass and load testing**.

If you want, I can turn this into a **Node.js-specific recommendation**:
1. when this pattern is worth using,
2. what a safer architecture looks like for your stack,
3. and what you can do right now in your Node server to get 80% of the protection without this tunnel.
 
Is there no way to just use/modify the GitHub - erebe/wstunnel: Tunnel all your traffic over Websocket or HTTP2 - Bypass firewalls/DPI - Static binary available (https://github.com/erebe/wstunnel) then, to eliminate the downsides? I think if adapted, that would yield the biggest performance and stability.
Good point, wstunnel is definitely a solid and flexible tool. The difference here is that it’s a generic tunnel for forwarding traffic over WebSocket, while this solution is tailored specifically for Tibia traffic and OTC compatibility. It handles packet framing properly, ensuring one full Tibia packet per WebSocket frame, which helps avoid issues like desync or packet splitting under load that can happen with generic TCP tunneling.
Post automatically merged:

Why not just run the OT server in Docker and put it behind Cloudflare Tunnel that way? It's how a lot of homelab people put their servers online without exposing their IP address. And any time Cloudflare has an outage, the server will be inaccessible. Putting it in front of Docker is more dynamic as you can even have backups in place and switch to other tunnel services in case on goes down. With this method you'd have to recompile the binary every time you want to make a change.

Don't reinvent the wheel if it's already rolling fine.

Just search "Docker and Cloudflare Tunnel" on Google or YouTube and you'll find hundreds of guides.
Yeah, Docker + Cloudflare Tunnel is a solid setup. This approach is a bit different though, it’s focused on handling Tibia traffic over WebSocket with proper packet framing for OTC clients, rather than just tunneling connections. It keeps compatibility on the client side while still hiding the real server IP.
 
Is there no way to just use/modify the GitHub - erebe/wstunnel: Tunnel all your traffic over Websocket or HTTP2 - Bypass firewalls/DPI - Static binary available (https://github.com/erebe/wstunnel) then, to eliminate the downsides? I think if adapted, that would yield the biggest performance and stability.
If you can find a way, to make it work as reverse proxy, that reads IP from WS header (from CF HTTP header) and passes it to 'TCP socket' with proxy-v2 protocol, it would be great!

Server side similar to this thread server app is GitHub - novnc/websockify: Websockify is a WebSocket to TCP proxy/bridge. This allows a browser to connect to any application/server/service. (https://github.com/novnc/websockify) , but websockify does not support proxy-v2 protocol ( https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt ) - it does not pass player IP to OTS, so all players have IP 127.0.0.1.
And you still need OTClient changes from this thread, to make it work with WebSockets.
Why not just run the OT server in Docker and put it behind Cloudflare Tunnel that way?
It looks like that's exactly what this app does. It adds WS support to OTS (as separate app) and to OTC, and then you can setup anything on CF (normal DNS records on domain or 'CF Tunnel') and it will work, because from CF point of view, it will be website server, not custom app with custom protocol.

As far as I know, CF does not offer other protocols (only HTTP, WS, SSH [5 GB/month limit on free account]) tunnel/proxy/protection on free account. With this code you can use full CF protection on free account.

If you got some more info about CF configs for custom protocols like normal Tibia 'socket' on port 7171/7172, I would like to hear about it.

It's how a lot of homelab people put their servers online without exposing their IP address.
I think it's because 99% of homelab apps are websites, so they work great with CF.
I read few tutorials headers and they all start from starting NodeJS webserver.
 
I recently got my hands on this Cloudflare WebSocket tunnel, and after testing it, I realized how useful it can be for protecting servers.
This system cost around $400 for the compiled binary (not the source code), but instead of keeping it private, I decided to release it so more people can benefit from it, especially with the increasing DDoS attacks and threats many server owners are facing.

This tool helps route game traffic through WebSocket over Cloudflare, hiding your real server IP, forwarding the real client IP using PROXYv2, and adding an extra layer of protection against DDoS. Special thanks to @Nekiro for the original idea behind this system. This isn’t about money, it is about helping the community run their servers more safely and reliably. I hope this contributes to reducing DDoS issues and makes things better for everyone hosting their servers.

Feel free to try it out and share your feedback.

Tunnel Source Code:
Code:
package main

import (
    "encoding/binary"
    "encoding/hex"
    "fmt"
    "io"
    "log"
    "net"
    "net/http"
    "os"
    "os/signal"
    "strings"
    "sync"
    "syscall"
    "time"

    "github.com/gorilla/websocket"
)

// Configuration - separate ports for game and login

const (
    GameWsPort  = ":7575" // WebSocket port for game server
    LoginWsPort = ":9950" // WebSocket Port for login server
    LoginTcpHost = "168.212.119.171:7171" // TFS login server
    GameTcpHost = "168.212.119.171:7172" // TFS login server
    EnableLogging = false
    BufferSize = 327680
)

var upgrader = websocket.Upgrader{
    ReadBufferSize: BufferSize,
    WriteBufferSize: BufferSize,
    CheckOrigin: func(r *http.Request) bool {
        return true
    },
}

// packetBufPool recycles packet body buffers across connections.
// At 5k connections each buffer is up to 65535 bytes; pooling avoids
// constant GC pressure from per-packet allocations in copyTCPtoWS

var packetBufPool = sync.Pool{
    New: func() any {
        buf := make([]byte, 65535)
        return &buf
    },
}

// wsReader wraps a WebSocket connection and implements io.Reader.
//
// Uses NextReader instead of ReadMessage to avoid per-packet allocations:
// NextReader returns an io.Reader over the raw frame data, so io.Copy
// pulls bytes directly from the WebSocket frame into the TCP write buffer
// with zero extra allocation.
//
// gorilla allows exactly one concurrent reader - safe here as this is
// only used from the WS -> TCP goroutine.

type wsReader struct {
    conn *websocket.Conn
    reader io.Reader // current frame reader, nil when between frames
}

func (r *wsReader) Read(p []byte) (int, error) {
    for {
        // If we have an active frame reader, drain it first
        if r.reader != nil {
            n, err := r.reader.Read(p)
            if err == io.EOF {
                // Frame fully consumed, clear and loop to get next frame
                r.reader = nil
                if n > 0 {
                    return n, nil
                    }
                    continue
                }
                return n, err
            }

            // No active frame - get the next one
            messageType, reader, err := r.conn.NextReader()
            if err != nil {
                return 0, err
            }
            if messageType != websocket.BinaryMessage {
             // Skip non-binary frames; ping/pong handled internally by gorilla
             continue
        }
        r.reader = reader
    }
}

// copyTCPtoWS reads complete Tibia packets from TCP and forwards each as a
// single WebSocket binary frame. This prevents io.Copy's internal 32KB buffer
// from splitting a packet across multiple frames, which would corrupt the
// client's parser if it expects one frame = one complete message.
//
// Tibia packet layout: [len_lo][len_hi][...payload of len bytes...]
// The 2-byte header is included in the forwarded frame so the client receives
// exactly what TFS sent, with the same framing it already expects.
//
// Body buffers are recycled via packetBufPool - at 5k connections this keeps
// allocations off the hot path and reduces GC pressure significantly.

func copyTCPtoWS(wsConn *websocket.Conn, tcpConn net.Conn) error {
    var headerBuf [2]byte

    for {
        // Read the 2-byte little-endian Tibia packet length
        if _, err := io.ReadFull(tcpConn, headerBuf[:]); err != nil {
            return err
        }

        packetLen := binary.LittleEndian.Uint16(headerBuf[:])
        if packetLen == 0 {
            continue
        }

        // Borrow a buffer from the pool; grow only when the packet is larger
        // than the pooled slice (rare - Tibia packets are almost always <8KB)
        bufPtr := packetBufPool.Get().(*[]byte)
        if int(packetLen) > len(*bufPtr) {
            *bufPtr = make([]byte, packetLen)
        }

        body := (*bufPtr)[:packetLen]

        // Read exactly packetLen bytes - one complete Tibia packet body
        if _, err := io.ReadFull(tcpConn, body); err != nil {
            packetBufPool.Put(bufPtr)
            return err
        }
        // Build the complete frame: [len_lo, len_hi, ...body...]
        // Allocating a fresh frame slice here is intentional: WriteMessage holds
        // a reference to the slice until the write completes, so we cannot reuse
        // the pool buffer without a copy anyway.
        frame := make([]byte, 2+int(packetLen))
        copy(frame[0:2], headerBuf[:])
        copy(frame[2:], body)

        packetBufPool.Put(bufPtr)

        // One complete Tibia packet = one WebSocket frame, always
        if err := wsConn.WriteMessage(websocket.BinaryMessage, frame); err != nil {
            return err
        }
    }
}

// Bridge represents a connection bridge between WebSocket client and TCP server

type Bridge struct {
    wsConn *websocket.Conn
    tcpConn net.Conn
    clientIP string
    tcpHost string
    closeChan chan struct{}
    closeOnce sync.Once
}

// NewBridge creates a new bridge instance
func NewBridge(ws *websocket.Conn, clientIP string, tcpHost string) *Bridge {
    return &Bridge {
        wsConn: ws,
        clientIP: clientIP,
        tcpHost: tcpHost,
        closeChan: make(chan struct{}),
    }
}

// Start initializes the bridge

func (b *Bridge) Start() {
    logMsg("Bridge", fmt.Sprintf("Connecting to %s for client: %s", b.tcpHost, b.clientIP))

    tcpConn, err := net.DialTimeout("tcp", b.tcpHost, 5*time.Second)
    if err != nil {
        logMsg("Error", fmt.Sprintf("Failed to connect to TFS (%s): %v", b.tcpHost, err))
        b.Close()
        return
    }

    // Disable Nagle - critical for real-time game traffic
    if tcp, ok := tcpConn.(*net.TCPConn); ok {
        tcp.SetNoDelay(true)
        tcp.SetKeepAlive(true)
        tcp.SetKeepAlivePeriod(60 * time.Second)
    }

    b.tcpConn = tcpConn
    logMsg("Bridge", fmt.Sprintf("TCP connected to %s for client: %s", b.tcpHost, b.clientIP))

    // Send PROXYv2 header for real IP forwarding to TFS
    if proxyHeader := b.buildPROXYv2Header(); len(proxyHeader) > 0 {
        if _, err := b.tcpConn.Write(proxyHeader); err != nil {
            logMsg("Error", fmt.Sprintf("Failed to send PROXYv2 header: %v", err))
            b.Close()
            return
        }
        logMsg("Proxy", fmt.Sprintf("Sent PROXYv2 header for client: %s", b.clientIP))
    }
    // WS → TCP
    // wsReader streams frame data directly via NextReader - zero allocations per packet.
    go func() {
        defer b.Close()
        _, err := io.Copy(b.tcpConn, &wsReader{conn: b.wsConn})
        if err != nil && !b.isClosed() {
            logMsg("WS→TCP Error", fmt.Sprintf("%s: %v", b.clientIP, err))
        }
    } ()

    // TCP → WS
    // copyTCPtoWS reads complete Tibia packet and sends each as one WS frame,
    // preventing io.Copy's 32KB chunking from splitting packets mid-message.
    go func() {
        defer b.Close()
        err := copyTCPtoWS(b.wsConn, b.tcpConn)
        if err != nil && !b.isClosed() {
            logMsg("TCP→WS Error", fmt.Sprintf("%s: %v", b.clientIP, err))
        }
    }()
}

// isClosed reports whether the bridge has been closed.

func (b *Bridge) isClosed() bool {
    select {
    case <-b.closeChan:
        return true
    default:
        return false
    }
}

// Close shuts down both sides of the bridge exactly once.
// Closing the connections unblocks any blocked io.Copy immediately.

func (b *Bridge) Close() {
    b.closeOnce.Do(func(){
        close(b.closeChan)

        if b.wsConn != nil {
            b.wsConn.Close()
        }
        if b.tcpConn != nil {
            b.tcpConn.Close()
        }

        logMsg("Bridge", fmt.Sprintf("Closed connection for client: %s", b.clientIP))
    })
}

// buildPROXYv2Header builds a HAProxy PROXYv2 binary protocol header
// so TFS receives the real client IP rather than 127.0.0.1.

func (b *Bridge) buildPROXYv2Header() []byte {
    if b.clientIP == "" {
        return nil
    }

    ip := net.ParseIP(b.clientIP)
    if ip == nil {
        logMsg("Warning", fmt.Sprintf("Invalid IP: %s", b.clientIP))
        return nil
    }

    ipv4 := ip.To4()
    if ipv4 == nil {
        if strings.Contains(b.clientIP, ":") {
            parts := strings.Split(b.clientIP, ":")
            if len (parts) > 0 {
                ip = net.ParseIP(parts[len(parts)-1])
                if ip != nil {
                    ipv4 = ip.To4()
                }
            }
        }
        if ipv4 == nil {
            logMsg("Warning", fmt.Sprintf("Cannot convert to IPv4: %s", b.clientIP))
            return nil
        }
    }

    header := make([]byte, 28)
    copy(header[0:12], []byte{0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A})
    header[12] = 0x21 // Version 2, PROXY command
    header[13] = 0x11 // AF_INET, STREAM
    binary.BigEndian.PutUint16(header[14:16], 12) // Address block length
    copy(header[16:20], ipv4) // Source IP (real client)
    copy(header[20:24], []byte{0, 0, 0, 0}) // Destination IP (placeholder)
    binary.BigEndian.PutUint16(header[24:26], 0) // Source port (placeholder)

    var dstPort uint16
    if strings.Contains(b.tcpHost, "7171") {
        dstPort = 7171
    } else {
        dstPort = 7172
    }
    binary.BigEndian.PutUint16(header[26:28], dstPort)

    if EnableLogging {
        logMsg("Proxy", fmt.Sprintf("PROXYv2 for %s → IP bytes: %s", b.clientIP, hex.EncodeToString(header[16:20])))
    }

    return header
}

// extractClientIP extracts the real client IP from proxy/CDN headers

func extractClientIP(r *http.Request) string {
    if cfIP := r.Header.Get("CF-Connecting-IP"); cfIP != "" {
        return cfIP
    }
    if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
        ips := strings.Split(xff, ",")
        if len(ips) > 0 {
            return strings.TrimSpace(ips[0])
        }
    }
    if xri := r.Header.Get("X-Real-IP"); xri != "" {
        return xri
    }

    clientIP := r.RemoteAddr
    if strings.Contains(clientIP, ":") {
        if host, _, err := net.SplitHostPort(clientIP); err == nil {
            clientIP = host
        }
    }
    clientIP = strings.TrimPrefix(clientIP, "::ffff:")

    if clientIP == "" || clientIP == "127.0.0.1" || clientIP == "::1" {
        logMsg("Warning", fmt.Sprintf("Using localhost IP: %s", clientIP))
    }

    return clientIP
}

// handleWebSocket upgrades the HTTP connection and starts a bridge.
// NOTE: http.Server ReadTimeout/WriteTimeout must not be set - they apply to
// the raw TCP connection and will kill long-lived WebSocket connections.

func handleWebSocket(tcpHost string) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        clientIP := extractClientIP(r)
        logMsg("Server", fmt.Sprintf("New connection from: %s → %s", clientIP, tcpHost))

        ws, err := upgrader.Upgrade(w, r, nil)
        if err != nil {
            logMsg("Error", fmt.Sprintf("WebSocket upgrade failed: %v", err))
            return
        }
        ws.EnableWriteCompression(false) // Never compress - adds latency, Tibia data is already packed
        ws.SetReadLimit(327680) // Match client SEND_BUFFER_SIZE to avoid false read-limit errors

        bridge := NewBridge(ws, clientIP, tcpHost)
        bridge.Start()
    }
}

func logMsg(tag, message string) {
    if EnableLogging {
        log.Printf("[%s] [%s] [%s]\n", time.Now().Format("2006-01-02 15:04:05.000"), tag, message)
    }
}

func main() {
    // IMPORTANT: No ReadTimeout or WriteTimeout on the http.Server.
    // These are applied to the raw net.Conn underneath, which kills WebSocket
    // connections after the timeout regardless of activity on the WS layer.
    gameServer := &http.Server{
        Addr: GameWsPort,
        Handler: handleWebSocket(GameTcpHost),
    }

    loginServer := &http.Server{
        Addr: LoginWsPort,
        Handler: handleWebSocket(LoginTcpHost),
    }

    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
    go func() {
        <-sigChan
        logMsg("Server", "Shutting down...")
        gameServer.Close()
        loginServer.Close()
        os.Exit(0)
    }()

    logMsg("Server", "OTClient WebSocket Bridge started")

    go func() {
        if err := loginServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatal("Login server error:", err)
        }
    }()

    if err := gameServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        log.Fatal("Game server error:", err)
    }
}

Here's a simple guide on how to compile and use it:

Open CMD inside the folder containing the tunnel files, then run:

Code:
winget install GoLang.Go
go mod init tunnel
go mod tidy
set GOOS=linux
set GOARCH=amd64
go build -o tunnel tunnel.go

This will generate the compiled binary for your server.

View attachment 99644

OTClient Changes:
Add the following files in: client/src/framework/net

- websocket_connection.cpp
- websocket_connection.h

Then declare them in:
otclient.vcxproj as follows:

below <ClInclude Include="..\src\framework\net\connection.h" />
add this <ClInclude Include="..\src\framework\net\websocket_connection.h" />

below
Code:
<ClCompile Include="..\src\framework\net\connection.cpp">
      <ExcludedFromBuild Condition="'$(Configuration)|$(Platform)'=='Debug_lib|Win32'">false</ExcludedFromBuild>
      <ExcludedFromBuild Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">true</ExcludedFromBuild>
 </ClCompile>

add this
Code:
<ClCompile Include="..\src\framework\net\websocket_connection.cpp">
      <ExcludedFromBuild Condition="'$(Configuration)|$(Platform)'=='Debug_lib|Win32'">false</ExcludedFromBuild>
      <ExcludedFromBuild Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">true</ExcludedFromBuild>
 </ClCompile>

Now declare the connection in:
client/src/framework/net/declarations.h

like this:
below class Connection;
add this class WebSocketConnection;

then below using ConnectionPtr = std::shared_ptr<Connection>
add this using WebSocketConnectionPtr = std::shared_ptr<WebSocketConnection>;

Then compile OTCv8.

Login protocol changes:

In client/modules/gamelib/protocollogin.lua below self.connectCallback = self.sendLoginPacket add this self.loginHost = host
then find function function ProtocolLogin:onConnect() and below self.gotConnection = true add this g_game.setLoginConnectionType(true, self.loginHost or "")

Enter Game Changes:

In client/modules/client_entergame/entergame.lua
Repalce this:
LUA:
local server_ip = server_params[1]
local server_port = 7171
if #server_params >= 2 then
    server_port = tonumber(server_params[2])
end
if #server_params >= 3 then
    G.clientVersion = tonumber(server_params[3])
end
if type(server_ip) ~= "string" or server_ip:len() <= 3 or not server_port or not G.clientVersion then
    return EnterGame.onError(
        "Invalid server, it should be in format IP:PORT or it should be http url to login script"
    )
end

With this:
LUA:
G.host = "ws://yourdomain.com"
local server_ip = "ws://yourdomain.com"
local server_port = 9950
G.clientVersion = 1098

Note: Change yourdomain.com to your domain registered on Cloudflare, pointing to your main host IP via DNS, similar to how you set up website IPs. Just make sure not to enable Under Attack Mode, and feel free to use different ports.

We are still in entergame.lua after this:

LUA:
if #server_params <= 3 and not g_game.getFeature(GameExtendedOpcode) then
    g_game.setCustomOs(2)
end

add this:

LUA:
for i = 4, #server_params do
    g_game.enableFeature(tonumber(server_params[i]))
end

In client/init.lua use 127.0.0.1:7171
In server/config.lua use 127.0.0.1

Then upload the compiled tunnel binary to your dedicated server and point your domain (Cloudflare) to it.
Your client will now connect through Cloudflare, keeping your real server IP hidden and adding protection against DDoS attacks.

Setup Complete: Your client will now connect through Cloudflare, keeping your real server IP hidden and adding protection against DDoS attacks.

Keep in mind: This setup hides your real server IP behind Cloudflare, adds strong protection against common DDoS attacks, and is relatively simple to set up once configured. It works well with OTCv8 and similar clients. At the same time, it may introduce slight latency depending on your setup, requires proper configuration to avoid packet-related issues, and performance can vary based on server location and routing.

If anyone has feedback, improvements, or real test results, feel free to share.

Scan Results (VirusTotal)
Great work! if this is another release from scammer Cblade then well done that gjypsi scammer dosen't deserve to be around this community.
 
Interesting, but will you not get weird hops sometimes with the free Cloudflare? Could perhaps be used with argo but then it might get a bit more expensive.

Although it probably soaks ddos better than a cheap vps..?
 
Hello, thank you for sharing this system.

I am testing the solution currently and it works, stable ping at 20ms, however I am getting some weird hops for a second to 200 ping then back to 20 every now and then.

Is there something I can do about this? I can’t seem to reproduce it happening, it’s very random.
 
I have got my hands on an upgraded version of the websocket tunnel posted above, featuring several security and stability improvements such as limits/restrictions for max connections, packets and IPs, along with localhost-only login support.

Features:
  • Localhost-only login support
  • IP/connection/packet restrictions
  • Improved websocket stability
  • Better reconnect handling
  • Reduced allocation/GC pressure
  • Proper websocket cleanup
  • HAProxy integration
  • Real IP forwarding (PROXY Protocol v2)
Most of the improvements below focus on websocket connection handling, packet forwarding consistency and reducing unnecessary allocations under heavy load.

Updated tunnel source:
Code:
package main

import (
    "encoding/binary"
    "encoding/hex"
    "fmt"
    "io"
    "log"
    "net"
    "net/http"
    "os"
    "os/signal"
    "strings"
    "sync"
    "sync/atomic"
    "syscall"
    "time"

    "github.com/gorilla/websocket"
)

// Configuration - separate ports for game and login

const (
    GameWsPort  = ":8080" // WebSocket port for game server
    LoginWsPort = ":8880" // WebSocket Port for login server
    LoginTcpHost = "127.0.0.1:7171" // TFS login server
    GameTcpHost = "127.0.0.1:7172" // TFS login server
    EnableLogging = false
    BufferSize = 327680

    // Login limiter (stricter)
    LoginMaxConnPerIP = 1000
    LoginMaxAttemptsPerIP = 1000
    LoginAttemptWindow = 30 * time.Second
    LoginMinReconnectGap = 1000 * time.Millisecond

    // Game limiter (looser, safer for normal play)
    GameMaxConnPerIP = 1000
    GameMaxAttemptsPerIP = 1000
    GameAttemptWindow = 30 * time.Second
    GameMinReconnectGap = 50 * time.Millisecond

    IPStateIdleExpiry = 1 * time.Minute
)

var upgrader = websocket.Upgrader{
    ReadBufferSize: BufferSize,
    WriteBufferSize: BufferSize,
    CheckOrigin: func(r *http.Request) bool {
        return true
    },
}

type ipState struct {
    active int32
    lastSeenUnix int64

    mu sync.Mutex
    windowStart time.Time
    attempts int
    lastAttempt time.Time
}

type limiterConfig struct {
    name string
    maxConnPerIP int32
    maxAttemptsPerIP int
    attemptWindow time.Duration
    minReconnectGap time.Duration
}

type limiterBucket struct {
    cfg limiterConfig
    states sync.Map // map[string]*ipState
}

var loginLimiter = &limiterBucket{
    cfg: limiterConfig{
        name: "login",
        maxConnPerIP: LoginMaxConnPerIP,
        maxAttemptsPerIP: LoginMaxAttemptsPerIP,
        attemptWindow: LoginAttemptWindow,
        minReconnectGap: LoginMinReconnectGap,
    },
}

var gameLimiter = &limiterBucket{
    cfg: limiterConfig{
        name: "game",
        maxConnPerIP: GameMaxConnPerIP,
        maxAttemptsPerIP: GameMaxAttemptsPerIP,
        attemptWindow: GameAttemptWindow,
        minReconnectGap: GameMinReconnectGap,
    },
}


// packetBufPool recycles packet body buffers across connections.
// At 5k connections each buffer is up to 65535 bytes; pooling avoids
// constant GC pressure from per-packet allocations in copyTCPtoWS

var packetBufPool = sync.Pool{
    New: func() any {
        buf := make([]byte, 65535)
        return &buf
    },
}

func (b *limiterBucket) getIPState(ip string) *ipState {
    v, _ := b.states.LoadOrStore(ip, &ipState{
        lastSeenUnix: time.Now().Unix(),
    })
    st := v.(*ipState)
    atomic.StoreInt64(&st.lastSeenUnix, time.Now().Unix())
    return st
}

func (b *limiterBucket) allowAttempt(ip string) bool {
    if ip == "" {
        return false
    }

    now := time.Now()
    st := b.getIPState(ip)

    st.mu.Lock()
    defer st.mu.Unlock()

    if !st.lastAttempt.IsZero() && now.Sub(st.lastAttempt) < b.cfg.minReconnectGap {
        st.lastAttempt = now
        return false
    }

    if st.windowStart.IsZero() || now.Sub(st.windowStart) >= b.cfg.attemptWindow {
        st.windowStart = now
        st.attempts = 0
    }

    st.attempts++
    st.lastAttempt = now

    return st.attempts <= b.cfg.maxAttemptsPerIP
}

func (b *limiterBucket) incConn(ip string) bool {
    if ip == "" {
        return false
    }

    st := b.getIPState(ip)
    n := atomic.AddInt32(&st.active, 1)
    if n > b.cfg.maxConnPerIP {
        atomic.AddInt32(&st.active, -1)
        return false
    }
    return true
}

func (b *limiterBucket) decConn(ip string) {
    if ip == "" {
        return
    }

    v, ok := b.states.Load(ip)
    if !ok{
        return
    }

    st:= v.(*ipState)
    n := atomic.AddInt32(&st.active, -1)
    if n < 0 {
        atomic.StoreInt32(&st.active, 0)
    }
    atomic.StoreInt64(&st.lastSeenUnix, time.Now().Unix())
}

func (b *limiterBucket) cleanupLoop() {
    ticker := time.NewTicker(1 * time.Minute)
    defer ticker.Stop()

    for range ticker.C {
        nowUnix := time.Now().Unix()

        b.states.Range(func(key, value any) bool {
            st := value.(*ipState)
            active := atomic.LoadInt32(&st.active)
            lastSeen := atomic.LoadInt64(&st.lastSeenUnix)

            if active == 0 && time.Duration(nowUnix-lastSeen)*time.Second > IPStateIdleExpiry {
                b.states.Delete(key)
            }
            return true
        })
    }
}

// wsReader wraps a WebSocket connection and implements io.Reader.
//
// Uses NextReader instead of ReadMessage to avoid per-packet allocations:
// NextReader returns an io.Reader over the raw frame data, so io.Copy
// pulls bytes directly from the WebSocket frame into the TCP write buffer
// with zero extra allocation.
//
// gorilla allows exactly one concurrent reader - safe here as this is
// only used from the WS -> TCP goroutine.

type wsReader struct {
    conn *websocket.Conn
    reader io.Reader // current frame reader, nil when between frames
}

func (r *wsReader) Read(p []byte) (int, error) {
    for {
        // If we have an active frame reader, drain it first
        if r.reader != nil {
            n, err := r.reader.Read(p)
            if err == io.EOF {
                // Frame fully consumed, clear and loop to get next frame
                r.reader = nil
                if n > 0 {
                    return n, nil
                    }
                    continue
                }
                return n, err
            }

            // No active frame - get the next one
            messageType, reader, err := r.conn.NextReader()
            if err != nil {
                return 0, err
            }
            if messageType != websocket.BinaryMessage {
             // Skip non-binary frames; ping/pong handled internally by gorilla
             continue
        }
        r.reader = reader
    }
}

// copyTCPtoWS reads complete Tibia packets from TCP and forwards each as a
// single WebSocket binary frame. This prevents io.Copy's internal 32KB buffer
// from splitting a packet across multiple frames, which would corrupt the
// client's parser if it expects one frame = one complete message.
//
// Tibia packet layout: [len_lo][len_hi][...payload of len bytes...]
// The 2-byte header is included in the forwarded frame so the client receives
// exactly what TFS sent, with the same framing it already expects.
//
// Body buffers are recycled via packetBufPool - at 5k connections this keeps
// allocations off the hot path and reduces GC pressure significantly.

func copyTCPtoWS(wsConn *websocket.Conn, tcpConn net.Conn) error {
    var headerBuf [2]byte

    for {
        // Read the 2-byte little-endian Tibia packet length
        if _, err := io.ReadFull(tcpConn, headerBuf[:]); err != nil {
            return err
        }

        packetLen := binary.LittleEndian.Uint16(headerBuf[:])
        if packetLen == 0 {
            continue
        }

        // Borrow a buffer from the pool; grow only when the packet is larger
        // than the pooled slice (rare - Tibia packets are almost always <8KB)
        bufPtr := packetBufPool.Get().(*[]byte)
        if int(packetLen) > len(*bufPtr) {
            *bufPtr = make([]byte, packetLen)
        }

        body := (*bufPtr)[:packetLen]

        // Read exactly packetLen bytes - one complete Tibia packet body
        if _, err := io.ReadFull(tcpConn, body); err != nil {
            packetBufPool.Put(bufPtr)
            return err
        }
        // Build the complete frame: [len_lo, len_hi, ...body...]
        // Allocating a fresh frame slice here is intentional: WriteMessage holds
        // a reference to the slice until the write completes, so we cannot reuse
        // the pool buffer without a copy anyway.
        frame := make([]byte, 2+int(packetLen))
        copy(frame[0:2], headerBuf[:])
        copy(frame[2:], body)

        packetBufPool.Put(bufPtr)

        // One complete Tibia packet = one WebSocket frame, always
        if err := wsConn.WriteMessage(websocket.BinaryMessage, frame); err != nil {
            return err
        }
    }
}

// Bridge represents a connection bridge between WebSocket client and TCP server

type Bridge struct {
    wsConn *websocket.Conn
    tcpConn net.Conn
    clientIP string
    tcpHost string
    limiter *limiterBucket
    closeChan chan struct{}
    closeOnce sync.Once
    releaseOnce sync.Once
}

// NewBridge creates a new bridge instance
func NewBridge(ws *websocket.Conn, clientIP string, tcpHost string, limiter *limiterBucket) *Bridge {
    return &Bridge {
        wsConn: ws,
        clientIP: clientIP,
        tcpHost: tcpHost,
        limiter: limiter,
        closeChan: make(chan struct{}),
    }
}

// Start initializes the bridge

func (b *Bridge) Start() {
    logMsg("Bridge", fmt.Sprintf("[%s] Connecting to %s for client: %s", b.limiter.cfg.name, b.tcpHost, b.clientIP))

    tcpConn, err := net.DialTimeout("tcp", b.tcpHost, 5*time.Second)
    if err != nil {
        logMsg("Error", fmt.Sprintf("Failed to connect to TFS (%s): %v", b.tcpHost, err))
        b.Close()
        return
    }

    // Disable Nagle - critical for real-time game traffic
    if tcp, ok := tcpConn.(*net.TCPConn); ok {
        tcp.SetNoDelay(true)
        tcp.SetKeepAlive(true)
        tcp.SetKeepAlivePeriod(60 * time.Second)
    }

    b.tcpConn = tcpConn
    logMsg("Bridge", fmt.Sprintf("[%s] TCP connected to %s for client: %s", b.limiter.cfg.name, b.tcpHost, b.clientIP))

    // Send PROXYv2 header for real IP forwarding to TFS
    if proxyHeader := b.buildPROXYv2Header(); len(proxyHeader) > 0 {
        if _, err := b.tcpConn.Write(proxyHeader); err != nil {
            logMsg("Error", fmt.Sprintf("Failed to send PROXYv2 header: %v", err))
            b.Close()
            return
        }
        logMsg("Proxy", fmt.Sprintf("[%s] Sent PROXYv2 header for client: %s", b.limiter.cfg.name, b.clientIP))
    }
    // WS → TCP
    // wsReader streams frame data directly via NextReader - zero allocations per packet.
    go func() {
        defer b.Close()
        _, err := io.Copy(b.tcpConn, &wsReader{conn: b.wsConn})
        if err != nil && !b.isClosed() {
            logMsg("WS→TCP Error", fmt.Sprintf("[%s] %s: %v", b.limiter.cfg.name, b.clientIP, err))
        }
    } ()

    // TCP → WS
    // copyTCPtoWS reads complete Tibia packet and sends each as one WS frame,
    // preventing io.Copy's 32KB chunking from splitting packets mid-message.
    go func() {
        defer b.Close()
        err := copyTCPtoWS(b.wsConn, b.tcpConn)
        if err != nil && !b.isClosed() {
            logMsg("TCP→WS Error", fmt.Sprintf("[%s] %s: %v", b.limiter.cfg.name, b.clientIP, err))
        }
    }()
}

// isClosed reports whether the bridge has been closed.

func (b *Bridge) isClosed() bool {
    select {
    case <-b.closeChan:
        return true
    default:
        return false
    }
}

// Close shuts down both sides of the bridge exactly once.
// Closing the connections unblocks any blocked io.Copy immediately.

func (b *Bridge) Close() {
    b.closeOnce.Do(func(){
        close(b.closeChan)

        if b.wsConn != nil {
            b.wsConn.Close()
        }
        if b.tcpConn != nil {
            b.tcpConn.Close()
        }

        b.releaseOnce.Do(func() {
            b.limiter.decConn(b.clientIP)
        })

        logMsg("Bridge", fmt.Sprintf("[%s] Closed connection for client: %s", b.limiter.cfg.name, b.clientIP))
    })
}

// buildPROXYv2Header builds a HAProxy PROXYv2 binary protocol header
// so TFS receives the real client IP rather than 127.0.0.1.

func (b *Bridge) buildPROXYv2Header() []byte {
    if b.clientIP == "" {
        return nil
    }

    ip := net.ParseIP(b.clientIP)
    if ip == nil {
        logMsg("Warning", fmt.Sprintf("Invalid IP: %s", b.clientIP))
        return nil
    }

    ipv4 := ip.To4()
    if ipv4 == nil {
        if strings.Contains(b.clientIP, ":") {
            parts := strings.Split(b.clientIP, ":")
            if len (parts) > 0 {
                ip = net.ParseIP(parts[len(parts)-1])
                if ip != nil {
                    ipv4 = ip.To4()
                }
            }
        }
        if ipv4 == nil {
            logMsg("Warning", fmt.Sprintf("Cannot convert to IPv4: %s", b.clientIP))
            return nil
        }
    }

    header := make([]byte, 28)
    copy(header[0:12], []byte{0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A})
    header[12] = 0x21 // Version 2, PROXY command
    header[13] = 0x11 // AF_INET, STREAM
    binary.BigEndian.PutUint16(header[14:16], 12) // Address block length
    copy(header[16:20], ipv4) // Source IP (real client)
    copy(header[20:24], []byte{0, 0, 0, 0}) // Destination IP (placeholder)
    binary.BigEndian.PutUint16(header[24:26], 0) // Source port (placeholder)

    var dstPort uint16
    if strings.Contains(b.tcpHost, "7171") {
        dstPort = 7171
    } else {
        dstPort = 7172
    }
    binary.BigEndian.PutUint16(header[26:28], dstPort)

    if EnableLogging {
        logMsg("Proxy", fmt.Sprintf("PROXYv2 for %s → IP bytes: %s", b.clientIP, hex.EncodeToString(header[16:20])))
    }

    return header
}

// extractClientIP extracts the real client IP from proxy/CDN headers

func extractClientIP(r *http.Request) string {
    if cfIP := r.Header.Get("CF-Connecting-IP"); cfIP != "" {
        return cfIP
    }
    if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
        ips := strings.Split(xff, ",")
        if len(ips) > 0 {
            return strings.TrimSpace(ips[0])
        }
    }
    if xri := r.Header.Get("X-Real-IP"); xri != "" {
        return xri
    }

    clientIP := r.RemoteAddr
    if strings.Contains(clientIP, ":") {
        if host, _, err := net.SplitHostPort(clientIP); err == nil {
            clientIP = host
        }
    }
    clientIP = strings.TrimPrefix(clientIP, "::ffff:")

    if clientIP == "" || clientIP == "127.0.0.1" || clientIP == "::1" {
        logMsg("Warning", fmt.Sprintf("Using localhost IP: %s", clientIP))
    }

    return clientIP
}

// handleWebSocket upgrades the HTTP connection and starts a bridge.
// NOTE: http.Server ReadTimeout/WriteTimeout must not be set - they apply to
// the raw TCP connection and will kill long-lived WebSocket connections.

func handleWebSocket(tcpHost string, limiter *limiterBucket) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        clientIP := extractClientIP(r)
        logMsg("Server", fmt.Sprintf("[%s] New connection from: %s → %s", limiter.cfg.name, clientIP, tcpHost))

        if !limiter.allowAttempt(clientIP) {
            logMsg("RateLimit", fmt.Sprintf("[%s] Rejected recurring connection from %s", limiter.cfg.name, clientIP))
            http.Error(w, "Too many reconnect attempts", http.StatusTooManyRequests)
            return
        }

        if !limiter.incConn(clientIP) {
            logMsg("RateLimit", fmt.Sprintf("[%s] Rejected connection from %s (current limit %d)", limiter.cfg.name, clientIP, limiter.cfg.maxConnPerIP))
            http.Error(w, "Too many concurrent connections", http.StatusTooManyRequests)
            return
        }

        ws, err := upgrader.Upgrade(w, r, nil)
        if err != nil {
            limiter.decConn(clientIP)
            logMsg("Error", fmt.Sprintf("WebSocket upgrade failed: %v", err))
            return
        }
        ws.EnableWriteCompression(false) // Never compress - adds latency, Tibia data is already packed
        ws.SetReadLimit(327680) // Match client SEND_BUFFER_SIZE to avoid false read-limit errors

        bridge := NewBridge(ws, clientIP, tcpHost, limiter)
        bridge.Start()
    }
}

func logMsg(tag, message string) {
    if EnableLogging {
        log.Printf("[%s] [%s] [%s]\n", time.Now().Format("2006-01-02 15:04:05.000"), tag, message)
    }
}

func main() {
    go loginLimiter.cleanupLoop()
    go gameLimiter.cleanupLoop()
    // IMPORTANT: No ReadTimeout or WriteTimeout on the http.Server.
    // These are applied to the raw net.Conn underneath, which kills WebSocket
    // connections after the timeout regardless of activity on the WS layer.
    gameServer := &http.Server{
        Addr: GameWsPort,
        Handler: handleWebSocket(GameTcpHost, gameLimiter),
    }

    loginServer := &http.Server{
        Addr: LoginWsPort,
        Handler: handleWebSocket(LoginTcpHost, loginLimiter),
    }

    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
    go func() {
        <-sigChan
        logMsg("Server", "Shutting down...")
        gameServer.Close()
        loginServer.Close()
        os.Exit(0)
    }()

    logMsg("Server", "OTClient WebSocket Bridge started")
    logMsg("Server", fmt.Sprintf("Game ws://0.0.0.0%s → %s", GameWsPort, GameTcpHost))
    logMsg("Server", fmt.Sprintf("Login ws://0.0.0.0%s → %s", LoginWsPort, LoginTcpHost))
    logMsg("Server", "Real IP injection: ENABLED (PROXYv2)")

    logMsg("Server", fmt.Sprintf("[login] concurrent=%d attempt=%d/%s min-gap=%s", LoginMaxConnPerIP, LoginMaxAttemptsPerIP, LoginAttemptWindow, LoginMinReconnectGap))
    logMsg("Server", fmt.Sprintf("[game] concurrent=%d attempts=%d/%s min-gap=%s", GameMaxConnPerIP, GameMaxAttemptsPerIP, GameAttemptWindow, GameMinReconnectGap))

    go func() {
        if err := loginServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatal("Login server error:", err)
        }
    }()

    if err := gameServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        log.Fatal("Game server error:", err)
    }
}

Compiling is the same as explained in the original thread above.

This setup uses HAProxy for additional filtering and connection handling. Below is a basic example of how it can be installed and configured.

  • Open your status server
  • apt-get install haproxy -y
  • nano /etc/haproxy/haproxy.cfg

and add the following configuration:
Code:
# ----------------------------
# Login connections (TCP)
# ----------------------------
listen stat
    bind 0.0.0.0:7171
    mode tcp
    balance roundrobin

    stick-table type ip size 1m expire 30s store conn_rate(10s),conn_cur,sess_rate(10s)

    tcp-request connection track-sc0 src

    # hard reject obvious abuse
    tcp-request connection reject if { sc_conn_rate(0) gt 20 }
    tcp-request connection reject if { sc_conn_cur(0) gt 8 }
    tcp-request connection reject if { sc_sess_rate(0) gt 20 }

    server srv10 YourhostIP:7050 check inter 2s fall 3 rise 2 maxconn 300

Then press CTRL + X, press "Y" and hit Enter to save.

  • Open your main host server
  • nano /etc/haproxy/haproxy.cfg

Code:
listen login
    bind 0.0.0.0:7050
    mode tcp
    server srv2 127.0.0.1:7171
    maxconn 500            # adjust per expected login traffic


Then press CTRL + X, press "Y" and hit Enter to save.

service haproxy restart

OTClient changes:

In your ‎framework/net/connection.cpp
Change your Connection::handleError function to this one below

C++:
void Connection::handleError(const boost::system::error_code &error)
{
    if (error == asio::error::operation_aborted)
        return;


    auto self = asConnection();

    m_error = error;
    if (m_errorCallback)
        m_errorCallback(error);
    if (m_connected || m_connecting)
    if(!self)
        return;
    if(m_connected || m_connecting)
        close();
}

In your ‎framework/net/protocol.cpp
Change your Protocol::disconnect() function to this

C++:
void Protocol::disconnect(bool localRequest)
{
    if (!m_disconnected) {
        g_logger.info(stdext::format("Protocol disconnect initiated by %s (%s)",
            localRequest ? "client/local code" : "server/peer or transport",
            m_wsConnection ? "websocket" : "tcp"));
    }

    m_disconnected = true;
    m_wsFrameBuffer.clear();
    m_wsFrameOffset = 0;

    if (m_player) {
        m_player->stop();
        m_player.reset();
        return;
    }
    if (m_proxy)
    {
        g_proxy.removeSession(m_proxy);
        m_proxy = 0;
        return;
    }
    if (m_wsConnection) {
        m_wsConnection->close();
        m_wsConnection.reset();
    }
    if (m_connection) {
        m_connection->close();
        m_connection.reset();
    }
}

and remove the following part added previously on this thread

C++:
            // If we consumed the whole frame, reset so recv() fetches a new one
            if (m_wsFrameOffset >= m_wsFrameBuffer.size()) {
                m_wsFrameBuffer.clear();
                m_wsFrameOffset = 0;
            }

and change your Protocol::onError function to this

C++:
void Protocol::onError(const boost::system::error_code &err)
{
    g_logger.warning(stdext::format("Protocol transport error (%s): %s (%d)",
        m_wsConnection ? "websocket" : "tcp",
        err.message(),
        err.value()));
    callLuaField("onError", err.message(), err.value());
    disconnect(false);
}

in ‎framework/net/protocol.h replace void disconnect(); with this void disconnect(bool localRequest = true);

Now replace both your previously added websocket_connection.cpp and websocket_connection.h files on this thread with the new uploaded .zip file attached below.

Then in your server/config.lua change bindOnlyGlobalAddress = false to bindOnlyGlobalAddress = true and you are ready to go.

Virus scan:
 

Attachments

Last edited:
I have got my hands on an upgraded version of the websocket tunnel posted above, featuring several security and stability improvements such as limits/restrictions for max connections, packets and IPs, along with localhost-only login support.

Features:
  • Localhost-only login support
  • IP/connection/packet restrictions
  • Improved websocket stability
  • Better reconnect handling
  • Reduced allocation/GC pressure
  • Proper websocket cleanup
  • HAProxy integration
  • Real IP forwarding (PROXY Protocol v2)
Most of the improvements below focus on websocket connection handling, packet forwarding consistency and reducing unnecessary allocations under heavy load.

Updated tunnel source:
Code:
package main

import (
    "encoding/binary"
    "encoding/hex"
    "fmt"
    "io"
    "log"
    "net"
    "net/http"
    "os"
    "os/signal"
    "strings"
    "sync"
    "sync/atomic"
    "syscall"
    "time"

    "github.com/gorilla/websocket"
)

// Configuration - separate ports for game and login

const (
    GameWsPort  = ":8080" // WebSocket port for game server
    LoginWsPort = ":8880" // WebSocket Port for login server
    LoginTcpHost = "127.0.0.1:7171" // TFS login server
    GameTcpHost = "127.0.0.1:7172" // TFS login server
    EnableLogging = false
    BufferSize = 327680

    // Login limiter (stricter)
    LoginMaxConnPerIP = 1000
    LoginMaxAttemptsPerIP = 1000
    LoginAttemptWindow = 30 * time.Second
    LoginMinReconnectGap = 1000 * time.Millisecond

    // Game limiter (looser, safer for normal play)
    GameMaxConnPerIP = 1000
    GameMaxAttemptsPerIP = 1000
    GameAttemptWindow = 30 * time.Second
    GameMinReconnectGap = 50 * time.Millisecond

    IPStateIdleExpiry = 1 * time.Minute
)

var upgrader = websocket.Upgrader{
    ReadBufferSize: BufferSize,
    WriteBufferSize: BufferSize,
    CheckOrigin: func(r *http.Request) bool {
        return true
    },
}

type ipState struct {
    active int32
    lastSeenUnix int64

    mu sync.Mutex
    windowStart time.Time
    attempts int
    lastAttempt time.Time
}

type limiterConfig struct {
    name string
    maxConnPerIP int32
    maxAttemptsPerIP int
    attemptWindow time.Duration
    minReconnectGap time.Duration
}

type limiterBucket struct {
    cfg limiterConfig
    states sync.Map // map[string]*ipState
}

var loginLimiter = &limiterBucket{
    cfg: limiterConfig{
        name: "login",
        maxConnPerIP: LoginMaxConnPerIP,
        maxAttemptsPerIP: LoginMaxAttemptsPerIP,
        attemptWindow: LoginAttemptWindow,
        minReconnectGap: LoginMinReconnectGap,
    },
}

var gameLimiter = &limiterBucket{
    cfg: limiterConfig{
        name: "game",
        maxConnPerIP: GameMaxConnPerIP,
        maxAttemptsPerIP: GameMaxAttemptsPerIP,
        attemptWindow: GameAttemptWindow,
        minReconnectGap: GameMinReconnectGap,
    },
}


// packetBufPool recycles packet body buffers across connections.
// At 5k connections each buffer is up to 65535 bytes; pooling avoids
// constant GC pressure from per-packet allocations in copyTCPtoWS

var packetBufPool = sync.Pool{
    New: func() any {
        buf := make([]byte, 65535)
        return &buf
    },
}

func (b *limiterBucket) getIPState(ip string) *ipState {
    v, _ := b.states.LoadOrStore(ip, &ipState{
        lastSeenUnix: time.Now().Unix(),
    })
    st := v.(*ipState)
    atomic.StoreInt64(&st.lastSeenUnix, time.Now().Unix())
    return st
}

func (b *limiterBucket) allowAttempt(ip string) bool {
    if ip == "" {
        return false
    }

    now := time.Now()
    st := b.getIPState(ip)

    st.mu.Lock()
    defer st.mu.Unlock()

    if !st.lastAttempt.IsZero() && now.Sub(st.lastAttempt) < b.cfg.minReconnectGap {
        st.lastAttempt = now
        return false
    }

    if st.windowStart.IsZero() || now.Sub(st.windowStart) >= b.cfg.attemptWindow {
        st.windowStart = now
        st.attempts = 0
    }

    st.attempts++
    st.lastAttempt = now

    return st.attempts <= b.cfg.maxAttemptsPerIP
}

func (b *limiterBucket) incConn(ip string) bool {
    if ip == "" {
        return false
    }

    st := b.getIPState(ip)
    n := atomic.AddInt32(&st.active, 1)
    if n > b.cfg.maxConnPerIP {
        atomic.AddInt32(&st.active, -1)
        return false
    }
    return true
}

func (b *limiterBucket) decConn(ip string) {
    if ip == "" {
        return
    }

    v, ok := b.states.Load(ip)
    if !ok{
        return
    }

    st:= v.(*ipState)
    n := atomic.AddInt32(&st.active, -1)
    if n < 0 {
        atomic.StoreInt32(&st.active, 0)
    }
    atomic.StoreInt64(&st.lastSeenUnix, time.Now().Unix())
}

func (b *limiterBucket) cleanupLoop() {
    ticker := time.NewTicker(1 * time.Minute)
    defer ticker.Stop()

    for range ticker.C {
        nowUnix := time.Now().Unix()

        b.states.Range(func(key, value any) bool {
            st := value.(*ipState)
            active := atomic.LoadInt32(&st.active)
            lastSeen := atomic.LoadInt64(&st.lastSeenUnix)

            if active == 0 && time.Duration(nowUnix-lastSeen)*time.Second > IPStateIdleExpiry {
                b.states.Delete(key)
            }
            return true
        })
    }
}

// wsReader wraps a WebSocket connection and implements io.Reader.
//
// Uses NextReader instead of ReadMessage to avoid per-packet allocations:
// NextReader returns an io.Reader over the raw frame data, so io.Copy
// pulls bytes directly from the WebSocket frame into the TCP write buffer
// with zero extra allocation.
//
// gorilla allows exactly one concurrent reader - safe here as this is
// only used from the WS -> TCP goroutine.

type wsReader struct {
    conn *websocket.Conn
    reader io.Reader // current frame reader, nil when between frames
}

func (r *wsReader) Read(p []byte) (int, error) {
    for {
        // If we have an active frame reader, drain it first
        if r.reader != nil {
            n, err := r.reader.Read(p)
            if err == io.EOF {
                // Frame fully consumed, clear and loop to get next frame
                r.reader = nil
                if n > 0 {
                    return n, nil
                    }
                    continue
                }
                return n, err
            }

            // No active frame - get the next one
            messageType, reader, err := r.conn.NextReader()
            if err != nil {
                return 0, err
            }
            if messageType != websocket.BinaryMessage {
             // Skip non-binary frames; ping/pong handled internally by gorilla
             continue
        }
        r.reader = reader
    }
}

// copyTCPtoWS reads complete Tibia packets from TCP and forwards each as a
// single WebSocket binary frame. This prevents io.Copy's internal 32KB buffer
// from splitting a packet across multiple frames, which would corrupt the
// client's parser if it expects one frame = one complete message.
//
// Tibia packet layout: [len_lo][len_hi][...payload of len bytes...]
// The 2-byte header is included in the forwarded frame so the client receives
// exactly what TFS sent, with the same framing it already expects.
//
// Body buffers are recycled via packetBufPool - at 5k connections this keeps
// allocations off the hot path and reduces GC pressure significantly.

func copyTCPtoWS(wsConn *websocket.Conn, tcpConn net.Conn) error {
    var headerBuf [2]byte

    for {
        // Read the 2-byte little-endian Tibia packet length
        if _, err := io.ReadFull(tcpConn, headerBuf[:]); err != nil {
            return err
        }

        packetLen := binary.LittleEndian.Uint16(headerBuf[:])
        if packetLen == 0 {
            continue
        }

        // Borrow a buffer from the pool; grow only when the packet is larger
        // than the pooled slice (rare - Tibia packets are almost always <8KB)
        bufPtr := packetBufPool.Get().(*[]byte)
        if int(packetLen) > len(*bufPtr) {
            *bufPtr = make([]byte, packetLen)
        }

        body := (*bufPtr)[:packetLen]

        // Read exactly packetLen bytes - one complete Tibia packet body
        if _, err := io.ReadFull(tcpConn, body); err != nil {
            packetBufPool.Put(bufPtr)
            return err
        }
        // Build the complete frame: [len_lo, len_hi, ...body...]
        // Allocating a fresh frame slice here is intentional: WriteMessage holds
        // a reference to the slice until the write completes, so we cannot reuse
        // the pool buffer without a copy anyway.
        frame := make([]byte, 2+int(packetLen))
        copy(frame[0:2], headerBuf[:])
        copy(frame[2:], body)

        packetBufPool.Put(bufPtr)

        // One complete Tibia packet = one WebSocket frame, always
        if err := wsConn.WriteMessage(websocket.BinaryMessage, frame); err != nil {
            return err
        }
    }
}

// Bridge represents a connection bridge between WebSocket client and TCP server

type Bridge struct {
    wsConn *websocket.Conn
    tcpConn net.Conn
    clientIP string
    tcpHost string
    limiter *limiterBucket
    closeChan chan struct{}
    closeOnce sync.Once
    releaseOnce sync.Once
}

// NewBridge creates a new bridge instance
func NewBridge(ws *websocket.Conn, clientIP string, tcpHost string, limiter *limiterBucket) *Bridge {
    return &Bridge {
        wsConn: ws,
        clientIP: clientIP,
        tcpHost: tcpHost,
        limiter: limiter,
        closeChan: make(chan struct{}),
    }
}

// Start initializes the bridge

func (b *Bridge) Start() {
    logMsg("Bridge", fmt.Sprintf("[%s] Connecting to %s for client: %s", b.limiter.cfg.name, b.tcpHost, b.clientIP))

    tcpConn, err := net.DialTimeout("tcp", b.tcpHost, 5*time.Second)
    if err != nil {
        logMsg("Error", fmt.Sprintf("Failed to connect to TFS (%s): %v", b.tcpHost, err))
        b.Close()
        return
    }

    // Disable Nagle - critical for real-time game traffic
    if tcp, ok := tcpConn.(*net.TCPConn); ok {
        tcp.SetNoDelay(true)
        tcp.SetKeepAlive(true)
        tcp.SetKeepAlivePeriod(60 * time.Second)
    }

    b.tcpConn = tcpConn
    logMsg("Bridge", fmt.Sprintf("[%s] TCP connected to %s for client: %s", b.limiter.cfg.name, b.tcpHost, b.clientIP))

    // Send PROXYv2 header for real IP forwarding to TFS
    if proxyHeader := b.buildPROXYv2Header(); len(proxyHeader) > 0 {
        if _, err := b.tcpConn.Write(proxyHeader); err != nil {
            logMsg("Error", fmt.Sprintf("Failed to send PROXYv2 header: %v", err))
            b.Close()
            return
        }
        logMsg("Proxy", fmt.Sprintf("[%s] Sent PROXYv2 header for client: %s", b.limiter.cfg.name, b.clientIP))
    }
    // WS → TCP
    // wsReader streams frame data directly via NextReader - zero allocations per packet.
    go func() {
        defer b.Close()
        _, err := io.Copy(b.tcpConn, &wsReader{conn: b.wsConn})
        if err != nil && !b.isClosed() {
            logMsg("WS→TCP Error", fmt.Sprintf("[%s] %s: %v", b.limiter.cfg.name, b.clientIP, err))
        }
    } ()

    // TCP → WS
    // copyTCPtoWS reads complete Tibia packet and sends each as one WS frame,
    // preventing io.Copy's 32KB chunking from splitting packets mid-message.
    go func() {
        defer b.Close()
        err := copyTCPtoWS(b.wsConn, b.tcpConn)
        if err != nil && !b.isClosed() {
            logMsg("TCP→WS Error", fmt.Sprintf("[%s] %s: %v", b.limiter.cfg.name, b.clientIP, err))
        }
    }()
}

// isClosed reports whether the bridge has been closed.

func (b *Bridge) isClosed() bool {
    select {
    case <-b.closeChan:
        return true
    default:
        return false
    }
}

// Close shuts down both sides of the bridge exactly once.
// Closing the connections unblocks any blocked io.Copy immediately.

func (b *Bridge) Close() {
    b.closeOnce.Do(func(){
        close(b.closeChan)

        if b.wsConn != nil {
            b.wsConn.Close()
        }
        if b.tcpConn != nil {
            b.tcpConn.Close()
        }

        b.releaseOnce.Do(func() {
            b.limiter.decConn(b.clientIP)
        })

        logMsg("Bridge", fmt.Sprintf("[%s] Closed connection for client: %s", b.limiter.cfg.name, b.clientIP))
    })
}

// buildPROXYv2Header builds a HAProxy PROXYv2 binary protocol header
// so TFS receives the real client IP rather than 127.0.0.1.

func (b *Bridge) buildPROXYv2Header() []byte {
    if b.clientIP == "" {
        return nil
    }

    ip := net.ParseIP(b.clientIP)
    if ip == nil {
        logMsg("Warning", fmt.Sprintf("Invalid IP: %s", b.clientIP))
        return nil
    }

    ipv4 := ip.To4()
    if ipv4 == nil {
        if strings.Contains(b.clientIP, ":") {
            parts := strings.Split(b.clientIP, ":")
            if len (parts) > 0 {
                ip = net.ParseIP(parts[len(parts)-1])
                if ip != nil {
                    ipv4 = ip.To4()
                }
            }
        }
        if ipv4 == nil {
            logMsg("Warning", fmt.Sprintf("Cannot convert to IPv4: %s", b.clientIP))
            return nil
        }
    }

    header := make([]byte, 28)
    copy(header[0:12], []byte{0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A})
    header[12] = 0x21 // Version 2, PROXY command
    header[13] = 0x11 // AF_INET, STREAM
    binary.BigEndian.PutUint16(header[14:16], 12) // Address block length
    copy(header[16:20], ipv4) // Source IP (real client)
    copy(header[20:24], []byte{0, 0, 0, 0}) // Destination IP (placeholder)
    binary.BigEndian.PutUint16(header[24:26], 0) // Source port (placeholder)

    var dstPort uint16
    if strings.Contains(b.tcpHost, "7171") {
        dstPort = 7171
    } else {
        dstPort = 7172
    }
    binary.BigEndian.PutUint16(header[26:28], dstPort)

    if EnableLogging {
        logMsg("Proxy", fmt.Sprintf("PROXYv2 for %s → IP bytes: %s", b.clientIP, hex.EncodeToString(header[16:20])))
    }

    return header
}

// extractClientIP extracts the real client IP from proxy/CDN headers

func extractClientIP(r *http.Request) string {
    if cfIP := r.Header.Get("CF-Connecting-IP"); cfIP != "" {
        return cfIP
    }
    if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
        ips := strings.Split(xff, ",")
        if len(ips) > 0 {
            return strings.TrimSpace(ips[0])
        }
    }
    if xri := r.Header.Get("X-Real-IP"); xri != "" {
        return xri
    }

    clientIP := r.RemoteAddr
    if strings.Contains(clientIP, ":") {
        if host, _, err := net.SplitHostPort(clientIP); err == nil {
            clientIP = host
        }
    }
    clientIP = strings.TrimPrefix(clientIP, "::ffff:")

    if clientIP == "" || clientIP == "127.0.0.1" || clientIP == "::1" {
        logMsg("Warning", fmt.Sprintf("Using localhost IP: %s", clientIP))
    }

    return clientIP
}

// handleWebSocket upgrades the HTTP connection and starts a bridge.
// NOTE: http.Server ReadTimeout/WriteTimeout must not be set - they apply to
// the raw TCP connection and will kill long-lived WebSocket connections.

func handleWebSocket(tcpHost string, limiter *limiterBucket) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        clientIP := extractClientIP(r)
        logMsg("Server", fmt.Sprintf("[%s] New connection from: %s → %s", limiter.cfg.name, clientIP, tcpHost))

        if !limiter.allowAttempt(clientIP) {
            logMsg("RateLimit", fmt.Sprintf("[%s] Rejected recurring connection from %s", limiter.cfg.name, clientIP))
            http.Error(w, "Too many reconnect attempts", http.StatusTooManyRequests)
            return
        }

        if !limiter.incConn(clientIP) {
            logMsg("RateLimit", fmt.Sprintf("[%s] Rejected connection from %s (current limit %d)", limiter.cfg.name, clientIP, limiter.cfg.maxConnPerIP))
            http.Error(w, "Too many concurrent connections", http.StatusTooManyRequests)
            return
        }

        ws, err := upgrader.Upgrade(w, r, nil)
        if err != nil {
            limiter.decConn(clientIP)
            logMsg("Error", fmt.Sprintf("WebSocket upgrade failed: %v", err))
            return
        }
        ws.EnableWriteCompression(false) // Never compress - adds latency, Tibia data is already packed
        ws.SetReadLimit(327680) // Match client SEND_BUFFER_SIZE to avoid false read-limit errors

        bridge := NewBridge(ws, clientIP, tcpHost, limiter)
        bridge.Start()
    }
}

func logMsg(tag, message string) {
    if EnableLogging {
        log.Printf("[%s] [%s] [%s]\n", time.Now().Format("2006-01-02 15:04:05.000"), tag, message)
    }
}

func main() {
    go loginLimiter.cleanupLoop()
    go gameLimiter.cleanupLoop()
    // IMPORTANT: No ReadTimeout or WriteTimeout on the http.Server.
    // These are applied to the raw net.Conn underneath, which kills WebSocket
    // connections after the timeout regardless of activity on the WS layer.
    gameServer := &http.Server{
        Addr: GameWsPort,
        Handler: handleWebSocket(GameTcpHost, gameLimiter),
    }

    loginServer := &http.Server{
        Addr: LoginWsPort,
        Handler: handleWebSocket(LoginTcpHost, loginLimiter),
    }

    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
    go func() {
        <-sigChan
        logMsg("Server", "Shutting down...")
        gameServer.Close()
        loginServer.Close()
        os.Exit(0)
    }()

    logMsg("Server", "OTClient WebSocket Bridge started")
    logMsg("Server", fmt.Sprintf("Game ws://0.0.0.0%s → %s", GameWsPort, GameTcpHost))
    logMsg("Server", fmt.Sprintf("Login ws://0.0.0.0%s → %s", LoginWsPort, LoginTcpHost))
    logMsg("Server", "Real IP injection: ENABLED (PROXYv2)")

    logMsg("Server", fmt.Sprintf("[login] concurrent=%d attempt=%d/%s min-gap=%s", LoginMaxConnPerIP, LoginMaxAttemptsPerIP, LoginAttemptWindow, LoginMinReconnectGap))
    logMsg("Server", fmt.Sprintf("[game] concurrent=%d attempts=%d/%s min-gap=%s", GameMaxConnPerIP, GameMaxAttemptsPerIP, GameAttemptWindow, GameMinReconnectGap))

    go func() {
        if err := loginServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatal("Login server error:", err)
        }
    }()

    if err := gameServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        log.Fatal("Game server error:", err)
    }
}

Compiling is the same as explained in the original thread above.

This setup uses HAProxy for additional filtering and connection handling. Below is a basic example of how it can be installed and configured.

  • Open your status server
  • apt-get install haproxy -y
  • nano /etc/haproxy/haproxy.cfg

and add the following configuration:
Code:
# ----------------------------
# Login connections (TCP)
# ----------------------------
listen stat
    bind 0.0.0.0:7171
    mode tcp
    balance roundrobin

    stick-table type ip size 1m expire 30s store conn_rate(10s),conn_cur,sess_rate(10s)

    tcp-request connection track-sc0 src

    # hard reject obvious abuse
    tcp-request connection reject if { sc_conn_rate(0) gt 20 }
    tcp-request connection reject if { sc_conn_cur(0) gt 8 }
    tcp-request connection reject if { sc_sess_rate(0) gt 20 }

    server srv10 YourhostIP:7050 check inter 2s fall 3 rise 2 maxconn 300

Then press CTRL + X, press "Y" and hit Enter to save.

  • Open your main host server
  • nano /etc/haproxy/haproxy.cfg

Code:
listen login
    bind 0.0.0.0:7050
    mode tcp
    server srv2 127.0.0.1:7171
    maxconn 500            # adjust per expected login traffic


Then press CTRL + X, press "Y" and hit Enter to save.

service haproxy restart

OTClient changes:

In your ‎framework/net/connection.cpp
Change your Connection::handleError function to this one below

C++:
void Connection::handleError(const boost::system::error_code &error)
{
    if (error == asio::error::operation_aborted)
        return;


    auto self = asConnection();

    m_error = error;
    if (m_errorCallback)
        m_errorCallback(error);
    if (m_connected || m_connecting)
    if(!self)
        return;
    if(m_connected || m_connecting)
        close();
}

In your ‎framework/net/protocol.cpp
Change your Protocol::disconnect() function to this

C++:
void Protocol::disconnect(bool localRequest)
{
    if (!m_disconnected) {
        g_logger.info(stdext::format("Protocol disconnect initiated by %s (%s)",
            localRequest ? "client/local code" : "server/peer or transport",
            m_wsConnection ? "websocket" : "tcp"));
    }

    m_disconnected = true;
    m_wsFrameBuffer.clear();
    m_wsFrameOffset = 0;

    if (m_player) {
        m_player->stop();
        m_player.reset();
        return;
    }
    if (m_proxy)
    {
        g_proxy.removeSession(m_proxy);
        m_proxy = 0;
        return;
    }
    if (m_wsConnection) {
        m_wsConnection->close();
        m_wsConnection.reset();
    }
    if (m_connection) {
        m_connection->close();
        m_connection.reset();
    }
}

and remove the following part added previously on this thread

C++:
            // If we consumed the whole frame, reset so recv() fetches a new one
            if (m_wsFrameOffset >= m_wsFrameBuffer.size()) {
                m_wsFrameBuffer.clear();
                m_wsFrameOffset = 0;
            }

and change your Protocol::onError function to this

C++:
void Protocol::onError(const boost::system::error_code &err)
{
    g_logger.warning(stdext::format("Protocol transport error (%s): %s (%d)",
        m_wsConnection ? "websocket" : "tcp",
        err.message(),
        err.value()));
    callLuaField("onError", err.message(), err.value());
    disconnect(false);
}

in ‎framework/net/protocol.h replace void disconnect(); with this void disconnect(bool localRequest = true);

Now replace both your websocket_connection.cpp and websocket_connection.h added previously on this thread with the new uploaded ones here.

Then in your server/config.lua change bindOnlyGlobalAddress = false to bindOnlyGlobalAddress = true and you are ready to go.
Interesting changes! I will definitely try this version out as well.

Thank you for the release of this system, Raw tried to take down the server on my launch day but coudn't do shit, not even an single lag spike, awesome, thanks so much for sharing this.

Best regards
 
Nekiro was far from the first guy who came up with this idea. This was used in Tibiabase and Exoria like a year ago. Me, Cblade, Neptuno and Michael were experimenting with it. I realized this was a bad approach because of how these sockets could randomly route your players to locations that would not only give them bad ping but also timeout the connection due keep-alive or w/e.

Cblade recently found a way to eliminate those issues.
 
Last edited:
Nekiro was far from the first guy who came up with this idea. This was used in Tibiabase and Exoria like a year ago. Me, Cblade, Neptuno and Michael were experimenting with it. I realized this was a bad approach because of how these sockets could randomly route your players to locations that would not only give them bad ping but also timeout the connection due keep-alive or w/e.

Cblade recently found a way to eliminate those issues.
Not everything released for free is guaranteed to work 100% with every system.

This setup is much deeper than just making the client work with WebSocket. The post mainly explains the client-side changes and the WebSocket bridge, but it does not properly explain the full Cloudflare-side setup.
Also, mentioning @Nekiro alone is not the right way to give credit to the other contributors, with all respect to Nekiro.
It was first tested as well in Natala-OT me and CBlade Discovered a lot of issues but it was managed to be fixed and upgraded as well In Evolisca-OT.
 
Last edited:
Not everything released for free is guaranteed to work 100% with every system.

This setup is much deeper than just making the client work with WebSocket. The post mainly explains the client-side changes and the WebSocket bridge, but it does not properly explain the full Cloudflare-side setup.
Also, mentioning @Nekiro alone is not the right way to give credit to the other contributor, with all respect to Nekiro.
It was first tested as well in Natala-OT at me and CBlade Discovered a lot of issues but it was managed to be fixed and upgraded as well In Evolisca-OT.
Well I prefer it not to work eitherway. From my understanding most of these people were a team. Something happend, people got angry and leaked Cblades work.

I dont always agree with Cblades approach to things, he has his ways to deal with some situations that could be dealt with more delicately but in this particular situation I would also be upset. He got leaked and thats not a small "thing" to get over in a day. But he continues. The grown up thing to do for all parties would have been to part on peaceful terms instead of this fuzz.
 
I don't like to start a beef, but it's not the code that is credited, but the idea (which also Niebieski contributed quite a bit to this). Code is probably AI generated like most things nowadays, the idea was provided by me in development channel on OTA discord server, which enabled cblade to create this. I don't claim any credit to this code, because it's not my code. I have my own implementation.

My original idea was to use kondrah proxy + websockets as backups (which I successfully do so, since like November 2025 on my server). And afaik the first live server that trully tested this in production was Niebieski's current server. (purposely left out the names to avoid people saying I advertise things)
 
I don't like to start a beef, but it's not the code that is credited, but the idea (which also Niebieski contributed quite a bit to this). Code is probably AI generated like most things nowadays, the idea was provided by me in development channel on OTA discord server, which enabled cblade to create this. I don't claim any credit to this code, because it's not my code. I have my own implementation.

My original idea was to use kondrah proxy + websockets as backups (which I successfully do so, since like November 2025 on my server). And afaik the first live server that trully tested this in production was Niebieski's current server.
Thats my point. We used cloudflare and anf websockets a year ago. But the way you guys or Cblade solved it was actually genious. Because it resolves even the issues that prevented me at that time. Im not sure who came with the idea of multi socker connection. But that didnt occur to me after seeing the issue of one socket connection at the time
 
Back
Top