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

308 lines
9.5 KiB
Go

package e2e
import (
"context"
"fmt"
"io"
"net"
"testing"
"time"
"github.com/iceBear67/redapricot/client"
)
// startClient builds and starts an in-process client against the hub, with the
// given mappings, returning the running client.
func startClient(t *testing.T, hubAddr, psk string, maxTunnels int, mappings []client.Mapping) *client.Client {
t.Helper()
return startClientWithPing(t, hubAddr, psk, maxTunnels, 20000, mappings)
}
// startClientWithPing is startClient with an explicit heartbeat interval, for
// tests that need liveness detection to trigger quickly.
func startClientWithPing(t *testing.T, hubAddr, psk string, maxTunnels, pingMs int, mappings []client.Mapping) *client.Client {
t.Helper()
return startClientCfg(t, &client.Config{
Server: hubAddr,
PSK: psk,
MaxTunnels: maxTunnels,
PingIntervalMs: pingMs,
Mappings: mappings,
})
}
// startClientCfg runs an in-process client from a fully-specified config, for
// tests that need a knob the shorthand helpers do not expose.
func startClientCfg(t *testing.T, cfg *client.Config) *client.Client {
t.Helper()
c := client.New(cfg)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(func() {
cancel()
c.Close()
})
if err := c.Start(ctx); err != nil {
t.Fatalf("client start: %v", err)
}
// Give the control-session registration a moment to reach the hub.
time.Sleep(200 * time.Millisecond)
return c
}
// TestRoundTrip verifies the full path plus verbatim handshake forwarding and
// case-insensitive pattern matching.
func TestRoundTrip(t *testing.T) {
const psk = "e2e-roundtrip"
port := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
startHub(t, port, psk)
dest := newMockDest(t, modeEcho)
startClient(t, hubAddr, psk, 4, []client.Mapping{
{Pattern: "mc.local", Destination: dest.addr},
})
// The player connects using mixed case; the hub matches case-insensitively
// but forwards the address verbatim.
pc := dialPlayer(t, hubAddr, "MC.Local")
defer pc.Close()
playerEcho(t, pc, []byte("hello redapricot"))
ev := dest.waitEvent(t, 5*time.Second)
if ev.handshakeAddr != "MC.Local" {
t.Fatalf("destination saw handshake address %q, want verbatim %q", ev.handshakeAddr, "MC.Local")
}
if ev.hasProxy {
t.Fatalf("did not expect a PROXY header for a non-proxy mapping")
}
}
// TestRegexPatternMatch verifies a wildcard regex pattern routes a matching
// player (whole-hostname, case-insensitive) and drops a non-matching one, and
// that the client maps the hub-echoed pattern back to its destination.
func TestRegexPatternMatch(t *testing.T) {
const psk = "e2e-regex"
port := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
startHub(t, port, psk)
dest := newMockDest(t, modeEcho)
startClient(t, hubAddr, psk, 2, []client.Mapping{
{Pattern: `mc\d+\.local`, Destination: dest.addr},
})
// Matches the regex (digit wildcard, mixed case); the whole hostname matches.
pc := dialPlayer(t, hubAddr, "MC7.Local")
defer pc.Close()
playerEcho(t, pc, []byte("regex hello"))
ev := dest.waitEvent(t, 5*time.Second)
if ev.handshakeAddr != "MC7.Local" {
t.Fatalf("destination saw handshake address %q, want verbatim %q", ev.handshakeAddr, "MC7.Local")
}
// A host that does not fully match the pattern is dropped ('\d+' needs a digit).
pc2 := dialPlayer(t, hubAddr, "mc.local")
defer pc2.Close()
_ = pc2.SetReadDeadline(time.Now().Add(3 * time.Second))
if _, err := pc2.Read(make([]byte, 16)); err == nil {
t.Fatalf("expected the hub to drop a host that does not match the regex")
}
}
// TestLargeTransfer pushes a multi-megabyte payload both ways to exercise
// framing and back-pressure.
func TestLargeTransfer(t *testing.T) {
const psk = "e2e-large"
port := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
startHub(t, port, psk)
dest := newMockDest(t, modeEcho)
startClient(t, hubAddr, psk, 2, []client.Mapping{
{Pattern: "mc.local", Destination: dest.addr},
})
pc := dialPlayer(t, hubAddr, "mc.local")
defer pc.Close()
payload := make([]byte, 3*1024*1024)
for i := range payload {
payload[i] = byte(i*31 + 7)
}
// Write from a goroutine while reading back concurrently to avoid deadlock.
writeErr := make(chan error, 1)
go func() {
_, err := pc.Write(payload)
writeErr <- err
}()
got := make([]byte, len(payload))
_ = pc.SetReadDeadline(time.Now().Add(30 * time.Second))
if _, err := io.ReadFull(pc, got); err != nil {
t.Fatalf("read large echo: %v", err)
}
if err := <-writeErr; err != nil {
t.Fatalf("write large payload: %v", err)
}
for i := range payload {
if got[i] != payload[i] {
t.Fatalf("large echo mismatch at byte %d", i)
}
}
}
// TestEachPlayerGetsOwnWorker confirms the 1:1 rule: N concurrent players
// produce N worker connections, and the maxTunnels cap is honoured.
func TestEachPlayerGetsOwnWorker(t *testing.T) {
const psk = "e2e-concurrent"
const n = 8
const maxTunnels = 16
port := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
startHub(t, port, psk)
dest := newMockDest(t, modeEcho)
c := startClient(t, hubAddr, psk, maxTunnels, []client.Mapping{
{Pattern: "mc.local", Destination: dest.addr},
})
conns := make([]net.Conn, 0, n)
defer func() {
for _, pc := range conns {
_ = pc.Close()
}
}()
for i := 0; i < n; i++ {
pc := dialPlayer(t, hubAddr, "mc.local")
playerEcho(t, pc, []byte(fmt.Sprintf("hello-%d", i)))
conns = append(conns, pc)
}
got := c.WorkerConnCount()
if got != n {
t.Fatalf("expected %d worker conns for %d players, got %d", n, n, got)
}
if got > maxTunnels {
t.Fatalf("worker conns %d exceed maxTunnels %d", got, maxTunnels)
}
t.Logf("%d players on %d worker conn(s)", n, got)
}
// TestProxyProtocol checks that the client prepends a correct HAProxy v2 header
// carrying the player's real source address.
func TestProxyProtocol(t *testing.T) {
const psk = "e2e-proxy"
port := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
startHub(t, port, psk)
dest := newMockDest(t, modeEcho)
startClient(t, hubAddr, psk, 2, []client.Mapping{
{Pattern: "mc.local", Destination: dest.addr, ProxyProtocol: true},
})
pc := dialPlayer(t, hubAddr, "mc.local")
defer pc.Close()
playerEcho(t, pc, []byte("proxied hello"))
ev := dest.waitEvent(t, 5*time.Second)
if !ev.hasProxy {
t.Fatalf("expected a PROXY v2 header")
}
localPort := pc.LocalAddr().(*net.TCPAddr).Port
if ev.proxy.srcPort != localPort {
t.Fatalf("proxy src port %d, want player local port %d", ev.proxy.srcPort, localPort)
}
if !ev.proxy.srcIP.IsLoopback() {
t.Fatalf("proxy src ip %v, want loopback", ev.proxy.srcIP)
}
t.Logf("PROXY v2: src=%v:%d dst=%v:%d", ev.proxy.srcIP, ev.proxy.srcPort, ev.proxy.dstIP, ev.proxy.dstPort)
}
// TestPlayerDisconnectPropagates: player closing → hub FIN → destination EOF.
func TestPlayerDisconnectPropagates(t *testing.T) {
const psk = "e2e-disc"
port := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
startHub(t, port, psk)
dest := newMockDest(t, modeEcho)
startClient(t, hubAddr, psk, 2, []client.Mapping{
{Pattern: "mc.local", Destination: dest.addr},
})
pc := dialPlayer(t, hubAddr, "mc.local")
playerEcho(t, pc, []byte("bye soon"))
_ = pc.Close()
select {
case <-dest.connClosed:
case <-time.After(5 * time.Second):
t.Fatalf("destination did not observe the player disconnect")
}
}
// TestDestinationDisconnectPropagates: destination closing → client FIN → player EOF.
func TestDestinationDisconnectPropagates(t *testing.T) {
const psk = "e2e-destclose"
port := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
startHub(t, port, psk)
dest := newMockDest(t, modeEchoOnceClose)
startClient(t, hubAddr, psk, 2, []client.Mapping{
{Pattern: "mc.local", Destination: dest.addr},
})
pc := dialPlayer(t, hubAddr, "mc.local")
defer pc.Close()
payload := []byte("one shot")
if _, err := pc.Write(payload); err != nil {
t.Fatalf("write: %v", err)
}
got := make([]byte, len(payload))
_ = pc.SetReadDeadline(time.Now().Add(5 * time.Second))
if _, err := io.ReadFull(pc, got); err != nil {
t.Fatalf("read echo: %v", err)
}
// Destination has now closed; the player's next read must reach EOF.
_ = pc.SetReadDeadline(time.Now().Add(5 * time.Second))
if _, err := pc.Read(make([]byte, 16)); err == nil {
t.Fatalf("expected EOF after destination closed")
}
}
// TestBadPSK: a client with the wrong PSK cannot establish a control session.
func TestBadPSK(t *testing.T) {
const psk = "e2e-correct"
port := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
startHub(t, port, psk)
dest := newMockDest(t, modeEcho)
cfg := &client.Config{
Server: hubAddr,
PSK: "totally-wrong",
MaxTunnels: 2,
PingIntervalMs: 20000,
Mappings: []client.Mapping{{Pattern: "mc.local", Destination: dest.addr}},
}
c := client.New(cfg)
ctx, cancel := context.WithCancel(context.Background())
defer func() { cancel(); c.Close() }()
if err := c.Start(ctx); err == nil {
t.Fatalf("expected control session to fail with a wrong PSK")
}
}
// TestUnmatchedPattern: a player using an unregistered address is dropped.
func TestUnmatchedPattern(t *testing.T) {
const psk = "e2e-nomatch"
port := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
startHub(t, port, psk)
dest := newMockDest(t, modeEcho)
startClient(t, hubAddr, psk, 2, []client.Mapping{
{Pattern: "mc.local", Destination: dest.addr},
})
pc := dialPlayer(t, hubAddr, "unknown.host")
defer pc.Close()
_ = pc.SetReadDeadline(time.Now().Add(5 * time.Second))
if _, err := pc.Read(make([]byte, 16)); err == nil {
t.Fatalf("expected the hub to drop an unmatched player")
}
}