initial commit
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"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()
|
||||
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)
|
||||
}
|
||||
cfg := fmt.Sprintf(`{"listen":"127.0.0.1:%d","psk":%q,"timestampWindowMs":30000,"pendingTimeoutMs":5000}`, port, psk)
|
||||
cfgPath := filepath.Join(t.TempDir(), "hub.json")
|
||||
if err := os.WriteFile(cfgPath, []byte(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
|
||||
)
|
||||
|
||||
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{}
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
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{}{}
|
||||
}()
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user