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()))
|
||||
}
|
||||
Reference in new issue
Block a user