package client import ( "encoding/json" "fmt" "os" "strconv" "strings" "time" ) // Protocol constants (mirror of the Java Protocol class; see PROTOCOL.md). const ( IntentRedapricot = 17 // IntentReserved is the handshake intent reserved for redapricot // management/status (mirror of Protocol.INTENT_RESERVED). The hub never // pattern-matches it; it replies with a Minecraft status line and closes, // so an operator can probe the port without joining the protocol. IntentReserved = 18 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 // RegisterAck status codes (mirror of Protocol.REGISTER_OK/_ERR_PATTERN). RegisterOk = 0x00 RegisterErrPattern = 0x01 // pattern is not a valid regular expression MuxSyn = 0x00 MuxData = 0x01 MuxFin = 0x02 MuxRst = 0x03 MuxWnd = 0x04 MuxPing = 0x05 MuxPong = 0x06 // MuxResume reattaches a parked player to this conn (CID + our accepted // offset); MuxResumeAck carries the hub's accepted offset and a fresh CID. MuxResume = 0x07 MuxResumeAck = 0x08 // RST reason codes (optional trailing byte; absence means "unspecified"). // Distinguishing them matters for resume: an unknown stream is terminal, // while "already bound" means the hub has the player on another conn — the // reattach retries until that bind dies and the hub re-parks the player. RstUnspecified = 0x00 RstUnknownStream = 0x01 RstAlreadyBound = 0x02 RstResumeAbandoned = 0x03 RstFlowControl = 0x04 RstDialFailed = 0x05 FrameError = 0x7F // DefaultMaxTunnels / MaxMaxTunnels bound concurrent 1:1 worker conns. // The old mux-era maxConn cap of 8 would silently become "8 players". DefaultMaxTunnels = 256 MaxMaxTunnels = 4096 // Session-establishment feature flags (trailing VarInt on the Rekey message). FlagStreamFC = 0x01 // FlagWorkerHeartbeat enables connection-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 and that // player is stuck until the client restarts. FlagWorkerHeartbeat = 0x02 // FlagStreamResume enables stream resumption (PROTOCOL.md §7.5): a worker // conn drop parks the player instead of killing them, the hub hangs the // player socket, and the client reattaches byte-exactly over a fresh conn. // Negotiated, so either side may decline and get today's behaviour // (immediate teardown) unchanged. FlagStreamResume = 0x04 // Per-connection flow-control window bounds (bytes). The advertised window // is the receiver's promise of how much un-credited DATA it will buffer. DefaultStreamWindow = 256 * 1024 MinStreamWindow = 32 * 1024 MaxStreamWindow = 8 << 20 // DataChunkSize caps a single DATA frame's payload so one write cannot // occupy the link for a full 1-MiB frame. 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 // DefaultPingIntervalMs is used when a config carries no (or a // non-positive) interval. LoadConfig applies the same default. DefaultPingIntervalMs = 20000 // MinPingIntervalMs floors the configured ping interval so the derived // heartbeat timeout can never be short enough to cause spurious drops. // Applied wherever the interval is read, not just on the file path: a // hand-built Config (tests) carrying 0 would otherwise panic // time.NewTicker at the call site. 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, clamped at the single point // where a duration is derived. LoadConfig also clamps on the file path; this // covers Configs built directly (tests), where a 0 or negative PingIntervalMs // would panic time.NewTicker — a panic, not a log line, because the interval // feeds the heartbeat timeout too. func (c *Config) pingInterval() time.Duration { ms := c.PingIntervalMs if ms <= 0 { ms = DefaultPingIntervalMs } if ms < MinPingIntervalMs { ms = MinPingIntervalMs } return time.Duration(ms) * 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"` // MaxTunnels is the max concurrent 1:1 worker connections (PROTOCOL.md §7.1). // 0 means the default. Clamped to [1, 4096]. MaxTunnels int `json:"maxTunnels"` // MaxConn is the retired mux-era pool size. Ignored when loading a file: // honouring a value of 4 as a player cap would silently break existing // configs. Tests that construct a Config should set MaxTunnels instead. MaxConn int `json:"maxConn"` PingIntervalMs int `json:"pingIntervalMs"` StreamWindowBytes int `json:"streamWindowBytes"` // per-connection receive window; 0 = default // MaxBandwidth caps what the client sends to the hub, aggregated over 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 != 0 && c.MaxTunnels == 0 { // Old mux pool size. Must not become the player cap: a previously-working // maxConn: 4 would admit only four players. fmt.Fprintf(os.Stderr, "redapricot-client: maxConn is ignored (it was the mux pool size); use maxTunnels (default %d)\n", DefaultMaxTunnels) } if c.MaxTunnels < 1 { c.MaxTunnels = DefaultMaxTunnels } if c.MaxTunnels > MaxMaxTunnels { c.MaxTunnels = MaxMaxTunnels } if c.PingIntervalMs <= 0 { c.PingIntervalMs = DefaultPingIntervalMs } 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 }