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") } }