fix
This commit is contained in:
+8
-1
@@ -14,12 +14,19 @@ import (
|
||||
// startClient builds and starts an in-process client against the hub, with the
|
||||
// given mappings, returning the running client.
|
||||
func startClient(t *testing.T, hubAddr, psk string, maxConn int, mappings []client.Mapping) *client.Client {
|
||||
t.Helper()
|
||||
return startClientWithPing(t, hubAddr, psk, maxConn, 20000, mappings)
|
||||
}
|
||||
|
||||
// startClientWithPing is startClient with an explicit heartbeat interval, for
|
||||
// tests that need liveness detection to trigger quickly.
|
||||
func startClientWithPing(t *testing.T, hubAddr, psk string, maxConn, pingMs int, mappings []client.Mapping) *client.Client {
|
||||
t.Helper()
|
||||
cfg := &client.Config{
|
||||
Server: hubAddr,
|
||||
PSK: psk,
|
||||
MaxConn: maxConn,
|
||||
PingIntervalMs: 20000,
|
||||
PingIntervalMs: pingMs,
|
||||
Mappings: mappings,
|
||||
}
|
||||
c := client.New(cfg)
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/redapricot/client"
|
||||
"github.com/iceBear67/redapricot/client/wire"
|
||||
)
|
||||
|
||||
// blackholeRelay forwards TCP to the hub until it is switched off, after which
|
||||
// already-established pairs silently stop carrying bytes while their sockets
|
||||
// stay open. That is what a stateful middlebox looks like when it forgets a
|
||||
// flow: conntrack expiry, a firewall reload, or a cloud LB idle timeout. No FIN
|
||||
// and no RST ever reach either end, so nothing below the application layer can
|
||||
// notice.
|
||||
// Only flows that already existed when the switch is thrown go dark; new
|
||||
// connections are carried normally, exactly as when a middlebox forgets
|
||||
// established state but keeps forwarding fresh traffic.
|
||||
type blackholeRelay struct {
|
||||
addr string
|
||||
backend string
|
||||
gen atomic.Uint64
|
||||
mu sync.Mutex
|
||||
held []net.Conn
|
||||
}
|
||||
|
||||
func newBlackholeRelay(t *testing.T, backend string) *blackholeRelay {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("relay listen: %v", err)
|
||||
}
|
||||
r := &blackholeRelay{addr: ln.Addr().String(), backend: backend}
|
||||
t.Cleanup(func() {
|
||||
_ = ln.Close()
|
||||
r.mu.Lock()
|
||||
for _, c := range r.held {
|
||||
_ = c.Close()
|
||||
}
|
||||
r.mu.Unlock()
|
||||
})
|
||||
go func() {
|
||||
for {
|
||||
cli, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go r.handle(cli)
|
||||
}
|
||||
}()
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *blackholeRelay) handle(cli net.Conn) {
|
||||
born := r.gen.Load()
|
||||
up, err := net.Dial("tcp", r.backend)
|
||||
if err != nil {
|
||||
_ = cli.Close()
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.held = append(r.held, cli, up)
|
||||
r.mu.Unlock()
|
||||
pipe := func(dst, src net.Conn) {
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := src.Read(buf)
|
||||
if n > 0 && r.gen.Load() == born {
|
||||
if _, werr := dst.Write(buf[:n]); werr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
go pipe(up, cli)
|
||||
go pipe(cli, up)
|
||||
}
|
||||
|
||||
// blackhole strands every currently-established pair. Later connections are
|
||||
// unaffected.
|
||||
func (r *blackholeRelay) blackhole() { r.gen.Add(1) }
|
||||
|
||||
// TestBlackholedPathRecovers is the end-to-end regression guard for the
|
||||
// stability bug this hardening was written for: with the tunnel's path silently
|
||||
// dropped, the client used to notice nothing at all. Its read loops parked
|
||||
// forever, the dead worker conn stayed in the pool, the hub kept routing players
|
||||
// to a control session nobody was reading, and no player could connect again
|
||||
// until the client process was restarted.
|
||||
//
|
||||
// The heartbeats must now detect the silence, drop both sessions, and let the
|
||||
// existing reconnect path restore service on its own.
|
||||
func TestBlackholedPathRecovers(t *testing.T) {
|
||||
const psk = "e2e-blackhole"
|
||||
hubPort := freePort(t)
|
||||
hubAddr := fmt.Sprintf("127.0.0.1:%d", hubPort)
|
||||
startHub(t, hubPort, psk)
|
||||
dest := newMockDest(t, modeEcho)
|
||||
|
||||
// The client reaches the hub only through the relay; players connect to the
|
||||
// hub directly, as they would from the internet.
|
||||
relay := newBlackholeRelay(t, hubAddr)
|
||||
const pingMs = 400 // heartbeat timeout is 3x this
|
||||
c := startClientWithPing(t, relay.addr, psk, 4, pingMs, []client.Mapping{
|
||||
{Pattern: "mc.local", Destination: dest.addr},
|
||||
})
|
||||
|
||||
play := func(what string) error {
|
||||
pc, err := net.DialTimeout("tcp", hubAddr, 5*time.Second)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer pc.Close()
|
||||
if _, err := pc.Write(wire.BuildHandshake(767, "mc.local", 25565, 2)); err != nil {
|
||||
return err
|
||||
}
|
||||
msg := []byte(what)
|
||||
if _, err := pc.Write(msg); err != nil {
|
||||
return err
|
||||
}
|
||||
got := make([]byte, len(msg))
|
||||
_ = pc.SetReadDeadline(time.Now().Add(15 * time.Second))
|
||||
_, err = io.ReadFull(pc, got)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := play("before"); err != nil {
|
||||
t.Fatalf("baseline round-trip failed: %v", err)
|
||||
}
|
||||
|
||||
relay.blackhole()
|
||||
t.Log("path blackholed: no FIN, no RST, sockets held open")
|
||||
|
||||
// Wait for the heartbeats to fire, the sessions to be dropped, and the
|
||||
// control session to reconnect through a fresh relay pair.
|
||||
deadline := time.Now().Add(45 * time.Second)
|
||||
var lastErr error
|
||||
for time.Now().Before(deadline) {
|
||||
if lastErr = play("after"); lastErr == nil {
|
||||
t.Logf("recovered on its own; worker conns now %d", c.WorkerConnCount())
|
||||
return
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("client never recovered from the blackholed path (last error: %v)", lastErr)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user