summaryrefslogtreecommitdiff
path: root/adapters/rcon
diff options
context:
space:
mode:
authorElizabeth Alexander Hunt <me@liz.coffee>2026-06-20 09:40:07 -0700
committerElizabeth Alexander Hunt <me@liz.coffee>2026-06-20 09:40:07 -0700
commitfb653292612eddca5b088ed88466c546d1597107 (patch)
tree900ed32e066812688858ed3bb15128c41bb78054 /adapters/rcon
downloadpenguins.lan-fb653292612eddca5b088ed88466c546d1597107.tar.gz
penguins.lan-fb653292612eddca5b088ed88466c546d1597107.zip
Initial commit
Diffstat (limited to 'adapters/rcon')
-rw-r--r--adapters/rcon/rcon.go130
-rw-r--r--adapters/rcon/rcon_test.go91
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)
+ }
+}