Megalodon: manage ARK servers over RCON from a web dashboard
CI / test (push) Successful in 2m26s
Release / release (push) Successful in 2m36s

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:
VeillaxandClaude Sonnet 5.5 committed 2026-10-30 23:36:13 -06:00
commit 0d2cb31abf
32 files changed
+6280

No files matched your search

+198
View File
@@ -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()))
}
+107
View File
@@ -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)
}
}
+92
View File
@@ -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
}