Files
redapricot/e2e/velocity_test.go
2026-07-25 16:33:28 +08:00

220 lines
6.1 KiB
Go

package e2e
import (
"bufio"
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net"
"testing"
"time"
"github.com/iceBear67/redapricot/client"
"github.com/iceBear67/redapricot/client/wire"
)
// ---- mock velocity-forwarding destination ----
// veloEvent is what the mock backend saw in the (verified) forwarding payload.
type veloEvent struct {
version int
ip string
uuid []byte
name string
err error
}
type veloDest struct {
addr string
secret string
success []byte // the Login Success packet the backend sends after the exchange
events chan veloEvent
}
// newVeloDest starts a mock backend that requires Velocity modern forwarding:
// it reads the handshake and Login Start, sends the velocity:player_info
// query (with a negative message id, as Paper's random ids often are), verifies
// the HMAC-signed response, and finally sends a recognizable Login Success.
func newVeloDest(t *testing.T, secret string) *veloDest {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("velo dest listen: %v", err)
}
d := &veloDest{
addr: ln.Addr().String(),
secret: secret,
success: mcPacket(wire.NewWriter().
VarInt(0x02). // Login Success
Bytes(bytes.Repeat([]byte{0x42}, 16)).
String("e2ePlayer").
VarInt(0).
Out()),
events: make(chan veloEvent, 16),
}
t.Cleanup(func() { _ = ln.Close() })
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go d.handle(conn)
}
}()
return d
}
const veloMsgID = -777
func (d *veloDest) handle(conn net.Conn) {
defer conn.Close()
ev := d.exchange(conn)
d.events <- ev
if ev.err == nil {
_, _ = conn.Write(d.success)
}
_, _ = io.Copy(io.Discard, conn) // hold the connection until the peer closes
}
func (d *veloDest) exchange(conn net.Conn) veloEvent {
br := bufio.NewReader(conn)
if _, err := readMCPacket(br); err != nil { // handshake
return veloEvent{err: fmt.Errorf("read handshake: %w", err)}
}
if _, err := readMCPacket(br); err != nil { // login start
return veloEvent{err: fmt.Errorf("read login start: %w", err)}
}
query := mcPacket(wire.NewWriter().
VarInt(0x04). // Login Plugin Request
VarInt(veloMsgID).
String("velocity:player_info").
U8(0x04). // max supported forwarding version
Out())
if _, err := conn.Write(query); err != nil {
return veloEvent{err: err}
}
resp, err := readMCPacket(br)
if err != nil {
return veloEvent{err: fmt.Errorf("read plugin response: %w", err)}
}
r := wire.NewReader(resp)
id, _ := r.VarInt()
if id != 0x02 {
return veloEvent{err: fmt.Errorf("expected Login Plugin Response, got packet %#x", id)}
}
msgID, _ := r.VarInt()
if !bytes.Equal(wire.AppendVarInt(nil, msgID), wire.AppendVarInt(nil, veloMsgID)) {
return veloEvent{err: fmt.Errorf("message id not echoed: got %d", msgID)}
}
okFlag, _ := r.U8()
if okFlag != 1 {
return veloEvent{err: fmt.Errorf("response marked unsuccessful")}
}
sig, err := r.Bytes(32)
if err != nil {
return veloEvent{err: fmt.Errorf("missing signature: %w", err)}
}
payload := r.Remaining()
mac := hmac.New(sha256.New, []byte(d.secret))
mac.Write(payload)
if !hmac.Equal(sig, mac.Sum(nil)) {
return veloEvent{err: fmt.Errorf("forwarding signature does not verify")}
}
pr := wire.NewReader(payload)
var ev veloEvent
ev.version, _ = pr.VarInt()
ev.ip, _ = pr.String()
ev.uuid, _ = pr.Bytes(16)
ev.name, err = pr.String()
if err != nil {
return veloEvent{err: fmt.Errorf("truncated payload: %w", err)}
}
if props, err := pr.VarInt(); err != nil || props != 0 || len(pr.Remaining()) != 0 {
return veloEvent{err: fmt.Errorf("unexpected properties/trailer in payload")}
}
return ev
}
func mcPacket(body []byte) []byte {
return append(wire.AppendVarInt(nil, len(body)), body...)
}
func readMCPacket(br *bufio.Reader) ([]byte, error) {
n, err := wire.ReadVarInt(br)
if err != nil {
return nil, err
}
if n <= 0 || n > 1<<20 {
return nil, fmt.Errorf("bad packet length %d", n)
}
pkt := make([]byte, n)
if _, err := io.ReadFull(br, pkt); err != nil {
return nil, err
}
return pkt, nil
}
// ---- test ----
// TestVelocityForwarding drives a full player login through the hub and a
// velocity-enabled mapping: the backend's velocity:player_info query must be
// answered by the client (never reaching the player), carrying the player's
// real IP, username and UUID, and the player's first bytes must be the
// backend's Login Success.
func TestVelocityForwarding(t *testing.T) {
const psk = "e2e-velocity"
const secret = "velo-forwarding-secret"
port := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
startHub(t, port, psk)
dest := newVeloDest(t, secret)
startClient(t, hubAddr, psk, 2, []client.Mapping{
{Pattern: `velo\.local`, Destination: dest.addr, VelocitySecret: secret},
})
pc := dialPlayer(t, hubAddr, "velo.local") // protocol 767, login intent
defer pc.Close()
uuid, _ := hex.DecodeString("00112233445566778899aabbccddeeff")
loginStart := mcPacket(wire.NewWriter().VarInt(0x00).String("e2ePlayer").Bytes(uuid).Out())
if _, err := pc.Write(loginStart); err != nil {
t.Fatalf("player login start: %v", err)
}
var ev veloEvent
select {
case ev = <-dest.events:
case <-time.After(10 * time.Second):
t.Fatalf("backend never completed the forwarding exchange")
}
if ev.err != nil {
t.Fatalf("backend rejected the forwarding exchange: %v", ev.err)
}
if ev.version != 4 {
t.Fatalf("forwarding version = %d, want 4 (lazy session)", ev.version)
}
if ev.ip != "127.0.0.1" {
t.Fatalf("forwarded IP = %q, want the player's real 127.0.0.1", ev.ip)
}
if ev.name != "e2ePlayer" || !bytes.Equal(ev.uuid, uuid) {
t.Fatalf("forwarded profile = %s/%x, want e2ePlayer/%x", ev.name, ev.uuid, uuid)
}
// The player must see the Login Success as its very first bytes — the
// velocity query must have been swallowed by the client.
got := make([]byte, len(dest.success))
_ = pc.SetReadDeadline(time.Now().Add(10 * time.Second))
if _, err := io.ReadFull(pc, got); err != nil {
t.Fatalf("player read login success: %v", err)
}
if !bytes.Equal(got, dest.success) {
t.Fatalf("player's first bytes are not the Login Success:\n got %x\nwant %x", got, dest.success)
}
}