Files
redapricot/client/config.go
T
iceBear67 da17140583 fix: harden resume/shutdown paths, tighten Intent-18 and PSK handshake handling
Client (Go) — resume correctness
- C1: completeResume now re-parks the stream when replay fails mid-conn-loss.
  parked was cleared before the replay loop, so the dying conn's teardown
  would start a second resumeLoop and the two loops could strand the stream
  with neither alive. Resume stats are counted only after the replay lands.
- C2: RST(ALREADY_BOUND) is retryable instead of terminating the loop. With
  C1 fixed there is never a genuine second attempt, so "already bound" means
  the hub still holds the stream on a half-open conn; the retry waits out
  that bind (bounded by the grace deadline, teardown on expiry) instead of
  returning and leaving the destination socket hung forever.

Client (Go) — shutdown semantics
- C3: Close() sets a closing flag and cancels an internal context; dialSession
  takes a ctx (DialContext + AfterFunc so shutdown aborts in-flight
  handshakes); the worker pool refuses new conns after closeAll (Allocate,
  background growth, cond waiters); serveControl's reconnect loop is gated by
  closing so Close works even when the caller's Start context is not
  cancelled; conn-loss teardown closes streams outright during shutdown
  instead of parking them for a reattach that will never come.

Client (Go) — hygiene
- C4: pingInterval() clamps at the single point a duration is derived, so a
  hand-built Config with PingIntervalMs <= 0 can no longer panic
  time.NewTicker (added DefaultPingIntervalMs).
- E6: shaperStall is sampled right after shaper.Acquire, before the socket
  write, so a hub that is not reading is no longer charged to the bandwidth
  cap in the stats.
- E7: stream log lines now carry conn%d/sid%d (leg.String()), making streams
  traceable across reattaches.
- P5: mirror constants IntentReserved/RegisterOk/RegisterErrPattern added;
  RegisterAck dispatch logs rejection reasons via the named codes.

Hub (Java) + PROTOCOL.md
- P3: Intent 18 replies with a Minecraft status-response packet
  ([Len: VarInt][0x00][JSON: String]) and closes (socket.end, so the write
  always lands) instead of closing silently; documented in PROTOCOL.md §2.
- P4: PSK address check is strict equality with the lowercase hex address;
  an uppercase/case-folded variant is now rejected per PROTOCOL.md §2.
- P7: PROTOCOL.md §5 SessionReady row lists its real fields
  (Flags/RecvWindow/ResumeGraceMs) instead of "(none)".

Verified: go vet, go test -race ./client/..., gradle test, full e2e suite
(twice), resume e2e 3x, plus live probes of the hub with the real client
codec (Intent-18 status reply, strict-lowercase PSK acceptance/rejection).%
2026-08-15 17:47:39 +08:00

349 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 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 the hub has the stream on another conn — the
// reattach retries until that bind dies and the hub re-parks the stream.
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
// 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"`
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 = 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
}