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:
Here's a simple guide on how to compile and use it:
Open CMD inside the folder containing the tunnel files, then run:
This will generate the compiled binary for your server.

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
add this
below
add this
Now declare the connection in:
client/src/framework/net/declarations.h
like this:
below
add this
then below
add this
Then compile OTCv8.
Login protocol changes:
In client/modules/gamelib/protocollogin.lua below
then find function
Enter Game Changes:
In client/modules/client_entergame/entergame.lua
Repalce this:
With this:
Note: Change
We are still in entergame.lua after this:
add this:
In client/init.lua use
In server/config.lua use
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:
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.
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.

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 = hostthen 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:7171In server/config.lua use
127.0.0.1Then 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
-
websocket_connection.rar6.5 KB · Views: 78 · VirusTotal
Last edited:


