Megalodon: manage ARK servers over RCON from a web dashboard
Watches any number of ARK:SE servers (RCON for players and chat, the query port for map, version and player cap), records joins, leaves, chat and an audit trail, and keeps a player database across servers. Control: save, broadcast, chat, whisper, kick, time of day, MOTD, wild dino wipe, a raw console with history and macros, restarts with timed warnings and a world save, optional start command and auto-start. Bans are kept by Megalodon: timed, cluster-wide and re-enforced when the player returns. Automation: schedules (every N or at times of day), Discord/ntfy/webhook notifications, welcome message, chat word filter. Access: viewer, moderator and admin roles, API tokens, Prometheus metrics. cmd/fakeark simulates a server for trying it out. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
0d2cb31abf
32 files changed
+6280
No files matched your search
@@ -0,0 +1,217 @@
|
||||
// Package rcon is a client for the Source RCON protocol as ARK: Survival Evolved speaks it. One
|
||||
// Client holds one TCP connection, signs in on first use, reconnects when the server has dropped
|
||||
// it, and runs one command at a time.
|
||||
package rcon
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
typeResponse int32 = 0
|
||||
typeExec int32 = 2 // a command going out, and the sign-in answer coming back
|
||||
typeAuth int32 = 3
|
||||
|
||||
// A response body this long may continue in the next packet.
|
||||
fullBody = 4000
|
||||
// ARK never sends anything near this; it only guards against a garbled length.
|
||||
maxPacket = 1 << 20
|
||||
// How long to wait for a continuation packet before deciding the response is complete.
|
||||
continuation = 250 * time.Millisecond
|
||||
)
|
||||
|
||||
// ErrAuth is returned when the server rejects the password.
|
||||
var ErrAuth = errors.New("rcon: wrong password")
|
||||
|
||||
// Client talks to one server. It is safe for concurrent use; commands are sent one at a time.
|
||||
type Client struct {
|
||||
Addr string // host:port
|
||||
Password string //
|
||||
Timeout time.Duration // per dial and per command, default 5s
|
||||
|
||||
mu sync.Mutex
|
||||
conn net.Conn
|
||||
id int32
|
||||
}
|
||||
|
||||
// New returns a client that has not connected yet.
|
||||
func New(addr, password string) *Client {
|
||||
return &Client{Addr: addr, Password: password, Timeout: 5 * time.Second}
|
||||
}
|
||||
|
||||
// Close drops the connection. The client can be used again afterwards.
|
||||
func (c *Client) Close() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.closeLocked()
|
||||
}
|
||||
|
||||
func (c *Client) closeLocked() error {
|
||||
if c.conn == nil {
|
||||
return nil
|
||||
}
|
||||
err := c.conn.Close()
|
||||
c.conn = nil
|
||||
return err
|
||||
}
|
||||
|
||||
// Exec runs one command and returns the server's reply. A connection that went stale while idle
|
||||
// (the server restarted, a NAT forgot it) is replaced once, transparently.
|
||||
func (c *Client) Exec(cmd string) (string, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
fresh := false
|
||||
if c.conn == nil {
|
||||
if err := c.connect(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
fresh = true
|
||||
}
|
||||
out, got, err := c.exec(cmd)
|
||||
if err == nil {
|
||||
return out, nil
|
||||
}
|
||||
c.closeLocked()
|
||||
if fresh || got {
|
||||
return "", err
|
||||
}
|
||||
// Nothing came back at all, so this was most likely a dead connection: try once on a new one.
|
||||
if err := c.connect(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
out, _, err = c.exec(cmd)
|
||||
if err != nil {
|
||||
c.closeLocked()
|
||||
return "", err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Client) timeout() time.Duration {
|
||||
if c.Timeout > 0 {
|
||||
return c.Timeout
|
||||
}
|
||||
return 5 * time.Second
|
||||
}
|
||||
|
||||
func (c *Client) connect() error {
|
||||
conn, err := net.DialTimeout("tcp", c.Addr, c.timeout())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conn.SetDeadline(time.Now().Add(c.timeout()))
|
||||
if err := writePacket(conn, 1, typeAuth, c.Password); err != nil {
|
||||
conn.Close()
|
||||
return err
|
||||
}
|
||||
// Servers may send an empty response before the sign-in answer.
|
||||
for i := 0; i < 4; i++ {
|
||||
p, err := readPacket(conn)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return fmt.Errorf("rcon: sign-in: %w", err)
|
||||
}
|
||||
if p.typ != typeExec {
|
||||
continue
|
||||
}
|
||||
if p.id == -1 {
|
||||
conn.Close()
|
||||
return ErrAuth
|
||||
}
|
||||
c.conn = conn
|
||||
return nil
|
||||
}
|
||||
conn.Close()
|
||||
return errors.New("rcon: the server never answered the sign-in")
|
||||
}
|
||||
|
||||
// exec reports whether any packet arrived, so Exec knows if a retry is safe.
|
||||
func (c *Client) exec(cmd string) (body string, got bool, err error) {
|
||||
c.id++
|
||||
if c.id < 2 {
|
||||
c.id = 2
|
||||
}
|
||||
id := c.id
|
||||
c.conn.SetDeadline(time.Now().Add(c.timeout()))
|
||||
if err := writePacket(c.conn, id, typeExec, cmd); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
var sb strings.Builder
|
||||
for {
|
||||
p, err := readPacket(c.conn)
|
||||
if err != nil {
|
||||
return "", got, err
|
||||
}
|
||||
got = true
|
||||
if p.id != id || p.typ != typeResponse {
|
||||
continue // left over from an earlier command
|
||||
}
|
||||
sb.WriteString(p.body)
|
||||
if len(p.body) < fullBody {
|
||||
return sb.String(), true, nil
|
||||
}
|
||||
break
|
||||
}
|
||||
// A full-size packet: the reply may go on. There is no end marker, so wait briefly for more.
|
||||
for {
|
||||
c.conn.SetReadDeadline(time.Now().Add(continuation))
|
||||
p, err := readPacket(c.conn)
|
||||
if err != nil {
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() {
|
||||
break
|
||||
}
|
||||
return "", true, err
|
||||
}
|
||||
if p.id != id || p.typ != typeResponse {
|
||||
continue
|
||||
}
|
||||
sb.WriteString(p.body)
|
||||
if len(p.body) < fullBody {
|
||||
break
|
||||
}
|
||||
}
|
||||
return sb.String(), true, nil
|
||||
}
|
||||
|
||||
type packet struct {
|
||||
id, typ int32
|
||||
body string
|
||||
}
|
||||
|
||||
func writePacket(w io.Writer, id, typ int32, body string) error {
|
||||
buf := make([]byte, 4+4+4+len(body)+2)
|
||||
binary.LittleEndian.PutUint32(buf[0:], uint32(4+4+len(body)+2))
|
||||
binary.LittleEndian.PutUint32(buf[4:], uint32(id))
|
||||
binary.LittleEndian.PutUint32(buf[8:], uint32(typ))
|
||||
copy(buf[12:], body)
|
||||
_, err := w.Write(buf)
|
||||
return err
|
||||
}
|
||||
|
||||
func readPacket(r io.Reader) (packet, error) {
|
||||
var hdr [4]byte
|
||||
if _, err := io.ReadFull(r, hdr[:]); err != nil {
|
||||
return packet{}, err
|
||||
}
|
||||
size := int(int32(binary.LittleEndian.Uint32(hdr[:])))
|
||||
if size < 10 || size > maxPacket {
|
||||
return packet{}, fmt.Errorf("rcon: bad packet length %d", size)
|
||||
}
|
||||
buf := make([]byte, size)
|
||||
if _, err := io.ReadFull(r, buf); err != nil {
|
||||
return packet{}, err
|
||||
}
|
||||
return packet{
|
||||
id: int32(binary.LittleEndian.Uint32(buf[0:])),
|
||||
typ: int32(binary.LittleEndian.Uint32(buf[4:])),
|
||||
body: strings.ToValidUTF8(string(buf[8:size-2]), "�"),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package rcon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeServer speaks enough of the protocol to test the client: sign-in, then one reply per
|
||||
// command, split into 4000-byte packets the way real servers do.
|
||||
type fakeServer struct {
|
||||
ln net.Listener
|
||||
password string
|
||||
handler func(cmd string) string
|
||||
conns atomic.Int32
|
||||
dropAt int32 // close each connection after this many commands (0 = never)
|
||||
}
|
||||
|
||||
func serve(t *testing.T, password string, h func(string) string) *fakeServer {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeServer{ln: ln, password: password, handler: h}
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
go func() {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
f.conns.Add(1)
|
||||
go f.handle(c)
|
||||
}
|
||||
}()
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakeServer) handle(c net.Conn) {
|
||||
defer c.Close()
|
||||
p, err := readPacket(c)
|
||||
if err != nil || p.typ != typeAuth {
|
||||
return
|
||||
}
|
||||
writePacket(c, p.id, typeResponse, "")
|
||||
if p.body != f.password {
|
||||
writePacket(c, -1, typeExec, "")
|
||||
return
|
||||
}
|
||||
writePacket(c, p.id, typeExec, "")
|
||||
var n int32
|
||||
for {
|
||||
p, err := readPacket(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
reply := f.handler(p.body)
|
||||
for {
|
||||
chunk := reply
|
||||
if len(chunk) > fullBody {
|
||||
chunk = reply[:fullBody]
|
||||
}
|
||||
writePacket(c, p.id, typeResponse, chunk)
|
||||
reply = reply[len(chunk):]
|
||||
if reply == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
n++
|
||||
if f.dropAt > 0 && n >= f.dropAt {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeServer) addr() string { return f.ln.Addr().String() }
|
||||
|
||||
func TestExec(t *testing.T) {
|
||||
f := serve(t, "pw", func(cmd string) string { return "you said " + cmd })
|
||||
c := New(f.addr(), "pw")
|
||||
defer c.Close()
|
||||
for _, cmd := range []string{"ListPlayers", "GetChat"} {
|
||||
out, err := c.Exec(cmd)
|
||||
if err != nil || out != "you said "+cmd {
|
||||
t.Fatalf("%s: got %q, %v", cmd, out, err)
|
||||
}
|
||||
}
|
||||
if f.conns.Load() != 1 {
|
||||
t.Fatalf("the connection should be reused, saw %d", f.conns.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongPassword(t *testing.T) {
|
||||
f := serve(t, "pw", func(string) string { return "" })
|
||||
c := New(f.addr(), "nope")
|
||||
if _, err := c.Exec("ListPlayers"); !errors.Is(err, ErrAuth) {
|
||||
t.Fatalf("want ErrAuth, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLongReplyArrivesWhole(t *testing.T) {
|
||||
long := strings.Repeat("0123456789", 1050) // 10500 bytes: three packets
|
||||
f := serve(t, "pw", func(string) string { return long })
|
||||
c := New(f.addr(), "pw")
|
||||
defer c.Close()
|
||||
out, err := c.Exec("GetChat")
|
||||
if err != nil || out != long {
|
||||
t.Fatalf("got %d bytes, %v", len(out), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconnectsAfterDrop(t *testing.T) {
|
||||
f := serve(t, "pw", func(string) string { return "ok" })
|
||||
f.dropAt = 1
|
||||
c := New(f.addr(), "pw")
|
||||
defer c.Close()
|
||||
for i := 0; i < 3; i++ {
|
||||
if out, err := c.Exec("ListPlayers"); err != nil || out != "ok" {
|
||||
t.Fatalf("run %d: got %q, %v", i, out, err)
|
||||
}
|
||||
}
|
||||
if f.conns.Load() < 3 {
|
||||
t.Fatalf("expected a new connection per command, saw %d", f.conns.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnreachable(t *testing.T) {
|
||||
ln, _ := net.Listen("tcp", "127.0.0.1:0")
|
||||
addr := ln.Addr().String()
|
||||
ln.Close()
|
||||
if _, err := New(addr, "pw").Exec("x"); err == nil {
|
||||
t.Fatal("expected a connection error")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user