Worker frames are now FrameType + payload; there is no stream id. Each player gets its own worker conn. maxTunnels (default 256) caps concurrent tunnels. The old maxConn pool size is ignored so existing configs do not silently admit only a handful of players. Resume, per-direction windows, the control session, and the DATA-only shaper stay. A dropped worker still hangs that one player and reattaches over a fresh conn. Add a hub-side per-IP limiter for player intents only (default 8/s, burst 16, 64 concurrent). Unmatched hostnames consume a token; Intent 17 is never counted. 0 disables each knob.
356 lines
13 KiB
Go
356 lines
13 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
|
|
// 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
|
|
}
|