316 lines
8.5 KiB
Go
316 lines
8.5 KiB
Go
package rcon_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"felis.lolicon.best/internal/rcon"
|
|
)
|
|
|
|
// fakeRCON is a minimal Source RCON server used to exercise the client
|
|
// hermetically (no real Minecraft server, no network beyond loopback).
|
|
type fakeRCON struct {
|
|
ln net.Listener
|
|
password string
|
|
replies map[string]string
|
|
wg sync.WaitGroup
|
|
// hang leaves every command unanswered; doubleEnd answers the end marker
|
|
// twice, as a Source-engine server does.
|
|
hang, doubleEnd bool
|
|
}
|
|
|
|
func startFakeRCON(t *testing.T, password string, replies map[string]string) *fakeRCON {
|
|
t.Helper()
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
f := &fakeRCON{ln: ln, password: password, replies: replies}
|
|
f.wg.Add(1)
|
|
go f.serve()
|
|
return f
|
|
}
|
|
|
|
func (f *fakeRCON) addr() string { return f.ln.Addr().String() }
|
|
|
|
func (f *fakeRCON) stop() {
|
|
f.ln.Close()
|
|
f.wg.Wait()
|
|
}
|
|
|
|
func (f *fakeRCON) serve() {
|
|
defer f.wg.Done()
|
|
for {
|
|
conn, err := f.ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
go f.handle(conn)
|
|
}
|
|
}
|
|
|
|
func (f *fakeRCON) handle(conn net.Conn) {
|
|
defer conn.Close()
|
|
authed := false
|
|
for {
|
|
id, typ, body, err := readFramePacket(conn)
|
|
if err != nil {
|
|
return
|
|
}
|
|
switch typ {
|
|
case 3: // AUTH
|
|
if body == f.password {
|
|
authed = true
|
|
_ = writeFramePacket(conn, id, 2, "") // AUTH_RESPONSE, echo id
|
|
} else {
|
|
_ = writeFramePacket(conn, -1, 2, "") // failure
|
|
}
|
|
case 2: // EXECCOMMAND
|
|
if !authed {
|
|
_ = writeFramePacket(conn, -1, 0, "")
|
|
continue
|
|
}
|
|
if f.hang {
|
|
continue
|
|
}
|
|
// Minecraft drops a connection whose read holds more than the one
|
|
// packet: a client that pipelines its next packet is cut off.
|
|
if pipelined(conn) {
|
|
return
|
|
}
|
|
// Minecraft splits a reply into 4096-byte bodies, even mid-rune.
|
|
reply := f.replies[body]
|
|
for len(reply) > 4096 {
|
|
_ = writeFramePacket(conn, id, 0, reply[:4096])
|
|
reply = reply[4096:]
|
|
}
|
|
_ = writeFramePacket(conn, id, 0, reply)
|
|
default:
|
|
if f.hang {
|
|
continue
|
|
}
|
|
_ = writeFramePacket(conn, id, 0, "Unknown request 0")
|
|
if f.doubleEnd {
|
|
_ = writeFramePacket(conn, id, 0, "")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// pipelined reports whether the client already sent more bytes behind the
|
|
// packet just read.
|
|
func pipelined(conn net.Conn) bool {
|
|
_ = conn.SetReadDeadline(time.Now().Add(30 * time.Millisecond))
|
|
defer conn.SetReadDeadline(time.Time{})
|
|
var b [1]byte
|
|
n, _ := conn.Read(b[:])
|
|
return n > 0
|
|
}
|
|
|
|
func writeFramePacket(w io.Writer, id, typ int32, body string) error {
|
|
b := []byte(body)
|
|
length := int32(4 + 4 + len(b) + 2)
|
|
buf := make([]byte, 0, 4+length)
|
|
buf = putI32(buf, length)
|
|
buf = putI32(buf, id)
|
|
buf = putI32(buf, typ)
|
|
buf = append(buf, b...)
|
|
buf = append(buf, 0, 0)
|
|
_, err := w.Write(buf)
|
|
return err
|
|
}
|
|
|
|
func readFramePacket(r io.Reader) (id, typ int32, body string, err error) {
|
|
var lenBuf [4]byte
|
|
if _, err = io.ReadFull(r, lenBuf[:]); err != nil {
|
|
return 0, 0, "", err
|
|
}
|
|
length := int32(binary.LittleEndian.Uint32(lenBuf[:]))
|
|
payload := make([]byte, length)
|
|
if _, err = io.ReadFull(r, payload); err != nil {
|
|
return 0, 0, "", err
|
|
}
|
|
id = int32(binary.LittleEndian.Uint32(payload[0:4]))
|
|
typ = int32(binary.LittleEndian.Uint32(payload[4:8]))
|
|
body = string(payload[8 : length-2])
|
|
return id, typ, body, nil
|
|
}
|
|
|
|
func putI32(buf []byte, v int32) []byte {
|
|
var b [4]byte
|
|
binary.LittleEndian.PutUint32(b[:], uint32(v))
|
|
return append(buf, b[:]...)
|
|
}
|
|
|
|
func TestDialAndExecute(t *testing.T) {
|
|
want := "There are 0 of a max of 20 players online:"
|
|
f := startFakeRCON(t, "s3cret", map[string]string{"list": want})
|
|
defer f.stop()
|
|
|
|
c, err := rcon.Dial(f.addr(), "s3cret", 2*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
defer c.Close()
|
|
|
|
got, err := c.Execute("list")
|
|
if err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
if got != want {
|
|
t.Errorf("Execute(list) = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// A command goes out in one packet of at most 4096 bytes; a longer one is
|
|
// refused before anything is sent.
|
|
func TestExecuteRefusesAnOversizeCommand(t *testing.T) {
|
|
longest := strings.Repeat("a", 4096-10) // 10: id, type, two terminators
|
|
f := startFakeRCON(t, "s3cret", map[string]string{longest: "ok"})
|
|
defer f.stop()
|
|
|
|
c, err := rcon.Dial(f.addr(), "s3cret", 2*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
defer c.Close()
|
|
|
|
if got, err := c.Execute(longest); err != nil || got != "ok" {
|
|
t.Fatalf("Execute(%d bytes) = %q, %v; want ok", len(longest), got, err)
|
|
}
|
|
if _, err := c.Execute(longest + "a"); err == nil || !strings.Contains(err.Error(), "too large") {
|
|
t.Fatalf("Execute(%d bytes) err = %v, want the too-large refusal", len(longest)+1, err)
|
|
}
|
|
}
|
|
|
|
func TestDialAuthFailure(t *testing.T) {
|
|
f := startFakeRCON(t, "correct-horse", nil)
|
|
defer f.stop()
|
|
|
|
_, err := rcon.Dial(f.addr(), "wrong-password", 2*time.Second)
|
|
if !errors.Is(err, rcon.ErrAuthFailed) {
|
|
t.Fatalf("Dial with wrong password: got %v, want ErrAuthFailed", err)
|
|
}
|
|
}
|
|
|
|
func TestDialUnreachable(t *testing.T) {
|
|
// Reserved TEST-NET-1 address: connect should fail fast within the timeout.
|
|
_, err := rcon.Dial("192.0.2.1:25575", "x", 200*time.Millisecond)
|
|
if err == nil {
|
|
t.Fatal("Dial to unreachable host: expected error, got nil")
|
|
}
|
|
if errors.Is(err, rcon.ErrAuthFailed) {
|
|
t.Fatalf("Dial to unreachable host: got ErrAuthFailed, want a dial error")
|
|
}
|
|
}
|
|
|
|
func TestExecuteGracefulShutdownSequence(t *testing.T) {
|
|
// A save followed by a second command on the same connection: the reply
|
|
// ids must line up across consecutive Executes.
|
|
f := startFakeRCON(t, "pw", map[string]string{
|
|
"save-all flush": "Saved the game",
|
|
"stop": "Stopping the server",
|
|
})
|
|
defer f.stop()
|
|
|
|
c, err := rcon.Dial(f.addr(), "pw", 2*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
defer c.Close()
|
|
|
|
if out, err := c.Execute("save-all flush"); err != nil || out != "Saved the game" {
|
|
t.Fatalf("save-all flush = %q, %v", out, err)
|
|
}
|
|
if out, err := c.Execute("stop"); err != nil || out != "Stopping the server" {
|
|
t.Fatalf("stop = %q, %v", out, err)
|
|
}
|
|
}
|
|
|
|
func TestExecuteReassemblesALongReply(t *testing.T) {
|
|
// 3000 three-byte runes: 9000 bytes over three packets, split mid-rune.
|
|
long := strings.Repeat("封", 3000)
|
|
f := startFakeRCON(t, "pw", map[string]string{"banlist": long})
|
|
defer f.stop()
|
|
c, err := rcon.Dial(f.addr(), "pw", time.Second)
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
defer c.Close()
|
|
got, err := c.Execute("banlist")
|
|
if err != nil {
|
|
t.Fatalf("execute: %v", err)
|
|
}
|
|
if len(got) != 9000 || got != long {
|
|
t.Fatalf("reply = %d bytes, want the 9000-byte original", len(got))
|
|
}
|
|
}
|
|
|
|
func TestExecuteSkipsALeftoverEndMarker(t *testing.T) {
|
|
f := startFakeRCON(t, "pw", map[string]string{"list": "There are 0 of a max of 20 players online", "seed": "Seed: [42]"})
|
|
f.doubleEnd = true
|
|
defer f.stop()
|
|
c, err := rcon.Dial(f.addr(), "pw", time.Second)
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
defer c.Close()
|
|
if got, err := c.Execute("list"); err != nil || got != "There are 0 of a max of 20 players online" {
|
|
t.Fatalf("first = %q, %v", got, err)
|
|
}
|
|
if got, err := c.Execute("seed"); err != nil || got != "Seed: [42]" {
|
|
t.Fatalf("second = %q, %v", got, err)
|
|
}
|
|
}
|
|
|
|
func TestExecuteContextGivesUpOnAHungServer(t *testing.T) {
|
|
f := startFakeRCON(t, "pw", nil)
|
|
f.hang = true
|
|
defer f.stop()
|
|
c, err := rcon.Dial(f.addr(), "pw", time.Second)
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
defer c.Close()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
|
|
defer cancel()
|
|
start := time.Now()
|
|
if _, err := c.ExecuteContext(ctx, "banlist"); !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatalf("deadline: err = %v, want context.DeadlineExceeded", err)
|
|
}
|
|
if took := time.Since(start); took > 2*time.Second {
|
|
t.Fatalf("deadline: took %v", took)
|
|
}
|
|
|
|
ctx, cancel = context.WithCancel(context.Background())
|
|
time.AfterFunc(100*time.Millisecond, cancel)
|
|
start = time.Now()
|
|
if _, err := c.ExecuteContext(ctx, "banlist"); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("cancel: err = %v, want context.Canceled", err)
|
|
}
|
|
if took := time.Since(start); took > 2*time.Second {
|
|
t.Fatalf("cancel: took %v", took)
|
|
}
|
|
|
|
// A caller's own SetDeadline bounds a context-free Execute.
|
|
if err := c.SetDeadline(time.Now().Add(150 * time.Millisecond)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
start = time.Now()
|
|
var ne net.Error
|
|
if _, err := c.Execute("banlist"); !errors.As(err, &ne) || !ne.Timeout() {
|
|
t.Fatalf("set deadline: err = %v, want a timeout", err)
|
|
}
|
|
if took := time.Since(start); took > 2*time.Second {
|
|
t.Fatalf("set deadline: took %v", took)
|
|
}
|
|
}
|