323 lines
12 KiB
Go
323 lines
12 KiB
Go
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
|
|
}
|