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,198 @@
|
||||
// Package ark knows the text ARK: Survival Evolved's admin console speaks: the commands to send,
|
||||
// and how to read what comes back from ListPlayers and GetChat.
|
||||
package ark
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NoResponse is what the server answers to a command that has nothing to say.
|
||||
const NoResponse = "Server received, But no response!!"
|
||||
|
||||
// Player is one person connected to a server.
|
||||
type Player struct {
|
||||
Name string `json:"name"`
|
||||
SteamID string `json:"steam_id"`
|
||||
}
|
||||
|
||||
var playerLine = regexp.MustCompile(`^\s*\d+\.\s+(.*),\s*(\d{15,20})\s*$`)
|
||||
|
||||
// ParsePlayers reads the reply to ListPlayers: "0. Name, 76561198000000000" per line. Names may
|
||||
// contain commas, so the Steam ID is taken from the end of the line.
|
||||
func ParsePlayers(s string) []Player {
|
||||
var out []Player
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
if m := playerLine.FindStringSubmatch(strings.TrimRight(line, "\r\x00 ")); m != nil {
|
||||
out = append(out, Player{Name: strings.TrimSpace(m[1]), SteamID: m[2]})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ChatLine is one line from GetChat.
|
||||
type ChatLine struct {
|
||||
Sender string `json:"sender"`
|
||||
Character string `json:"character,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Server bool `json:"server"` // the server itself speaking (broadcasts, admin messages)
|
||||
}
|
||||
|
||||
var timestamp = regexp.MustCompile(`^\[\d{4}\.\d\d\.\d\d[-_]\d\d\.\d\d\.\d\d\]\s*`)
|
||||
|
||||
// ParseChat reads the reply to GetChat. Player lines look like "Steam Name (Character): hello".
|
||||
func ParseChat(s string) []ChatLine {
|
||||
var out []ChatLine
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
line = strings.TrimSpace(strings.Trim(line, "\x00"))
|
||||
line = timestamp.ReplaceAllString(line, "")
|
||||
if line == "" || strings.HasPrefix(line, NoResponse) {
|
||||
continue
|
||||
}
|
||||
cl := ChatLine{Message: line, Server: true}
|
||||
if i := strings.Index(line, ": "); i > 0 {
|
||||
who := line[:i]
|
||||
cl.Message = line[i+2:]
|
||||
cl.Sender = who
|
||||
if j := strings.LastIndex(who, " ("); j > 0 && strings.HasSuffix(who, ")") {
|
||||
cl.Sender = who[:j]
|
||||
cl.Character = who[j+2 : len(who)-1]
|
||||
}
|
||||
cl.Server = cl.Sender == "SERVER"
|
||||
}
|
||||
out = append(out, cl)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// IsEmptyReply reports whether a reply carries nothing worth showing.
|
||||
func IsEmptyReply(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
return s == "" || strings.HasPrefix(s, NoResponse)
|
||||
}
|
||||
|
||||
// ValidSteamID says whether s is a 64-bit Steam ID: digits only, so it can never smuggle anything
|
||||
// else into a command.
|
||||
func ValidSteamID(s string) bool {
|
||||
if len(s) < 15 || len(s) > 20 {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Clean makes text safe to put in a command: one line, no control characters, no double quotes.
|
||||
func Clean(s string) string {
|
||||
var sb strings.Builder
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r == '"':
|
||||
sb.WriteRune('\'')
|
||||
case r == '\n' || r == '\r' || r == '\t':
|
||||
sb.WriteRune(' ')
|
||||
case r < 0x20 || r == 0x7f:
|
||||
default:
|
||||
sb.WriteRune(r)
|
||||
}
|
||||
}
|
||||
out := strings.TrimSpace(sb.String())
|
||||
if r := []rune(out); len(r) > 400 {
|
||||
out = string(r[:400])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Command lines with fixed text.
|
||||
const (
|
||||
ListPlayers = "ListPlayers"
|
||||
GetChat = "GetChat"
|
||||
SaveWorld = "SaveWorld"
|
||||
DoExit = "DoExit"
|
||||
DestroyWildDinos = "DestroyWildDinos"
|
||||
)
|
||||
|
||||
// Broadcast shows a message in the middle of every player's screen.
|
||||
func Broadcast(msg string) (string, error) { return withText("Broadcast", msg) }
|
||||
|
||||
// ServerChat posts a line to global chat as SERVER.
|
||||
func ServerChat(msg string) (string, error) { return withText("ServerChat", msg) }
|
||||
|
||||
// SetMOTD changes the message of the day.
|
||||
func SetMOTD(msg string) (string, error) { return withText("SetMessageOfTheDay", msg) }
|
||||
|
||||
func withText(cmd, msg string) (string, error) {
|
||||
msg = Clean(msg)
|
||||
if msg == "" {
|
||||
return "", errors.New("the message is empty")
|
||||
}
|
||||
return cmd + " " + msg, nil
|
||||
}
|
||||
|
||||
// ServerChatTo whispers a message to one player.
|
||||
func ServerChatTo(steamID, msg string) (string, error) {
|
||||
if !ValidSteamID(steamID) {
|
||||
return "", errors.New("not a Steam ID")
|
||||
}
|
||||
msg = Clean(msg)
|
||||
if msg == "" {
|
||||
return "", errors.New("the message is empty")
|
||||
}
|
||||
return fmt.Sprintf(`ServerChatTo "%s" %s`, steamID, msg), nil
|
||||
}
|
||||
|
||||
// ForPlayer builds a command that takes only a Steam ID: KickPlayer, BanPlayer, UnbanPlayer,
|
||||
// AllowPlayerToJoinNoCheck, DisallowPlayerToJoinNoCheck.
|
||||
func ForPlayer(cmd, steamID string) (string, error) {
|
||||
if !ValidSteamID(steamID) {
|
||||
return "", errors.New("not a Steam ID")
|
||||
}
|
||||
return cmd + " " + steamID, nil
|
||||
}
|
||||
|
||||
var timeOfDay = regexp.MustCompile(`^([01]?\d|2[0-3]):[0-5]\d(:[0-5]\d)?$`)
|
||||
|
||||
// SetTimeOfDay sets the in-game clock to HH:MM or HH:MM:SS.
|
||||
func SetTimeOfDay(t string) (string, error) {
|
||||
t = strings.TrimSpace(t)
|
||||
if !timeOfDay.MatchString(t) {
|
||||
return "", errors.New("time must look like 06:30")
|
||||
}
|
||||
if strings.Count(t, ":") == 1 {
|
||||
t += ":00"
|
||||
}
|
||||
return "SetTimeOfDay " + t, nil
|
||||
}
|
||||
|
||||
// Reference lists the console commands worth offering as suggestions in a UI.
|
||||
var Reference = []string{
|
||||
"ListPlayers", "GetChat", "SaveWorld", "DoExit", "Broadcast ", "ServerChat ", `ServerChatTo "<steamid>" `,
|
||||
`ServerChatToPlayer "<name>" `, "KickPlayer <steamid>", "BanPlayer <steamid>", "UnbanPlayer <steamid>",
|
||||
"AllowPlayerToJoinNoCheck <steamid>", "DisallowPlayerToJoinNoCheck <steamid>", "DestroyWildDinos",
|
||||
"SetTimeOfDay 12:00", "SetMessageOfTheDay ", "ShowMessageOfTheDay", "GiveItemToPlayer <playerid> <blueprint> 1 0 0",
|
||||
"GiveExpToPlayer <playerid> 1000 0 0", "RenamePlayer \"<old>\" <new>", "RenameTribe \"<old>\" <new>",
|
||||
}
|
||||
|
||||
// Duration formats d the way a player would say it ("10 minutes", "30 seconds").
|
||||
func Duration(d time.Duration) string {
|
||||
d = d.Round(time.Second)
|
||||
switch {
|
||||
case d >= 2*time.Hour:
|
||||
return fmt.Sprintf("%d hours", int(d.Hours()))
|
||||
case d >= time.Hour:
|
||||
return "1 hour"
|
||||
case d >= 2*time.Minute:
|
||||
return fmt.Sprintf("%d minutes", int(d.Minutes()))
|
||||
case d >= time.Minute:
|
||||
return "1 minute"
|
||||
case d == time.Second:
|
||||
return "1 second"
|
||||
}
|
||||
return fmt.Sprintf("%d seconds", int(d.Seconds()))
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package ark
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParsePlayers(t *testing.T) {
|
||||
got := ParsePlayers("0. Alex, the Tamer, 76561198000000001\r\n1. Bo, 76561198000000002 \n")
|
||||
if len(got) != 2 || got[0].Name != "Alex, the Tamer" || got[0].SteamID != "76561198000000001" || got[1].Name != "Bo" {
|
||||
t.Fatalf("got %+v", got)
|
||||
}
|
||||
if n := len(ParsePlayers("No Players Connected \n")); n != 0 {
|
||||
t.Fatalf("expected nobody, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChat(t *testing.T) {
|
||||
got := ParseChat("Alex (Rex Rider): hello: world\nSERVER: restarting soon\nsomething odd\n" + NoResponse + "\n")
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("got %+v", got)
|
||||
}
|
||||
if a := got[0]; a.Sender != "Alex" || a.Character != "Rex Rider" || a.Message != "hello: world" || a.Server {
|
||||
t.Fatalf("player line: %+v", a)
|
||||
}
|
||||
if !got[1].Server || got[1].Message != "restarting soon" {
|
||||
t.Fatalf("server line: %+v", got[1])
|
||||
}
|
||||
if !got[2].Server || got[2].Message != "something odd" {
|
||||
t.Fatalf("odd line: %+v", got[2])
|
||||
}
|
||||
if n := len(ParseChat("[2026.01.02-03.04.05] Bo: hi")); n != 1 {
|
||||
t.Fatalf("timestamped line not parsed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandsAreSafe(t *testing.T) {
|
||||
if _, err := ForPlayer("KickPlayer", "123; DoExit"); err == nil {
|
||||
t.Fatal("a non-numeric id was accepted")
|
||||
}
|
||||
c, err := ForPlayer("KickPlayer", "76561198000000001")
|
||||
if err != nil || c != "KickPlayer 76561198000000001" {
|
||||
t.Fatalf("got %q, %v", c, err)
|
||||
}
|
||||
c, err = Broadcast("hi \"there\"\nDoExit")
|
||||
if err != nil || c != "Broadcast hi 'there' DoExit" {
|
||||
t.Fatalf("got %q, %v", c, err)
|
||||
}
|
||||
if _, err := Broadcast(" \n "); err == nil {
|
||||
t.Fatal("empty message accepted")
|
||||
}
|
||||
if c, _ := SetTimeOfDay("6:30"); c != "SetTimeOfDay 6:30:00" {
|
||||
t.Fatalf("got %q", c)
|
||||
}
|
||||
if _, err := SetTimeOfDay("25:00"); err == nil {
|
||||
t.Fatal("bad time accepted")
|
||||
}
|
||||
if c, _ := ServerChatTo("76561198000000001", "hello"); c != `ServerChatTo "76561198000000001" hello` {
|
||||
t.Fatalf("got %q", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuration(t *testing.T) {
|
||||
for d, want := range map[time.Duration]string{
|
||||
10 * time.Second: "10 seconds", time.Minute: "1 minute", 5 * time.Minute: "5 minutes", 3 * time.Hour: "3 hours",
|
||||
} {
|
||||
if got := Duration(d); got != want {
|
||||
t.Errorf("%v: got %q, want %q", d, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A fake game server that asks for a challenge first, as current servers do.
|
||||
func TestQuery(t *testing.T) {
|
||||
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pc.Close()
|
||||
go func() {
|
||||
buf := make([]byte, 100)
|
||||
for {
|
||||
n, from, err := pc.ReadFrom(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if n == len(a2sRequest) {
|
||||
pc.WriteTo([]byte{0xff, 0xff, 0xff, 0xff, 0x41, 1, 2, 3, 4}, from)
|
||||
continue
|
||||
}
|
||||
reply := []byte{0xff, 0xff, 0xff, 0xff, 0x49, 17}
|
||||
for _, s := range []string{"Test Island - (v350.2)", "TheIsland", "ark_survival_evolved", "ARK: Survival Evolved"} {
|
||||
reply = append(append(reply, s...), 0)
|
||||
}
|
||||
reply = append(reply, 0, 0, 3, 20, 0, 'd', 'w', 0, 1, '1', '.', '0', 0)
|
||||
pc.WriteTo(reply, from)
|
||||
}
|
||||
}()
|
||||
in, err := Query(pc.LocalAddr().String(), 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if in.Name != "Test Island - (v350.2)" || in.Map != "TheIsland" || in.Players != 3 || in.MaxPlayers != 20 || in.Version != "350.2" {
|
||||
t.Fatalf("got %+v", in)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package ark
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"net"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Info is what a server tells the Steam server browser (A2S_INFO).
|
||||
type Info struct {
|
||||
Name string `json:"name"`
|
||||
Map string `json:"map"`
|
||||
Game string `json:"game"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Players int `json:"players"`
|
||||
MaxPlayers int `json:"max_players"`
|
||||
PingMS int64 `json:"ping_ms"`
|
||||
}
|
||||
|
||||
var a2sRequest = []byte("\xff\xff\xff\xffTSource Engine Query\x00")
|
||||
|
||||
// ARK puts its version in the server name: "My Server - (v349.7)".
|
||||
var nameVersion = regexp.MustCompile(`\(v(\d+(?:\.\d+)*)\)`)
|
||||
|
||||
// Query asks the game's query port (UDP, usually 27015) who is on the server, without needing the
|
||||
// RCON password.
|
||||
func Query(addr string, timeout time.Duration) (*Info, error) {
|
||||
conn, err := net.Dial("udp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
start := time.Now()
|
||||
conn.SetDeadline(start.Add(timeout))
|
||||
if _, err := conn.Write(a2sRequest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buf := make([]byte, 1400)
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n >= 9 && buf[4] == 0x41 { // challenge: ask again with the number it gave
|
||||
req := append(append([]byte{}, a2sRequest...), buf[5:9]...)
|
||||
if _, err := conn.Write(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n, err = conn.Read(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
info, err := parseInfo(buf[:n])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info.PingMS = time.Since(start).Milliseconds()
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func parseInfo(b []byte) (*Info, error) {
|
||||
if len(b) < 6 || !bytes.Equal(b[:4], []byte{0xff, 0xff, 0xff, 0xff}) || b[4] != 0x49 {
|
||||
return nil, errors.New("not an A2S_INFO reply")
|
||||
}
|
||||
r := b[6:] // skip the header and the protocol byte
|
||||
str := func() string {
|
||||
i := bytes.IndexByte(r, 0)
|
||||
if i < 0 {
|
||||
s := string(r)
|
||||
r = nil
|
||||
return s
|
||||
}
|
||||
s := string(r[:i])
|
||||
r = r[i+1:]
|
||||
return s
|
||||
}
|
||||
var in Info
|
||||
in.Name, in.Map = str(), str()
|
||||
str() // folder
|
||||
in.Game = str()
|
||||
if len(r) < 9 {
|
||||
return nil, errors.New("short A2S_INFO reply")
|
||||
}
|
||||
in.Players, in.MaxPlayers = int(r[2]), int(r[3])
|
||||
r = r[9:] // app id (2), players, max, bots, type, environment, visibility, VAC
|
||||
in.Version = str()
|
||||
if m := nameVersion.FindStringSubmatch(in.Name); m != nil {
|
||||
in.Version = m[1]
|
||||
}
|
||||
return &in, nil
|
||||
}
|
||||
Reference in new issue
Block a user