package client import ( "encoding/json" "fmt" "os" "strings" "time" ) // Protocol constants (mirror of the Java Protocol class; see PROTOCOL.md). const ( IntentRedapricot = 17 ProtocolVersion = 767 // arbitrary; the hub ignores it MagicControl = 0x01 MagicWorker = 0x02 CIDLen = 16 CtlSessionReady = 0x00 CtlRegister = 0x01 CtlUnregister = 0x02 CtlRegisterAck = 0x03 CtlControlRequest = 0x04 CtlPing = 0x05 CtlPong = 0x06 MuxSyn = 0x00 MuxData = 0x01 MuxFin = 0x02 MuxRst = 0x03 MuxWnd = 0x04 MuxPing = 0x05 MuxPong = 0x06 // MuxCtlSid is the reserved stream id carrying connection-scoped mux frames // (PING/PONG). Real streams are numbered from 1. MuxCtlSid = 0 FrameError = 0x7F // SaturationThreshold caps how many streams share one worker conn once the // pool has grown to maxConn. Below maxConn the pool grows first (§7.1), so a // single connection is never a shared point of failure for every player. SaturationThreshold = 8 // Session-establishment feature flags (trailing VarInt on the Rekey message). FlagStreamFC = 0x01 // FlagWorkerHeartbeat enables mux-level PING/PONG on worker conns. Without // it a worker conn whose path is silently blackholed (NAT/conntrack drop, // firewall) is never detected: the read loop parks forever, the dead conn // stays in the pool, and no player can be served until the client restarts. FlagWorkerHeartbeat = 0x02 // Per-stream flow-control window bounds (bytes). The advertised window is the // receiver's promise of how much un-credited DATA it will buffer per stream. DefaultStreamWindow = 256 * 1024 MinStreamWindow = 32 * 1024 MaxStreamWindow = 8 << 20 // DataChunkSize caps a single DATA frame's payload so no stream monopolizes // the shared worker connection for long. DataChunkSize = 32 * 1024 ) // Timeouts. Every tunnel socket is covered by one of these: without them a // silently dropped path (no FIN/RST) leaves the client parked forever. const ( // HandshakeTimeout bounds session establishment end to end — the TCP dial, // the Rekey write and the SessionReady read. A hub that accepts the socket // but never answers must not park the caller (and, for the pool, every other // player behind it) indefinitely. HandshakeTimeout = 15 * time.Second // TCPKeepAlivePeriod asks the kernel to probe idle tunnel sockets, so a peer // that becomes unreachable is detected even when no frames are in flight. TCPKeepAlivePeriod = 30 * time.Second // MissedHeartbeats is how many ping intervals may pass with no reply before // a session is declared dead and dropped. MissedHeartbeats = 3 // MinPingIntervalMs floors the configured ping interval so the derived // heartbeat timeout can never be short enough to cause spurious drops. // Applied in LoadConfig, i.e. to configs that come from disk. MinPingIntervalMs = 1000 ) // heartbeatTimeout is how long a session may go without a reply before it is // considered dead, derived from the configured ping interval. func (c *Config) heartbeatTimeout() time.Duration { return c.pingInterval() * MissedHeartbeats } // pingInterval is the configured heartbeat period. func (c *Config) pingInterval() time.Duration { return time.Duration(c.PingIntervalMs) * time.Millisecond } // Mapping routes a registered pattern to a real destination. type Mapping struct { Pattern string `json:"pattern"` Destination string `json:"destination"` ProxyProtocol bool `json:"proxyProtocol"` // VelocitySecret, when non-empty, answers the destination's Velocity // modern-forwarding login query (velocity:player_info) with this secret, // forwarding the player's real IP, username and UUID (see velocity.go). VelocitySecret string `json:"velocitySecret"` } // Config is the client configuration (PROTOCOL.md §9.2). type Config struct { Server string `json:"server"` PSK string `json:"psk"` MaxConn int `json:"maxConn"` PingIntervalMs int `json:"pingIntervalMs"` StreamWindowBytes int `json:"streamWindowBytes"` // per-stream receive window; 0 = default Mappings []Mapping `json:"mappings"` } // LoadConfig reads and validates a JSON config file. func LoadConfig(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } var c Config if err := json.Unmarshal(data, &c); err != nil { return nil, fmt.Errorf("parse config: %w", err) } if c.Server == "" { return nil, fmt.Errorf("server is required") } if c.PSK == "" { return nil, fmt.Errorf("psk is required") } if c.MaxConn < 1 { c.MaxConn = 1 } if c.MaxConn > 8 { c.MaxConn = 8 } if c.PingIntervalMs <= 0 { c.PingIntervalMs = 20000 } if c.PingIntervalMs < MinPingIntervalMs { c.PingIntervalMs = MinPingIntervalMs } if len(c.Mappings) == 0 { return nil, fmt.Errorf("at least one mapping is required") } return &c, nil } // NormalizeAddress matches the hub's normalization: lower-cased, FML-suffix and // trailing-dot stripped. func NormalizeAddress(addr string) string { if i := strings.IndexByte(addr, 0); i >= 0 { addr = addr[:i] } addr = strings.ToLower(addr) addr = strings.TrimRight(addr, ".") return addr }