package client import ( "encoding/json" "fmt" "os" "strconv" "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 // MuxResume reattaches a parked stream to this conn (CID + our accepted // offset); MuxResumeAck carries the hub's accepted offset and a fresh CID. MuxResume = 0x07 MuxResumeAck = 0x08 // MuxCtlSid is the reserved stream id carrying connection-scoped mux frames // (PING/PONG). Real streams are numbered from 1. MuxCtlSid = 0 // RST reason codes (optional trailing byte; absence means "unspecified"). // Distinguishing them matters for resume: an unknown stream is terminal, // while "already bound" means a racing attempt won and this one must leave // the stream alone rather than tear down a player the hub just rebound. RstUnspecified = 0x00 RstUnknownStream = 0x01 RstAlreadyBound = 0x02 RstResumeAbandoned = 0x03 RstFlowControl = 0x04 RstDialFailed = 0x05 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 // FlagStreamResume enables stream resumption (PROTOCOL.md §7.5): a worker // conn drop parks its streams instead of killing them, the hub hangs the // player sockets, and the client reattaches each stream byte-exactly over a // fresh conn. Negotiated, so either side may decline and get today's // behaviour (immediate teardown) unchanged. FlagStreamResume = 0x04 // 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 ) // Egress bandwidth shaping (see shaper.go and docs/architecture.md §6). These // are entirely client-local: nothing here appears on the wire. const ( // MinBandwidth floors a configured cap. Below this the tunnel cannot carry a // Minecraft session at all, so such a value is a unit typo ("20bps" for // "20mbps") and is rejected rather than silently clamped. MinBandwidth = 8 * 1024 // ShaperBurstSeconds is how much transmission time the token bucket banks // while idle. Big enough to absorb a chunk-load spike; small enough that // releasing it cannot overrun the physical uplink and rebuild the standing // queue the cap exists to prevent. ShaperBurstSeconds = 0.2 // MinShaperBurst must exceed DataChunkSize: a request larger than the bucket // could never be afforded and would park forever. MinShaperBurst = 64 * 1024 MaxShaperBurst = 4 << 20 // ShaperSliceSeconds bounds how long one stream holds the link before the // scheduler can switch, by sizing the send chunk to that much transmission // time. Above ~13 Mbps this yields DataChunkSize and nothing changes. ShaperSliceSeconds = 0.02 MinShaperChunk = 4 * 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 // DefaultResumeGraceMs is how long a parked stream keeps trying to reattach // before giving up and closing the destination. // // Chosen against the backend, not the tunnel: a hung player stops answering // the game server's KeepAlive, and vanilla disconnects a silent client at // 30s. A longer grace would resume sessions the backend then kicks anyway. DefaultResumeGraceMs = 15000 // MinResumeGraceMs floors the grace so it can always fit at least one dial; // a grace shorter than HandshakeTimeout could never complete an attempt. MinResumeGraceMs = 2000 // ResumeRetryDelay paces reattach attempts after a failure. Short, because // the player is hanging for the whole grace period. ResumeRetryDelay = 500 * time.Millisecond // maxControlBackoff caps the control-session reconnect delay. The hub holds // this client's routes only for its own registration grace, so a backoff that // grew past that would strand players it is hanging on our behalf. maxControlBackoff = 10 * time.Second // ResumeAckTimeout bounds the wait for RESUME_ACK on a conn that completed // its handshake but then went quiet, so a wedged hub does not consume the // entire grace budget in one attempt. ResumeAckTimeout = 10 * time.Second ) // 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 // MaxBandwidth caps what the client sends to the hub, aggregated over every // stream on every worker conn — the direction that carries the game server's // output to the players, and the one a residential uplink runs out of first. // Empty means no limit. See parseBandwidth for the accepted syntax. MaxBandwidth string `json:"maxBandwidth"` // StreamResume enables stream resumption (PROTOCOL.md §7.5). A pointer so an // absent key means "on" while an explicit false disables it: with it off the // client never offers the flag, allocates no retransmit buffers, and behaves // exactly as a pre-resume client. StreamResume *bool `json:"streamResume"` // ResumeGraceMs bounds how long a parked stream keeps trying to reattach. // Clamped below the hub's advertised grace so the client always gives up // first and the hub is never left holding a player nobody will claim. ResumeGraceMs int `json:"resumeGraceMs"` // StatsIntervalMs enables the periodic performance summary; 0 (the default) // disables it and costs nothing. StatsIntervalMs int `json:"statsIntervalMs"` Mappings []Mapping `json:"mappings"` } // resumeEnabled reports whether stream resumption is configured on. func (c *Config) resumeEnabled() bool { return c.StreamResume == nil || *c.StreamResume } // resumeGrace is how long a parked stream may keep trying to reattach. func (c *Config) resumeGrace() time.Duration { ms := c.ResumeGraceMs if ms <= 0 { ms = DefaultResumeGraceMs } if ms < MinResumeGraceMs { ms = MinResumeGraceMs } return time.Duration(ms) * time.Millisecond } // bandwidthUnits maps a rate suffix to its value in bytes per second. Bit units // are decimal because that is what ISPs quote; byte units are binary to match // streamWindowBytes. Ordered longest-suffix-first so "kbps" is not read as // "bps", nor "gb/s" as "b/s". var bandwidthUnits = []struct { suffix string mul float64 }{ {"gbps", 1e9 / 8}, {"gbit", 1e9 / 8}, {"mbps", 1e6 / 8}, {"mbit", 1e6 / 8}, {"kbps", 1e3 / 8}, {"kbit", 1e3 / 8}, {"gb/s", 1 << 30}, {"mb/s", 1 << 20}, {"kb/s", 1 << 10}, {"bps", 1.0 / 8}, {"b/s", 1}, } // parseBandwidth converts a human-readable rate to bytes per second. The empty // string means "no limit" and yields 0. // // "20mbps" 20 megabits/s = 2500000 B/s // "512kbps" 512 kilobits/s = 64000 B/s // "2MB/s" 2 mebibytes/s = 2097152 B/s // "1500000" a bare number is already bytes per second func parseBandwidth(s string) (int64, error) { s = strings.TrimSpace(s) if s == "" { return 0, nil } lower := strings.ToLower(s) num, mul := lower, 1.0 for _, u := range bandwidthUnits { if strings.HasSuffix(lower, u.suffix) { num, mul = strings.TrimSpace(lower[:len(lower)-len(u.suffix)]), u.mul break } } v, err := strconv.ParseFloat(num, 64) if err != nil || v <= 0 { return 0, fmt.Errorf(`maxBandwidth: cannot read %q as a rate (try "20mbps", "2MB/s", or bytes per second)`, s) } return int64(v * mul), nil } // 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 } // Parsed here only to fail fast on a bad value; New does the real conversion. bps, err := parseBandwidth(c.MaxBandwidth) if err != nil { return nil, err } if bps > 0 && bps < MinBandwidth { return nil, fmt.Errorf("maxBandwidth %q is only %d B/s; the minimum is %d B/s", c.MaxBandwidth, bps, MinBandwidth) } 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 }