Files
redapricot/e2e/harness_test.go
T
2026-08-15 17:31:35 +08:00

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