diff options
Diffstat (limited to 'adapters/rcon')
| -rw-r--r-- | adapters/rcon/rcon.go | 130 | ||||
| -rw-r--r-- | adapters/rcon/rcon_test.go | 91 |
2 files changed, 221 insertions, 0 deletions
diff --git a/adapters/rcon/rcon.go b/adapters/rcon/rcon.go new file mode 100644 index 0000000..4b9e8b6 --- /dev/null +++ b/adapters/rcon/rcon.go @@ -0,0 +1,130 @@ +// Package rcon is a minimal Source RCON client (the protocol Minecraft speaks), +// enough to authenticate and run a command like "list". +package rcon + +import ( + "bytes" + "encoding/binary" + "errors" + "io" + "net" + "time" +) + +const ( + typeResponse int32 = 0 + typeAuthResp int32 = 2 + typeExecCommand int32 = 2 + typeAuth int32 = 3 + + authRequestID int32 = 0 + execRequestID int32 = 1 + + headerSize = 8 // id (int32) + type (int32) + paddingSize = 2 // two trailing null bytes + maxBodySize = 4096 + + DefaultTimeout = 5 * time.Second +) + +var ( + ErrAuthFailed = errors.New("rcon: authentication failed") + ErrResponseMismatch = errors.New("rcon: response id mismatch") + ErrPacketSize = errors.New("rcon: invalid packet size") +) + +type Client struct { + conn net.Conn + timeout time.Duration +} + +func Dial(address, password string, timeout time.Duration) (*Client, error) { + conn, err := net.DialTimeout("tcp", address, timeout) + if err != nil { + return nil, err + } + + client := &Client{conn: conn, timeout: timeout} + if err := client.auth(password); err != nil { + conn.Close() + return nil, err + } + return client, nil +} + +func (c *Client) Close() error { return c.conn.Close() } + +func (c *Client) auth(password string) error { + if err := c.write(authRequestID, typeAuth, password); err != nil { + return err + } + + // Some servers send an empty SERVERDATA_RESPONSE_VALUE before the auth + // response; skip anything that isn't the auth response. + for { + id, typ, _, err := c.read() + if err != nil { + return err + } + if typ != typeAuthResp { + continue + } + if id == -1 { + return ErrAuthFailed + } + return nil + } +} + +func (c *Client) Execute(command string) (string, error) { + if err := c.write(execRequestID, typeExecCommand, command); err != nil { + return "", err + } + + id, _, body, err := c.read() + if err != nil { + return "", err + } + if id != execRequestID { + return "", ErrResponseMismatch + } + return body, nil +} + +func (c *Client) write(id, typ int32, body string) error { + c.conn.SetWriteDeadline(time.Now().Add(c.timeout)) + + var buf bytes.Buffer + size := int32(headerSize + len(body) + paddingSize) + binary.Write(&buf, binary.LittleEndian, size) + binary.Write(&buf, binary.LittleEndian, id) + binary.Write(&buf, binary.LittleEndian, typ) + buf.WriteString(body) + buf.Write([]byte{0, 0}) + + _, err := c.conn.Write(buf.Bytes()) + return err +} + +func (c *Client) read() (id, typ int32, body string, err error) { + c.conn.SetReadDeadline(time.Now().Add(c.timeout)) + + var size int32 + if err = binary.Read(c.conn, binary.LittleEndian, &size); err != nil { + return + } + if size < headerSize+paddingSize || size > headerSize+maxBodySize+paddingSize { + err = ErrPacketSize + return + } + + payload := make([]byte, size) + if _, err = io.ReadFull(c.conn, payload); err != nil { + return + } + + id = int32(binary.LittleEndian.Uint32(payload[0:4])) + typ = int32(binary.LittleEndian.Uint32(payload[4:8])) + body = string(payload[headerSize : size-paddingSize]) + return +} diff --git a/adapters/rcon/rcon_test.go b/adapters/rcon/rcon_test.go new file mode 100644 index 0000000..f712fa5 --- /dev/null +++ b/adapters/rcon/rcon_test.go @@ -0,0 +1,91 @@ +package rcon + +import ( + "encoding/binary" + "errors" + "io" + "net" + "testing" + "time" +) + +func writeTestPacket(w io.Writer, id, typ int32, body string) { + binary.Write(w, binary.LittleEndian, int32(headerSize+len(body)+paddingSize)) + binary.Write(w, binary.LittleEndian, id) + binary.Write(w, binary.LittleEndian, typ) + io.WriteString(w, body) + w.Write([]byte{0, 0}) +} + +func readTestPacket(r io.Reader) (id, typ int32, body string) { + var size int32 + binary.Read(r, binary.LittleEndian, &size) + payload := make([]byte, size) + io.ReadFull(r, payload) + id = int32(binary.LittleEndian.Uint32(payload[0:4])) + typ = int32(binary.LittleEndian.Uint32(payload[4:8])) + return id, typ, string(payload[headerSize : size-paddingSize]) +} + +// fakeServer speaks just enough Source RCON to exercise the client. +func fakeServer(t *testing.T, password string) string { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer conn.Close() + + id, typ, body := readTestPacket(conn) + if typ != typeAuth { + return + } + respID := id + if body != password { + respID = -1 + } + writeTestPacket(conn, id, typeResponse, "") // empty value first + writeTestPacket(conn, respID, typeAuthResp, "") + if respID == -1 { + return + } + + cid, _, cbody := readTestPacket(conn) + out := "unknown command" + if cbody == "list" { + out = "There are 1 of a max of 20 players online: Steve" + } + writeTestPacket(conn, cid, typeResponse, out) + }() + + return ln.Addr().String() +} + +func TestClientExecute(t *testing.T) { + client, err := Dial(fakeServer(t, "secret"), "secret", time.Second) + if err != nil { + t.Fatal(err) + } + defer client.Close() + + out, err := client.Execute("list") + if err != nil { + t.Fatal(err) + } + if want := "There are 1 of a max of 20 players online: Steve"; out != want { + t.Errorf("Execute = %q, want %q", out, want) + } +} + +func TestClientAuthFailure(t *testing.T) { + _, err := Dial(fakeServer(t, "secret"), "wrong", time.Second) + if !errors.Is(err, ErrAuthFailed) { + t.Errorf("Dial err = %v, want ErrAuthFailed", err) + } +} |
