335 lines
7.8 KiB
Go
335 lines
7.8 KiB
Go
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)
|
|
}
|
|
}
|