Files
redapricot/client/config.go
T
iceBear67 4df2560331 break: replace muxed workers with 1:1 tunnels
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.
2026-08-15 18:32:51 +08:00

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
}