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.
This commit is contained in:
iceBear67
2026-08-15 18:32:51 +08:00
parent da17140583
commit 4df2560331
27 changed files with 1174 additions and 856 deletions
+41 -31
View File
@@ -14,8 +14,8 @@ import (
"github.com/iceBear67/redapricot/client/wire"
)
// Client is a redapricot client: it holds a control session with the hub and a
// pool of worker connections used to serve player streams.
// Client is a redapricot client: it holds a control session with the hub and
// dials one worker connection per player.
type Client struct {
cfg *Config
pskBytes []byte
@@ -25,7 +25,7 @@ type Client struct {
mappings map[string]Mapping // normalized pattern -> mapping
pool *WorkerPool
// ctx/cancel own every pool dial: Close cancels it so an in-flight dial
// ctx/cancel own every worker dial: Close cancels it so an in-flight dial
// aborts instead of holding a goroutine for the whole handshake timeout.
// The control path uses the caller's context from Start, which is the same
// shutdown signal by convention.
@@ -34,10 +34,10 @@ type Client struct {
// closing is set by Close; a conn-loss teardown checks it and closes
// streams outright rather than parking them for a reattach that is never
// coming. Allocate also consults it through the pool's own flag.
// coming. Dial also consults it through the pool's own flag.
closing atomic.Bool
streamWnd int // our advertised per-stream receive window (bytes)
streamWnd int // our advertised per-connection receive window (bytes)
shaper *Shaper // caps aggregate egress to the hub; nil when unlimited
chunk int // DATA payload cap; shrinks below DataChunkSize at low rates
@@ -78,10 +78,20 @@ func New(cfg *Config) *Client {
log.Printf("egress shaped to %d B/s (burst %d B, chunk %d B)",
bps, int64(c.shaper.burst), c.shaper.chunk)
}
c.pool = newWorkerPool(c, cfg.MaxConn)
c.pool = newWorkerPool(c, clampMaxTunnels(cfg.MaxTunnels))
return c
}
func clampMaxTunnels(n int) int {
if n < 1 {
return DefaultMaxTunnels
}
if n > MaxMaxTunnels {
return MaxMaxTunnels
}
return n
}
func clampWindow(w int) int {
if w <= 0 {
return DefaultStreamWindow
@@ -99,8 +109,8 @@ func clampWindow(w int) int {
// was negotiated during establishment.
type session struct {
fc *wire.FramedConn
peerWnd int // hub's advertised per-stream receive window
heartbeat bool // hub accepted mux-level PING/PONG on worker conns
peerWnd int // hub's advertised per-connection receive window
heartbeat bool // hub accepted connection-level PING/PONG on worker conns
resume bool // hub accepted stream resumption (§7.5)
// hubGrace is how long the hub will hang a parked player, as advertised in
// SessionReady. Zero when resumption was not negotiated.
@@ -108,7 +118,7 @@ type session struct {
}
// dialSession opens a TCP connection, performs the Intent-17 handshake, the
// Phase-A rekey, and reads SessionReady. Per-stream flow control is mandatory:
// Phase-A rekey, and reads SessionReady. Per-connection flow control is mandatory:
// a hub that does not echo the STREAM_FC flag is rejected.
//
// The whole exchange is bounded by HandshakeTimeout. A hub that accepts the
@@ -116,7 +126,7 @@ type session struct {
// behalf) must fail fast rather than park the caller forever.
//
// ctx bounds the dial: the control path passes the caller's context so a
// shutdown mid-handshake aborts the attempt, and the pool passes the client's
// shutdown mid-handshake aborts the attempt, and worker dials pass the client's
// own context so Close cancels in-flight dials. After the dial, a cancelled
// ctx keeps aborting by closing the conn underneath the deadline-bounded
// handshake I/O.
@@ -158,7 +168,7 @@ func (c *Client) dialSession(ctx context.Context, magic byte) (sess *session, er
)
// 3. Rekey frame (Phase A), including the mandatory feature flags and our
// per-stream receive window.
// per-connection receive window.
rnd := make([]byte, 16)
if _, err := crand.Read(rnd); err != nil {
return nil, err
@@ -186,7 +196,7 @@ func (c *Client) dialSession(ctx context.Context, magic byte) (sess *session, er
)
// 5. SessionReady: the type byte followed by the hub's accepted flags and
// its per-stream receive window. Both are required.
// its per-connection receive window. Both are required.
payload, err := fc.ReadFrame()
if err != nil {
return nil, err
@@ -198,7 +208,7 @@ func (c *Client) dialSession(ctx context.Context, magic byte) (sess *session, er
flags, ferr := r.VarInt()
hubWnd, werr := r.VarInt()
if ferr != nil || werr != nil || flags&FlagStreamFC == 0 || hubWnd <= 0 {
return nil, fmt.Errorf("hub did not accept per-stream flow control (unsupported hub version?)")
return nil, fmt.Errorf("hub did not accept per-connection flow control (unsupported hub version?)")
}
if hubWnd > MaxStreamWindow {
hubWnd = MaxStreamWindow
@@ -398,8 +408,8 @@ func (c *Client) pingLoop(ctx context.Context, ctrl *ctrlSession) {
}
}
// handleControlRequest reacts to a matched player: allocate a worker stream,
// SYN it, and bridge it to the mapped destination.
// handleControlRequest reacts to a matched player: dial a dedicated worker
// conn, SYN it, and bridge it to the mapped destination.
func (c *Client) handleControlRequest(cid []byte, pattern, ip string, port int) {
mapping, ok := c.mappings[NormalizeAddress(pattern)]
if !ok {
@@ -408,37 +418,37 @@ func (c *Client) handleControlRequest(cid []byte, pattern, ip string, port int)
}
log.Printf("player %s:%d joined via pattern %q -> %s", ip, port, pattern, mapping.Destination)
// Allocate and publish must agree on a live conn: Allocate hands out a
// (conn, sid) pair that can die before we register on it, which would strand
// the stream in a map nothing iterates. registerStream reports that, and we
// simply pick another conn.
// Dial and attach must agree on a live conn: Dial hands out a conn that can
// die before we attach on it, which would strand the stream on a conn
// nothing iterates. attach reports that, and we simply dial another.
var st *Stream
var lg *leg
for attempt := 0; attempt < allocateAttempts; attempt++ {
wc, sid, err := c.pool.Allocate()
var wc *WorkerConn
for attempt := 0; attempt < dialAttempts; attempt++ {
var err error
wc, err = c.pool.Dial()
if err != nil {
log.Printf("worker allocate failed: %v", err)
log.Printf("worker dial failed: %v", err)
return
}
st = newStream(c, wc, sid, cid, mapping, ip, port)
// Register before SYN so inbound DATA can never race ahead of the table,
st = newStream(c, wc, cid, mapping, ip, port)
// Attach before SYN so inbound DATA can never race ahead of the binding,
// and start the pumps before the (bounded) SYN write so a failed or slow
// SYN cannot strand a stream that nothing would ever tear down.
if wc.registerStream(sid, st) {
lg = st.conn()
if wc.attach(st) {
break
}
_ = wc.fc.Close()
st = nil
}
if st == nil {
log.Printf("worker allocate failed: no live conn after %d attempts", allocateAttempts)
log.Printf("worker dial failed: no live conn after %d attempts", dialAttempts)
return
}
go st.writeLoop()
go st.run()
if err := lg.wc.sendSyn(lg.sid, cid); err != nil {
log.Printf("stream %s: SYN failed: %v", lg, err)
if err := wc.sendSyn(cid); err != nil {
log.Printf("stream %s: SYN failed: %v", st.name(), err)
st.teardown(false)
}
}
@@ -452,7 +462,7 @@ func (c *Client) WorkerConnCount() int { return c.pool.count() }
// open.
func (c *Client) Close() {
c.closing.Store(true)
c.cancel() // aborts in-flight pool dials
c.cancel() // aborts in-flight worker dials
c.mu.Lock()
fc := c.ctrl
c.mu.Unlock()