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