• 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!

Cloudflare WebSocket Tunnel - Free for the Open Tibia community

Some replies are turning this into bedtime stories and random drama instead of focusing on the actual topic of the thread, and half of these stories only God knows if they are even true or not. The goal of this thread was simply to share something useful for the community. Many people are already testing it, improving it and finding it helpful, which is what really matters. If anyone has real feedback, fixes or test results, feel free to share them.

The upgraded version posted here already addresses several of the concerns mentioned regarding websocket handling, reconnects, cleanup and connection stability: Cloudflare WebSocket Tunnel - Free for the Open Tibia community (https://otland.net/threads/cloudflare-websocket-tunnel-free-for-the-open-tibia-community.304390/post-2802165)
 
I’m running some tests connecting through WS, following the “upgraded version” by Aizendazai.

One issue I’m facing is some random disconnections, as shown in the log below:
Bash:
[2026-05-28 18:09:48.929] [WS→TCP Error] [[game] 192.168.45.210: readfrom tcp 127.0.0.1:60214->127.0.0.1:7172: read tcp 104.26.14.87:2053->198.51.100.42:11873: read: connection reset by peer]
[2026-05-28 18:09:48.929] [Bridge] [[game] Closed connection for client: 192.168.45.210]
[2026-05-28 18:13:54.551] [WS→TCP Error] [[game] 192.168.45.210: readfrom tcp 127.0.0.1:48802->127.0.0.1:7172: websocket: close 1006 (abnormal closure): unexpected EOF]
[2026-05-28 18:13:54.551] [Bridge] [[game] Closed connection for client: 192.168.45.210]


I currently have “anti-idle” enabled on 3 clients (using OTCv8-OTA). I suspect some default Cloudflare timeout might be causing the disconnections. I’ll keep monitoring it and will post again as soon as I have more information.

In my test environment, I’m not using HAProxy, since I already have Real IP injection: ENABLED (PROXYv2).

I’m using a single VPS to run everything (OVH).
 
Last edited:
I’m running some tests connecting through WS, following the “upgraded version” by Aizendazai.

One issue I’m facing is some random disconnections, as shown in the log below:
Bash:
[2026-05-28 18:09:48.929] [WS→TCP Error] [[game] 192.168.45.210: readfrom tcp 127.0.0.1:60214->127.0.0.1:7172: read tcp 104.26.14.87:2053->198.51.100.42:11873: read: connection reset by peer]
[2026-05-28 18:09:48.929] [Bridge] [[game] Closed connection for client: 192.168.45.210]
[2026-05-28 18:13:54.551] [WS→TCP Error] [[game] 192.168.45.210: readfrom tcp 127.0.0.1:48802->127.0.0.1:7172: websocket: close 1006 (abnormal closure): unexpected EOF]
[2026-05-28 18:13:54.551] [Bridge] [[game] Closed connection for client: 192.168.45.210]


I currently have “anti-idle” enabled on 3 clients (using OTCv8-OTA). I suspect some default Cloudflare timeout might be causing the disconnections. I’ll keep monitoring it and will post again as soon as I have more information.

In my test environment, I’m not using HAProxy, since I already have Real IP injection: ENABLED (PROXYv2).

I’m using a single VPS to run everything (OVH).
did you make it to work properly without ping spikes?
 
made some changes 100% vibe coded tho ;p but made great changes for me, maybe can be good for you also who knows

New features:
- PROXY v2 support — upgraded the tunnel to send HAProxy PROXY v2 headers with the real player IP, and added parsing
in TFS. IP banning now works correctly through the tunnel instead of always seeing 127.0.0.1
- Session resumption — when Cloudflare rotates edge servers the connection drops. Added a token system: TFS generates
a session token on login and sends it to the client, if the connection drops the client silently reconnects using the
token instead of full re-authentication. Players never notice <-- im testing and bugging still much work on this
- Multi-port fallback — client automatically cycles through 3 ports on reconnect failure instead of
just giving up
if any1 need tips or need codes, im glad to help
 
Last edited:
Okay, after many hours of testing and banging my head against the wall, here’s a summary of the changes that made the setup significantly more stable and reliable for me.

-- Client side:** OTClient (OTCv8) already has a WebSocket transport in its network layer. Point it at your domain over a standard port. The game protocol rides inside the WS frames
-- Server side:** a tiny bridge. I used Go with gorilla/websocket: accept the WS connection, dial a plain TCP socket to TFS, copy bytes both directions. A few hundred lines, one binary, autostart on boot.
-- Cloudflare:** point the game subdomain at the VPS, turn the cloud orange, done. WS proxying is on.
-- Keep the real client IP:** the bridge speaks PROXY protocol v2 to TFS (TFS has proxyProtocol = true), using Cloudflare's CF-Connecting-IP header. So IP bans, logging and anti-multi-client still see the real player IP, not Cloudflare's.

The part nobody tells you about is making it stable — that's where I lost the most time, so here are the gotchas for me :D

## The gotchas that actually matter

These four are the difference between "it connects" and "it's kinda solid i think with ARGO from cloudflare it could be better"

### 1. Force the client to IPv4

Cloudflare always publishes an AAAA (IPv6) record now, and you cannot turn that off zone-side anymore. If your client's resolver races IPv6 vs IPv4, you get reconnect storms — connections flapping. Force the client's resolver to IPv4-only. This was the single biggest stability win. (And no, "Pseudo IPv4" does not help — it only rewrites an IP header, it doesn't change the transport.)

### 2. Raise TFS's connection timeout

TFS closes a socket if it sees no data for ~30 seconds (the default read/write timeout). On raw TCP that's fine. But the Cloudflare WS path buffers/stalls traffic in short bursts, especially when a player is standing still and only sparse keepalive pings flow. That transient gap can cross 30s → TFS kills a connection that is actually alive → drop → reconnect.

Raise the timeout to 60s. I matched it to pzLocked (60s) on purpose, so a combat disconnect stays in-world exactly as long as the PZ rule already allows — it adds no extra combat-log window. Truly dead sockets still get reaped by TCP keepalive. This killed the idle drops completely.

### 3. Run the bridge at NORMAL OS priority

I tried being clever and set the Go bridge to High process priority "for performance." It made latency worse — a busy Go process at High priority fights the OS network scheduling and adds jitter. Leave the bridge at Normal. (Your TFS process can be High, that's fine — it's the bridge specifically that must not be.)

### 4. One socket, one standard port

Cloudflare only reliably proxies WebSocket on its standard ports. Don't let the client rotate through extra game ports "for fallback" — the non-standard ones fail the vast majority of the time and just cause confusing dropouts. Pin the game to one standard port and keep a single socket.

---

## Bonus 1: seamless (silent) reconnect

Once drops were rare, I added an RSA-secured token reattach: the server hands the client a single-use, encrypted token; if the connection ever breaks, the client silently reconnects and reattaches to its existing in-world character — a brief freeze or rly small dropout, then you're back, instead of being kicked to the character list. Most servers don't have this at all. The important detail is handling the race where the reconnect arrives before the server has cleaned up the old connection — kick the stale one, retry briefly, then attach.

-- Bonus 2: stay listed on otservlist without exposing your IP
Server lists (otservlist etc.) ping your login server directly — which would expose your origin. Solution: a separate, grey-clouded subdomain pointing at a cheap external proxy (a small VPS running HAProxy with send-proxy-v2), which forwards to your real login port. Firewall your origin so it only accepts that proxy's IP. Result: you show up online on the lists, players still connect through Cloudflare for the game, and your real IP stays hidden the whole time.

## Security: how it's actually locked down

This isn't just "hide the IP and hope." It's layered — defense in depth:

1. Hidden origin + Cloudflare DDoS. Players and server-lists only ever see Cloudflare IPs. Volumetric (L3/L4) and application (L7) attacks hit Cloudflare's network, not your box.
2. Origin firewall locked to Cloudflare. Even if someone does discover your real IP, the game ports only accept connections from Cloudflare's published IP ranges — everything else is dropped. In a 5.5-hour test the firewall blocked 240 direct scanner probes, zero of them from Cloudflare, while 100% of legit traffic went through. [[Leaking the IP is no longer game-over]]!!
3. Real player IP preserved (PROXY protocol v2). A naive proxy makes every player look like they come from Cloudflare — which silently breaks IP bans, rate limits and anti-multiclient. PROXYv2 forwards the real CF-Connecting-IP to TFS, so all your IP-based moderation keeps working exactly as it did on raw TCP.
4. Encrypted, single-use reconnect token (RSA). The seamless-reconnect token is RSA-secured and consumed on first use, and the channel rides the standard OT XTEA encryption. A token can't be sniffed off the wire and replayed to hijack someone's session.
5. Rate limiting at the bridge. The bridge enforces a minimum gap between (re)connections per source, so connection-flood / reconnect-spam is throttled before it ever reaches TFS.
6. WSS / TLS in transit. The player↔Cloudflare leg is HTTPS / WebSocket-Secure, so the tunnel is encrypted on the public internet on top of the game's own encryption.
 
### 1. Force the client to IPv4

Cloudflare always publishes an AAAA (IPv6) record now, and you cannot turn that off zone-side anymore. If your client's resolver races IPv6 vs IPv4, you get reconnect storms — connections flapping. Force the client's resolver to IPv4-only. This was the single biggest stability win. (And no, "Pseudo IPv4" does not help — it only rewrites an IP header, it doesn't change the transport.)

Why don’t you simply disable IPv6 on Cloudflare? It’s possible to do that via the API, even with a free account.
Bash:
curl -X PATCH "https://api.cloudflare.com/client/v4/zones/YOUR_ZONE_ID/settings/ipv6" \
  -H "X-Auth-Email: [email protected]" \
  -H "X-Auth-Key: cfk_YOUR_GLOBAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"value":"off"}'
 
Why don’t you simply disable IPv6 on Cloudflare? It’s possible to do that via the API, even with a free account.
Bash:
curl -X PATCH "https://api.cloudflare.com/client/v4/zones/YOUR_ZONE_ID/settings/ipv6" \
  -H "X-Auth-Email: [email protected]" \
  -H "X-Auth-Key: cfk_YOUR_GLOBAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"value":"off"}'
Hey bro yea but Cloudflare does have an IPv6 zone setting in the API, but it does not reliably mean “stop publishing AAAA records for proxied hostnames” in the way your snippet implies. The setting controls IPv6 support for your zone and edge behavior, but Cloudflare’s proxy architecture and DNS behavior are more nuanced than “turn IPv6 off and clients become IPv4-only.”
lately also Cloudflare added this =

What is IPv6?​

As the Internet runs low on IPv4 address space, there’s been a rapid increase in the deployment of IPv6 technologies. This shift is critical to the long-term growth and health of the Internet. At Cloudflare, we are doing our part to make it free and easy for all websites to be available on network.ipv6.

What does enabling IPv6 compatibility do?​

The IPv6 gateway setting enables IPv6 on all subdomains that are on Cloudflare (marked by an orange cloud in your DNS settings). If your host provides IPv6 support, the gateway will proxy IPv6 connections through Cloudflare. When both IPv4 and IPv6 connections are available, Cloudflare prefers IPv4. We recommend keeping IPv6 Compatibility set to “On” for greater availability for your website.

Note: If you already have IPv6 records on your origin server, you must enable Cloudflare for your AAAA records on the DNS settings page.

Why can’t I turn off IPv6?​

At Cloudflare we believe in being good to the Internet and good to our customers. By moving on from the legacy world of IPv4-only to the modern-day world where IPv4 and IPv6 are treated equally, we believe we are doing exactly that. In the Cloudflare dashboard, IPv6 is no longer something you can toggle on and off, it’s always just on.
 
Why don’t you simply disable IPv6 on Cloudflare? It’s possible to do that via the API, even with a free account.
Bash:
curl -X PATCH "https://api.cloudflare.com/client/v4/zones/YOUR_ZONE_ID/settings/ipv6" \
  -H "X-Auth-Email: [email protected]" \
  -H "X-Auth-Key: cfk_YOUR_GLOBAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"value":"off"}'
More detailed instruction: How to disable CloudFlare IPv6 access — Gesior's blog (https://skalski.pro/2024/11/03/how-to-disable-cloudflare-ipv6-access)
Bonus 2: stay listed on otservlist without exposing your IP
Server lists (otservlist etc.) ping your login server directly — which would expose your origin. Solution: a separate, grey-clouded subdomain pointing at a cheap external proxy (a small VPS running HAProxy with send-proxy-v2), which forwards to your real login port. Firewall your origin so it only accepts that proxy's IP. Result: you show up online on the lists, players still connect through Cloudflare for the game, and your real IP stays hidden the whole time.
If you have that extra VPS to hide IP for otservlist etc., you should put "mailer" on it. Every acc. maker that send registration/recovery e-mails put server IP in e-mail details. Most of OTSes use separate VPS for www, so IP of OTS does not leak, but it still leaks www VPS IP.
For newest versions of MyAAC (1.8.9+) there is a plugin for that: New plugin: mail-queue · slawkens/myaac-plugins@1481413 (https://github.com/slawkens/myaac-plugins/commit/1481413dac708a0b8bf76cebb92a7b1781266cb0)
You must put these files in .zip and upload to MyAAC as plugin. There is no official .zip release yet.
Then you have to copy installed and configured MyAAC files to second VPS, create file mailer.sh:
Code:
#!/bin/bash

while true; do
    php aac mail-queue:process 2>&1 | tee -a logs/send_mails.log
    sleep 1
done
and then run it on 'screen' or register as Linux service.
not reliably mean “stop publishing AAAA records for proxied hostnames” in the way your snippet implies
It does. I did it 9 years ago on otupdate.ovh and 2 weeks ago on another domain. No AAAA records for these domains.
You can try to dig AAAA of map.otupdate.ovh (this page does not work). It's available only thru IPv4, so there is no AAAA record.
If you had problem with AAAA record after disabling IPv6 in CF panel, it was probably cached on some DNS server/your PC.
 
More detailed instruction: How to disable CloudFlare IPv6 access — Gesior's blog (https://skalski.pro/2024/11/03/how-to-disable-cloudflare-ipv6-access)

If you have that extra VPS to hide IP for otservlist etc., you should put "mailer" on it. Every acc. maker that send registration/recovery e-mails put server IP in e-mail details. Most of OTSes use separate VPS for www, so IP of OTS does not leak, but it still leaks www VPS IP.
For newest versions of MyAAC (1.8.9+) there is a plugin for that: New plugin: mail-queue · slawkens/myaac-plugins@1481413 (https://github.com/slawkens/myaac-plugins/commit/1481413dac708a0b8bf76cebb92a7b1781266cb0)
You must put these files in .zip and upload to MyAAC as plugin. There is no official .zip release yet.
Then you have to copy installed and configured MyAAC files to second VPS, create file mailer.sh:
Code:
#!/bin/bash

while true; do
    php aac mail-queue:process 2>&1 | tee -a logs/send_mails.log
    sleep 1
done
and then run it on 'screen' or register as Linux service.

It does. I did it 9 years ago on otupdate.ovh and 2 weeks ago on another domain. No AAAA records for these domains.
You can try to dig AAAA of map.otupdate.ovh (this page does not work). It's available only thru IPv4, so there is no AAAA record.
If you had problem with AAAA record after disabling IPv6 in CF panel, it was probably cached on some DNS server/your PC.
thanks ill check that out actually about recover acc, no i have no problems with ipv6 but thanks for posting it for others :D
 
since many people have contacted me for this, here is it. if you know a better way or can improve it please do!

1) REAL per-IP rate limiting.
The original shipped with MaxConnPerIP = 1000 and MaxAttempts = 1000
(placeholders that never trigger), so it gave no L7 protection.
Cloudflare stops volumetric floods but NOT an attacker opening
thousands of valid WebSocket connections. This version uses sane
anti-flood caps (12 conn / 50 attempts per 30s) - well above legit
multi-clienting, but a hard stop on connection floods.
(Enforce your real "max clients per player" rule in TFS; this is
anti-flood only.)

2) ZERO per-packet allocation.
The original did frame := make([]byte, ...) for every forwarded
packet. This version reads header+body into a pooled buffer and
writes it directly - no allocation on the hot path, far less Go GC
pressure under load.

3) Proper WebSocket keepalive.
The original had no read/write deadlines, so a client that dropped
without a clean TCP FIN (wifi/mobile) lingered forever - inflating
the per-IP counter and leaving zombie sessions on TFS. This version
reaps dead connections with a 60s read-idle timeout + 20s write
timeout. It is client-driven (the OTClient already pings every
~250ms-1s), so the tunnel sends no pings of its own = lowest overhead,
and the 60s window means a laggy/spotty player is NEVER falsely
kicked (only a full minute of TOTAL silence counts as dead).

4) TCP_NODELAY on BOTH sockets.
The original only disabled Nagle on the tunnel->TFS side. This
version also disables it on the Cloudflare-facing WS socket (Go does
not guarantee it on accepted connections), so small packets are never
delayed anywhere in the chain.

5) Right-sized buffers + shared WriteBufferPool.
The original used 320KB read+write buffers per connection with no
pool (~640MB at 1000 players). This version uses 32KB buffers (Tibia
packets are <=~24KB) plus a shared WriteBufferPool - a fraction of the
memory at scale, with zero latency cost.

6) Correct IPv6 handling.
The original's IPv6 path was broken (naive string-split). This
version maps IPv6 clients to a stable synthetic IPv4 in the CGNAT
range (100.64.0.0/10) by hashing the /64 prefix - so IPv6 players
actually connect, and IP-bans apply at /64 granularity.


NOTE: this is the TRANSPORT layer only. If you see periodic ping spikes
hitting ALL players at once, that is almost always a server-side
main-thread stall (LuaJIT GC or the hourly saveServer freezing TFS) - a
separate problem the tunnel cannot fix.

BUILD & CONFIG:
go build -o tunnel.exe tunnel.go (Go 1.21+)
Only dependency: github.com/gorilla/websocket (see go.mod)

Source attached (tunnel.go + go.mod + go.sum). Feedback welcome.
Code:
package main

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

    "github.com/gorilla/websocket"
)

const (
    GameWsPort    = ":8080"
    LoginWsPort   = ":8880"
    LoginTcpHost  = "127.0.0.1:7171"
    GameTcpHost   = "127.0.0.1:7172"
    EnableLogging = false

    // WS I/O buffers: Tibia packets are small (<=~24KB), so 32KB is ample. The old value was
    // 320KB PER connection for BOTH read and write -- ~640KB/conn, ~640MB at 1000 players.
    // The WriteBufferPool (see upgrader) shares write buffers so they exist only during writes.
    WsBufferSize = 32768 // 32KB I/O buffers (was 327680 = 320KB)
    WsReadLimit  = 65535 // max message accepted from a client (uint16 packet max) -- abuse cap

    // Anti-flood only -- NOT the multi-client rule (TFS enforces the real "max 4 clients"
    // per account in-source). These caps are set well above legit use (dead connections
    // linger and still count until CF closes them, and a full 4-client reconnect can
    // briefly stack ~8) but far below a connection flood, so an attacker opening hundreds
    // is blocked while real players are never falsely rejected.
    LoginMaxConnPerIP     = 12
    LoginMaxAttemptsPerIP = 50
    LoginAttemptWindow    = 30 * time.Second
    LoginMinReconnectGap  = 1000 * time.Millisecond

    GameMaxConnPerIP     = 12
    GameMaxAttemptsPerIP = 50
    GameAttemptWindow    = 30 * time.Second
    GameMinReconnectGap  = 50 * time.Millisecond

    IPStateIdleExpiry = 1 * time.Minute
)

var upgrader = websocket.Upgrader{
    ReadBufferSize:  WsBufferSize,
    WriteBufferSize: WsBufferSize,
    WriteBufferPool: &sync.Pool{}, // share write buffers across connections (memory at scale)
    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
}

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,
    },
}

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
        })
    }
}

// WebSocket keepalive: ping idle clients and drop dead ones fast, so dead connections
// don't linger and inflate the per-IP count, and zombie sessions to TFS get cleaned up.
// A client (Beast/OTClient) auto-responds to pings while reading; any incoming frame OR
// pong resets the read deadline (belt-and-suspenders in case a ping is lost in transit).
// No frame and no pong for pongWait => the link is dead and the bridge is torn down.
const (
    // The OTClient sends game-protocol pings every ~250ms-1s (client-initiated) and runs its
    // own 10s watchdog + reconnect. So the tunnel does NOT send its own WS control pings --
    // the client's constant data IS the keepalive signal: lower overhead (no per-packet write
    // mutex, no ping goroutine) and more reliable (no dependency on CF forwarding control
    // frames). We only reap a connection that goes FULLY silent (client gone/frozen); every
    // incoming frame resets the timer, so a laggy/spotty player is never falsely reaped
    // (reaching the timeout = a full minute of total silence = genuinely dead).
    readIdleTimeout = 60 * time.Second // no data from the client for this long => reap zombie
    writeTimeout    = 20 * time.Second // max time to complete a single write to the client
)

type wsReader struct {
    conn   *websocket.Conn
    reader io.Reader
}

func (r *wsReader) Read(p []byte) (int, error) {
    for {
        if r.reader != nil {
            n, err := r.reader.Read(p)
            if err == io.EOF {
                r.reader = nil
                if n > 0 {
                    return n, nil
                }
                continue
            }
            return n, err
        }
        messageType, reader, err := r.conn.NextReader()
        if err != nil {
            return 0, err
        }
        // A frame arrived => the peer is alive. Reset the idle timer. The client's game
        // pings (every ~250ms-1s) keep this fed continuously, so only a truly silent
        // (dead/frozen) connection ever reaches the timeout.
        r.conn.SetReadDeadline(time.Now().Add(readIdleTimeout))
        if messageType != websocket.BinaryMessage {
            continue
        }
        r.reader = reader
    }
}

// copyTCPtoWS forwards TFS → client, ONE WS frame per TFS packet (WriteMessage).
// The client processes exactly one message per WS frame, so packets must never
// be coalesced or split here. This is the ONLY writer to the WS conn (the tunnel sends
// no pings of its own), so no write mutex is needed -- lowest per-packet overhead.
func (b *Bridge) copyTCPtoWS() error {
    for {
        bufPtr := packetBufPool.Get().(*[]byte)
        buf := *bufPtr

        // Read the 2-byte length header into the front of the pooled buffer.
        if _, err := io.ReadFull(b.tcpConn, buf[0:2]); err != nil {
            packetBufPool.Put(bufPtr)
            return err
        }
        packetLen := int(binary.LittleEndian.Uint16(buf[0:2]))
        if packetLen == 0 {
            packetBufPool.Put(bufPtr)
            continue
        }

        total := 2 + packetLen
        if total > len(buf) {
            // Oversized packet: grow the pooled buffer (it stays in the pool afterwards).
            nb := make([]byte, total)
            copy(nb[0:2], buf[0:2])
            *bufPtr = nb
            buf = nb
        }

        // Read the body directly after the header so header+body are one contiguous
        // slice handed straight to WriteMessage -- no per-packet allocation (was
        // make([]byte, 2+packetLen) every packet -> Go GC pressure under load).
        if _, err := io.ReadFull(b.tcpConn, buf[2:total]); err != nil {
            packetBufPool.Put(bufPtr)
            return err
        }

        // gorilla WriteMessage is synchronous and does not retain the buffer, so it is
        // safe to return it to the pool immediately after the call returns. The write
        // deadline reaps a client that has stopped accepting data (buffers full = dead).
        b.wsConn.SetWriteDeadline(time.Now().Add(writeTimeout))
        err := b.wsConn.WriteMessage(websocket.BinaryMessage, buf[0:total])
        packetBufPool.Put(bufPtr)
        if err != nil {
            return err
        }
    }
}

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

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{}),
    }
}

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
    }

    if tcp, ok := tcpConn.(*net.TCPConn); ok {
        tcp.SetNoDelay(true)
        tcp.SetKeepAlive(true)
        tcp.SetKeepAlivePeriod(60 * time.Second)
    }

    b.tcpConn = tcpConn

    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
        }
    }

    // Zombie reaping: if the client goes fully silent (gone/frozen), the read deadline fires
    // -> the read loop errors -> the bridge closes and the per-IP slot frees within
    // readIdleTimeout. wsReader resets this on every incoming frame, and the client's game
    // pings (~250ms-1s) keep it fed, so a live/laggy connection is never falsely reaped.
    b.wsConn.SetReadDeadline(time.Now().Add(readIdleTimeout))

    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))
        }
    }()

    go func() {
        defer b.Close()
        err := b.copyTCPtoWS()
        if err != nil && !b.isClosed() {
            logMsg("TCP→WS Error", fmt.Sprintf("[%s] %s: %v", b.limiter.cfg.name, b.clientIP, err))
        }
    }()
}

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

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))
    })
}

// syntheticIPv4FromIPv6 maps an IPv6 client to a stable synthetic IPv4 in the
// CGNAT range 100.64.0.0/10. TFS stores client IPs as a 32-bit value (uint32_t)
// and its PROXY parser is IPv4-only (fixed 28-byte header), so a real IPv6
// address cannot be passed through. We hash the /64 prefix (the network the
// client's ISP assigns) so that all addresses a single user rotates through map
// to the same synthetic IPv4 — an IP-ban then applies at /64 granularity, which
// is the correct granularity for banning IPv6 clients.
func syntheticIPv4FromIPv6(ip net.IP) net.IP {
    ip16 := ip.To16()
    h := fnv.New32a()
    h.Write(ip16[:8]) // /64 prefix
    lower22 := h.Sum32() & 0x3FFFFF
    addr := uint32(100)<<24 | uint32(64)<<16 | lower22 // 100.64.0.0/10 base | hash
    out := make(net.IP, net.IPv4len)
    binary.BigEndian.PutUint32(out, addr)
    return out
}

func (b *Bridge) buildPROXYv2Header() []byte {
    if b.clientIP == "" {
        return nil
    }
    ip := net.ParseIP(b.clientIP)
    if ip == nil {
        return nil
    }
    ipv4 := ip.To4()
    if ipv4 == nil {
        // IPv6 client: map to a stable synthetic IPv4 so the connection still
        // works and bans apply at /64 granularity (see syntheticIPv4FromIPv6).
        ipv4 = syntheticIPv4FromIPv6(ip)
    }
    header := make([]byte, 28)
    copy(header[0:12], []byte{0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A})
    header[12] = 0x21
    header[13] = 0x11
    binary.BigEndian.PutUint16(header[14:16], 12)
    copy(header[16:20], ipv4)
    copy(header[20:24], []byte{0, 0, 0, 0})
    binary.BigEndian.PutUint16(header[24:26], 0)
    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
}

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:")
    return clientIP
}

func handleWebSocket(tcpHost string, limiter *limiterBucket) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        clientIP := extractClientIP(r)

        if !limiter.allowAttempt(clientIP) {
            http.Error(w, "Too many reconnect attempts", http.StatusTooManyRequests)
            return
        }
        if !limiter.incConn(clientIP) {
            http.Error(w, "Too many concurrent connections", http.StatusTooManyRequests)
            return
        }

        ws, err := upgrader.Upgrade(w, r, nil)
        if err != nil {
            limiter.decConn(clientIP)
            return
        }
        ws.EnableWriteCompression(false)
        ws.SetReadLimit(WsReadLimit)
        // Disable Nagle on the CF-facing socket too (Go does not guarantee TCP_NODELAY on
        // accepted conns), so small game packets are never delayed on the client<->CF<->tunnel
        // leg -- same treatment we already give the tunnel<->TFS socket.
        if tc, ok := ws.UnderlyingConn().(*net.TCPConn); ok {
            tc.SetNoDelay(true)
        }

        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()

    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
        gameServer.Close()
        loginServer.Close()
        os.Exit(0)
    }()

    log.Println("Login tunnel: :8880 -> 127.0.0.1:7171")
    log.Println("Game tunnel:  :8080 -> 127.0.0.1:7172")
    log.Println("PROXY v2: ENABLED")

    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)
    }
}
 

Attachments

Last edited:
Back
Top