package e2e import ( "bufio" "bytes" "encoding/binary" "encoding/json" "fmt" "io" "net" "os" "os/exec" "path/filepath" "runtime" "sync" "testing" "time" "github.com/iceBear67/redapricot/client/wire" ) // ---- repo / toolchain discovery ---- func repoRoot() string { _, file, _, _ := runtime.Caller(0) return filepath.Dir(filepath.Dir(file)) // e2e/ -> repo root } func resolveJava(t *testing.T) string { t.Helper() if jh := os.Getenv("JAVA_HOME"); jh != "" { p := filepath.Join(jh, "bin", "java") if _, err := os.Stat(p); err == nil { return p } } if home, err := os.UserHomeDir(); err == nil { p := filepath.Join(home, ".sdkman/candidates/java/current/bin/java") if _, err := os.Stat(p); err == nil { return p } } if p, err := exec.LookPath("java"); err == nil { return p } t.Skip("java not found (set JAVA_HOME)") return "" } // ---- hub subprocess ---- func freePort(t *testing.T) int { t.Helper() ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("free port: %v", err) } defer ln.Close() return ln.Addr().(*net.TCPAddr).Port } // startHub launches the Java hub on the given port and blocks until it accepts // connections. The process is killed on test cleanup. func startHub(t *testing.T, port int, psk string) { t.Helper() startHubCfg(t, port, psk, nil) } // startHubCfg is startHub with extra config keys merged over the defaults, for // tests that need to tune a hub-side knob. func startHubCfg(t *testing.T, port int, psk string, extra map[string]any) { t.Helper() install := filepath.Join(repoRoot(), "server", "build", "install", "redapricot-server") if _, err := os.Stat(install); err != nil { t.Fatalf("hub not built at %s (run scripts/build.sh first): %v", install, err) } settings := map[string]any{ "listen": fmt.Sprintf("127.0.0.1:%d", port), "psk": psk, "timestampWindowMs": 30000, "pendingTimeoutMs": 5000, } for k, v := range extra { settings[k] = v } cfg, err := json.Marshal(settings) if err != nil { t.Fatal(err) } cfgPath := filepath.Join(t.TempDir(), "hub.json") if err := os.WriteFile(cfgPath, cfg, 0o644); err != nil { t.Fatal(err) } java := resolveJava(t) cp := filepath.Join(install, "lib", "*") cmd := exec.Command(java, "-cp", cp, "io.icybear.redapricot.Main", cfgPath) cmd.Stdout = &prefixWriter{prefix: "[hub] "} cmd.Stderr = cmd.Stdout if err := cmd.Start(); err != nil { t.Fatalf("start hub: %v", err) } t.Cleanup(func() { _ = cmd.Process.Kill() _, _ = cmd.Process.Wait() }) waitPort(t, fmt.Sprintf("127.0.0.1:%d", port), 30*time.Second) } func waitPort(t *testing.T, addr string, timeout time.Duration) { t.Helper() deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { c, err := net.DialTimeout("tcp", addr, 500*time.Millisecond) if err == nil { _ = c.Close() return } time.Sleep(100 * time.Millisecond) } t.Fatalf("hub did not come up on %s within %s", addr, timeout) } type prefixWriter struct { prefix string mu sync.Mutex buf []byte } func (w *prefixWriter) Write(p []byte) (int, error) { w.mu.Lock() defer w.mu.Unlock() w.buf = append(w.buf, p...) for { i := bytes.IndexByte(w.buf, '\n') if i < 0 { break } fmt.Fprintf(os.Stderr, "%s%s\n", w.prefix, w.buf[:i]) w.buf = w.buf[i+1:] } return len(p), nil } // ---- mock Minecraft destination ---- type destMode int const ( modeEcho destMode = iota // echo every post-handshake byte modeEchoOnceClose // echo one read, then close the connection modeBlackhole // accept but never read: immediate write back-pressure ) type proxyInfo struct { srcIP net.IP srcPort int dstIP net.IP dstPort int } type destEvent struct { handshakeAddr string hasProxy bool proxy proxyInfo } var proxyV2Signature = []byte{ 0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A, } type mockDest struct { ln net.Listener addr string mode destMode events chan destEvent connClosed chan struct{} done chan struct{} } func newMockDest(t *testing.T, mode destMode) *mockDest { t.Helper() ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("mock dest listen: %v", err) } d := &mockDest{ ln: ln, addr: ln.Addr().String(), mode: mode, events: make(chan destEvent, 128), connClosed: make(chan struct{}, 128), done: make(chan struct{}), } t.Cleanup(func() { _ = ln.Close() close(d.done) }) go d.serve() return d } func (d *mockDest) serve() { for { conn, err := d.ln.Accept() if err != nil { return } go d.handle(conn) } } func (d *mockDest) handle(conn net.Conn) { defer func() { _ = conn.Close() d.connClosed <- struct{}{} }() if d.mode == modeBlackhole { <-d.done // hold the connection open without ever reading return } br := bufio.NewReader(conn) var ev destEvent if sig, err := br.Peek(12); err == nil && bytes.Equal(sig, proxyV2Signature) { hdr := make([]byte, 16) if _, err := io.ReadFull(br, hdr); err != nil { return } famProto := hdr[13] addrLen := int(binary.BigEndian.Uint16(hdr[14:16])) block := make([]byte, addrLen) if _, err := io.ReadFull(br, block); err != nil { return } ev.hasProxy = true ev.proxy = parseProxyAddr(famProto, block) } // Minecraft handshake packet. pktLen, err := wire.ReadVarInt(br) if err != nil { return } pkt := make([]byte, pktLen) if _, err := io.ReadFull(br, pkt); err != nil { return } rr := wire.NewReader(pkt) _, _ = rr.VarInt() // packet id _, _ = rr.VarInt() // protocol version addr, _ := rr.String() ev.handshakeAddr = addr d.events <- ev switch d.mode { case modeEcho: _, _ = io.Copy(conn, br) // echo until the peer closes case modeEchoOnceClose: buf := make([]byte, 4096) n, _ := br.Read(buf) if n > 0 { _, _ = conn.Write(buf[:n]) } // fallthrough to close via defer } } func (d *mockDest) waitEvent(t *testing.T, timeout time.Duration) destEvent { t.Helper() select { case ev := <-d.events: return ev case <-time.After(timeout): t.Fatalf("destination received no connection within %s", timeout) return destEvent{} } } func parseProxyAddr(famProto byte, block []byte) proxyInfo { var pi proxyInfo switch famProto { case 0x11: // TCP/IPv4 if len(block) >= 12 { pi.srcIP = net.IP(block[0:4]) pi.dstIP = net.IP(block[4:8]) pi.srcPort = int(binary.BigEndian.Uint16(block[8:10])) pi.dstPort = int(binary.BigEndian.Uint16(block[10:12])) } case 0x21: // TCP/IPv6 if len(block) >= 36 { pi.srcIP = net.IP(block[0:16]) pi.dstIP = net.IP(block[16:32]) pi.srcPort = int(binary.BigEndian.Uint16(block[32:34])) pi.dstPort = int(binary.BigEndian.Uint16(block[34:36])) } } return pi } // ---- player simulator ---- // dialPlayer connects to the hub and sends a Minecraft Handshake (login intent) // with the given server address, returning the open connection. func dialPlayer(t *testing.T, hubAddr, address string) net.Conn { t.Helper() conn, err := net.DialTimeout("tcp", hubAddr, 5*time.Second) if err != nil { t.Fatalf("player dial: %v", err) } hs := wire.BuildHandshake(767, address, 25565, 2) // intent 2 = login if _, err := conn.Write(hs); err != nil { t.Fatalf("player handshake: %v", err) } return conn } // playerEcho sends payload and asserts the same bytes come back (proving the // full player↔destination round-trip works). func playerEcho(t *testing.T, conn net.Conn, payload []byte) { t.Helper() if _, err := conn.Write(payload); err != nil { t.Fatalf("player write: %v", err) } got := make([]byte, len(payload)) _ = conn.SetReadDeadline(time.Now().Add(10 * time.Second)) if _, err := io.ReadFull(conn, got); err != nil { t.Fatalf("player read echo: %v", err) } _ = conn.SetReadDeadline(time.Time{}) if !bytes.Equal(got, payload) { t.Fatalf("echo mismatch: sent %q got %q", payload, got) } }