311 lines
9.7 KiB
Go
311 lines
9.7 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, maxConn int, mappings []client.Mapping) *client.Client {
|
|
t.Helper()
|
|
return startClientWithPing(t, hubAddr, psk, maxConn, 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, maxConn, pingMs int, mappings []client.Mapping) *client.Client {
|
|
t.Helper()
|
|
return startClientCfg(t, &client.Config{
|
|
Server: hubAddr,
|
|
PSK: psk,
|
|
MaxConn: maxConn,
|
|
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 mux
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestConcurrentStreamsUseMultipleConns confirms the least-loaded allocator
|
|
// grows the pool breadth-first: concurrent streams spread over several worker
|
|
// connections rather than stacking on one, without ever exceeding maxConn.
|
|
func TestConcurrentStreamsUseMultipleConns(t *testing.T) {
|
|
const psk = "e2e-concurrent"
|
|
const n = 20
|
|
const maxConn = 4
|
|
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, maxConn, []client.Mapping{
|
|
{Pattern: "mc.local", Destination: dest.addr},
|
|
})
|
|
|
|
conns := make([]net.Conn, 0, n)
|
|
defer func() {
|
|
for _, pc := range conns {
|
|
_ = pc.Close()
|
|
}
|
|
}()
|
|
// Establish streams sequentially so allocation is deterministic; keep them
|
|
// all open to hold streams active.
|
|
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 < 2 {
|
|
t.Fatalf("expected >=2 worker conns for %d concurrent streams, got %d", n, got)
|
|
}
|
|
if got > maxConn {
|
|
t.Fatalf("worker conns %d exceed maxConn %d", got, maxConn)
|
|
}
|
|
t.Logf("%d concurrent streams spread over %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",
|
|
MaxConn: 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")
|
|
}
|
|
}
|