Files
redapricot/e2e/ratelimit_test.go
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

175 lines
5.1 KiB
Go

package e2e
import (
"fmt"
"net"
"testing"
"time"
"github.com/iceBear67/redapricot/client"
)
// expectClosedSoon fails unless conn is closed (or reset) within d. A player
// that entered pending stays open until pendingTimeoutMs, so a fast EOF is
// how we tell the limiter dropped the socket before match-side bookkeeping.
func expectClosedSoon(t *testing.T, conn net.Conn, d time.Duration) {
t.Helper()
_ = conn.SetReadDeadline(time.Now().Add(d))
n, err := conn.Read(make([]byte, 16))
if err == nil {
t.Fatalf("expected the hub to close the player, read %d bytes", n)
}
}
// TestPlayerBurstDropsExtraHandshakes: more arrivals than playerBurst from the
// same IP are closed after the handshake and never become pending.
func TestPlayerBurstDropsExtraHandshakes(t *testing.T) {
const psk = "e2e-rate-burst"
port := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
startHubCfg(t, port, psk, map[string]any{
"playerRatePerSec": 1,
"playerBurst": 2,
"maxPlayersPerIp": 64,
})
dest := newMockDest(t, modeEcho)
startClient(t, hubAddr, psk, 8, []client.Mapping{
{Pattern: "mc.local", Destination: dest.addr},
})
kept := make([]net.Conn, 0, 2)
defer func() {
for _, c := range kept {
_ = c.Close()
}
}()
for i := 0; i < 2; i++ {
pc := dialPlayer(t, hubAddr, "mc.local")
playerEcho(t, pc, []byte(fmt.Sprintf("ok-%d", i)))
kept = append(kept, pc)
}
// Tokens spent, first two still held: extras must die well inside pendingTimeout.
for i := 0; i < 2; i++ {
extra := dialPlayer(t, hubAddr, "mc.local")
expectClosedSoon(t, extra, 1500*time.Millisecond)
_ = extra.Close()
}
// The admitted players are unaffected.
playerEcho(t, kept[0], []byte("still-here"))
}
// TestMaxPlayersPerIpCapsConcurrentSockets: a second player from the same IP
// is refused while the first is still open, and admitted again after it closes.
func TestMaxPlayersPerIpCapsConcurrentSockets(t *testing.T) {
const psk = "e2e-rate-conc"
port := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
startHubCfg(t, port, psk, map[string]any{
"playerRatePerSec": 0,
"playerBurst": 16,
"maxPlayersPerIp": 1,
})
dest := newMockDest(t, modeEcho)
startClient(t, hubAddr, psk, 4, []client.Mapping{
{Pattern: "mc.local", Destination: dest.addr},
})
first := dialPlayer(t, hubAddr, "mc.local")
defer first.Close()
playerEcho(t, first, []byte("first"))
second := dialPlayer(t, hubAddr, "mc.local")
expectClosedSoon(t, second, 1500*time.Millisecond)
_ = second.Close()
playerEcho(t, first, []byte("still-first"))
_ = first.Close()
// The closeHandler runs on the hub event loop; give it a beat to release.
time.Sleep(200 * time.Millisecond)
third := dialPlayer(t, hubAddr, "mc.local")
defer third.Close()
playerEcho(t, third, []byte("after-release"))
}
// TestUnmatchedHostConsumesRateBudget: a hostname scan is not a free flood.
// Two unmatched handshakes spend the burst, so a later matching player is
// also dropped.
func TestUnmatchedHostConsumesRateBudget(t *testing.T) {
const psk = "e2e-rate-scan"
port := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
startHubCfg(t, port, psk, map[string]any{
"playerRatePerSec": 1,
"playerBurst": 2,
"maxPlayersPerIp": 64,
})
dest := newMockDest(t, modeEcho)
startClient(t, hubAddr, psk, 4, []client.Mapping{
{Pattern: "mc.local", Destination: dest.addr},
})
for i := 0; i < 2; i++ {
miss := dialPlayer(t, hubAddr, "no.such.host")
expectClosedSoon(t, miss, 1500*time.Millisecond)
_ = miss.Close()
}
matched := dialPlayer(t, hubAddr, "mc.local")
expectClosedSoon(t, matched, 1500*time.Millisecond)
_ = matched.Close()
}
// TestIntent17IgnoresPlayerLimiter: the control session and worker conns are
// Intent 17, so a limiter tight enough to refuse a second player must not
// prevent the client from connecting or taking over the first player.
func TestIntent17IgnoresPlayerLimiter(t *testing.T) {
const psk = "e2e-rate-intent17"
port := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
startHubCfg(t, port, psk, map[string]any{
"playerRatePerSec": 1,
"playerBurst": 1,
"maxPlayersPerIp": 1,
})
dest := newMockDest(t, modeEcho)
startClient(t, hubAddr, psk, 4, []client.Mapping{
{Pattern: "mc.local", Destination: dest.addr},
})
pc := dialPlayer(t, hubAddr, "mc.local")
defer pc.Close()
playerEcho(t, pc, []byte("intent17-ok"))
}
// TestPlayerLimiterOffSwitch: both knobs at 0 restore phase-1 behaviour —
// many players from 127.0.0.1 all get through.
func TestPlayerLimiterOffSwitch(t *testing.T) {
const psk = "e2e-rate-off"
const n = 6
port := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
startHubCfg(t, port, psk, map[string]any{
"playerRatePerSec": 0,
"maxPlayersPerIp": 0,
})
dest := newMockDest(t, modeEcho)
startClient(t, hubAddr, psk, n, []client.Mapping{
{Pattern: "mc.local", Destination: dest.addr},
})
conns := make([]net.Conn, 0, n)
defer func() {
for _, c := range conns {
_ = c.Close()
}
}()
for i := 0; i < n; i++ {
pc := dialPlayer(t, hubAddr, "mc.local")
playerEcho(t, pc, []byte(fmt.Sprintf("off-%d", i)))
conns = append(conns, pc)
}
}