Files
VeillaxandClaude Sonnet 5.5 0d2cb31abf
CI / test (push) Successful in 2m26s
Release / release (push) Successful in 2m36s
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>
2026-10-30 23:36:13 -06:00

446 lines
13 KiB
Go

// Package config holds Megalodon's settings: a single JSON file that the web UI can also edit.
package config
import (
"crypto/pbkdf2"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
// Role says what a user may do. Each role includes everything the one before it can.
type Role string
const (
Viewer Role = "viewer" // look at servers, players, chat and events
Moderator Role = "moderator" // plus talk to players, kick, ban, save
Admin Role = "admin" // plus the raw console, restarts, schedules, settings and users
)
// Rank orders roles; an unknown role ranks 0 and may do nothing.
func (r Role) Rank() int {
switch r {
case Viewer:
return 1
case Moderator:
return 2
case Admin:
return 3
}
return 0
}
// Allows reports whether this role is at least min.
func (r Role) Allows(min Role) bool { return r.Rank() >= min.Rank() && r.Rank() > 0 }
type Config struct {
Listen string `json:"listen"` // web address, default 127.0.0.1:8480
DataDir string `json:"data_dir"` // events, players and bans live here
TrustProxy bool `json:"trust_proxy"` // believe X-Forwarded-For / -Proto (when behind a reverse proxy)
CookieSecure bool `json:"cookie_secure"` // always mark the session cookie Secure
PollSeconds int `json:"poll_seconds"` // how often each server is asked who is online
ChatSeconds int `json:"chat_seconds"` // how often chat is collected
Servers []Server `json:"servers"`
Users []User `json:"users"`
Tokens []Token `json:"tokens"`
Jobs []Job `json:"jobs"`
Macros []Macro `json:"macros"`
Notify []Notify `json:"notify"`
Welcome Welcome `json:"welcome"`
ChatFilter ChatFilter `json:"chat_filter"`
}
// Server is one ARK server (or one map of a cluster).
type Server struct {
ID string `json:"id"`
Name string `json:"name"`
Host string `json:"host"`
RCONPort int `json:"rcon_port"`
Password string `json:"password,omitempty"`
QueryPort int `json:"query_port"` // UDP, for the server browser info; 0 turns it off
// Optional, for when Megalodon runs on the same machine as the server, or can reach whatever
// starts it (docker, systemctl, a script). Run through the shell, must return promptly.
StartCommand string `json:"start_command,omitempty"`
AutoStart bool `json:"auto_start,omitempty"` // start it when it has been down for a while
AutoStartSec int `json:"auto_start_after,omitempty"` // seconds, default 120
}
type User struct {
Name string `json:"name"`
Role Role `json:"role"`
Hash string `json:"hash"`
}
type Token struct {
Name string `json:"name"`
Role Role `json:"role"`
Hash string `json:"hash"` // hex SHA-256 of the token
}
// Job is something done on a schedule.
type Job struct {
ID string `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
Servers []string `json:"servers"` // ids; empty means every server
Action string `json:"action"` // save, broadcast, restart, command, destroy_wild_dinos, motd
Every string `json:"every"` // a Go duration such as "30m"; or
At []string `json:"at"` // times of day as "HH:MM" (server local time)
Days []string `json:"days"` // limit At to these days ("mon".."sun"); empty means every day
Message string `json:"message"` // for broadcast and motd
Command string `json:"command"` // for command
Countdown string `json:"countdown"` // for restart, such as "10m"
Reason string `json:"reason"` // shown to players with a restart
}
// Macro is a named list of console commands that can be run in one click.
type Macro struct {
Name string `json:"name"`
Commands []string `json:"commands"`
Role Role `json:"role"` // who may run it; default admin
}
// Notify sends events somewhere else.
type Notify struct {
Name string `json:"name"`
Kind string `json:"kind"` // discord, ntfy or json
URL string `json:"url"` //
Events []string `json:"events"` // event types; empty means the default set
Servers []string `json:"servers"`
}
type Welcome struct {
Enabled bool `json:"enabled"`
Message string `json:"message"` // {name}, {server} and {players} are filled in
}
type ChatFilter struct {
Enabled bool `json:"enabled"`
Words []string `json:"words"`
Action string `json:"action"` // warn, kick or ban
Strikes int `json:"strikes"` // hits before the action; default 3
BanMinutes int `json:"ban_minutes"` // length of a ban made by the filter; default 60
}
// JobActions are the things a Job may do.
var JobActions = map[string]bool{"save": true, "broadcast": true, "restart": true, "command": true, "destroy_wild_dinos": true, "motd": true}
var (
idPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,31}$`)
clockRegex = regexp.MustCompile(`^([01]\d|2[0-3]):[0-5]\d$`)
weekdays = map[string]bool{"mon": true, "tue": true, "wed": true, "thu": true, "fri": true, "sat": true, "sun": true}
)
// Defaults fills in anything left blank.
func (c *Config) Defaults() {
if c.Listen == "" {
c.Listen = "127.0.0.1:8480"
}
if c.DataDir == "" {
c.DataDir = "data"
}
if c.PollSeconds <= 0 {
c.PollSeconds = 10
}
if c.ChatSeconds <= 0 {
c.ChatSeconds = 3
}
if c.ChatFilter.Action == "" {
c.ChatFilter.Action = "warn"
}
if c.ChatFilter.Strikes <= 0 {
c.ChatFilter.Strikes = 3
}
if c.ChatFilter.BanMinutes <= 0 {
c.ChatFilter.BanMinutes = 60
}
if c.Welcome.Message == "" {
c.Welcome.Message = "Welcome to {server}, {name}! {players} online."
}
for i := range c.Servers {
s := &c.Servers[i]
if s.Name == "" {
s.Name = s.ID
}
if s.RCONPort == 0 {
s.RCONPort = 27020
}
}
for i := range c.Macros {
if c.Macros[i].Role == "" {
c.Macros[i].Role = Admin
}
}
}
// Validate checks everything a person could have mistyped, so a bad edit is refused instead of
// taking effect.
func (c *Config) Validate() error {
seen := map[string]bool{}
for _, s := range c.Servers {
if !idPattern.MatchString(s.ID) {
return fmt.Errorf("server id %q: use lowercase letters, digits, - and _", s.ID)
}
if seen[s.ID] {
return fmt.Errorf("server id %q is used twice", s.ID)
}
seen[s.ID] = true
if strings.TrimSpace(s.Host) == "" {
return fmt.Errorf("server %s needs a host", s.ID)
}
if s.RCONPort < 1 || s.RCONPort > 65535 || s.QueryPort < 0 || s.QueryPort > 65535 {
return fmt.Errorf("server %s: ports must be 1-65535", s.ID)
}
}
names := map[string]bool{}
for _, u := range c.Users {
if u.Name == "" || names[u.Name] {
return fmt.Errorf("user %q: the name is empty or used twice", u.Name)
}
names[u.Name] = true
if u.Role.Rank() == 0 {
return fmt.Errorf("user %s: role must be viewer, moderator or admin", u.Name)
}
}
for _, t := range c.Tokens {
if t.Name == "" || t.Role.Rank() == 0 || t.Hash == "" {
return fmt.Errorf("token %q is incomplete", t.Name)
}
}
jobIDs := map[string]bool{}
for _, j := range c.Jobs {
if err := c.validJob(j, seen, jobIDs); err != nil {
return fmt.Errorf("job %q: %w", j.Name, err)
}
}
for _, m := range c.Macros {
if strings.TrimSpace(m.Name) == "" || len(m.Commands) == 0 {
return errors.New("a macro needs a name and at least one command")
}
if m.Role.Rank() == 0 {
return fmt.Errorf("macro %s: unknown role", m.Name)
}
}
for _, n := range c.Notify {
u, err := url.Parse(n.URL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return fmt.Errorf("notification %q needs an http(s) URL", n.Name)
}
switch n.Kind {
case "discord", "ntfy", "json":
default:
return fmt.Errorf("notification %q: kind must be discord, ntfy or json", n.Name)
}
}
if a := c.ChatFilter.Action; a != "warn" && a != "kick" && a != "ban" {
return errors.New("chat filter action must be warn, kick or ban")
}
return nil
}
func (c *Config) validJob(j Job, servers, ids map[string]bool) error {
if j.ID == "" || ids[j.ID] {
return errors.New("missing or repeated id")
}
ids[j.ID] = true
if strings.TrimSpace(j.Name) == "" {
return errors.New("needs a name")
}
if !JobActions[j.Action] {
return fmt.Errorf("unknown action %q", j.Action)
}
for _, s := range j.Servers {
if !servers[s] {
return fmt.Errorf("unknown server %q", s)
}
}
if (j.Every == "") == (len(j.At) == 0) {
return errors.New("set either every or at")
}
if j.Every != "" {
d, err := time.ParseDuration(j.Every)
if err != nil || d < time.Minute {
return errors.New("every must be a duration of at least 1m, such as 30m or 6h")
}
}
for _, t := range j.At {
if !clockRegex.MatchString(t) {
return fmt.Errorf("time %q must look like 04:30", t)
}
}
for _, d := range j.Days {
if !weekdays[d] {
return fmt.Errorf("day %q must be mon, tue, ... sun", d)
}
}
if j.Countdown != "" {
if _, err := time.ParseDuration(j.Countdown); err != nil {
return errors.New("countdown must be a duration such as 10m")
}
}
switch j.Action {
case "broadcast", "motd":
if strings.TrimSpace(j.Message) == "" {
return errors.New("needs a message")
}
case "command":
if strings.TrimSpace(j.Command) == "" {
return errors.New("needs a command")
}
}
return nil
}
// Holder keeps the live configuration and writes every change to disk.
type Holder struct {
path string
mu sync.RWMutex
cfg Config
}
// Load reads the file. A missing file gives the defaults (and is created by the first Update).
func Load(path string) (*Holder, error) {
h := &Holder{path: path}
b, err := os.ReadFile(path)
switch {
case errors.Is(err, os.ErrNotExist):
case err != nil:
return nil, err
default:
if err := json.Unmarshal(b, &h.cfg); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
}
h.cfg.Defaults()
if err := h.cfg.Validate(); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return h, nil
}
func (h *Holder) Path() string { return h.path }
// Exists says whether the file is on disk yet.
func (h *Holder) Exists() bool { _, err := os.Stat(h.path); return err == nil }
// Get returns a private copy; change it freely.
func (h *Holder) Get() Config {
h.mu.RLock()
defer h.mu.RUnlock()
return clone(h.cfg)
}
func clone(c Config) Config {
b, _ := json.Marshal(c)
var out Config
json.Unmarshal(b, &out)
return out
}
// Update applies fn to a copy, validates it, saves it and only then makes it current.
func (h *Holder) Update(fn func(*Config) error) error {
h.mu.Lock()
defer h.mu.Unlock()
c := clone(h.cfg)
if err := fn(&c); err != nil {
return err
}
c.Defaults()
if err := c.Validate(); err != nil {
return err
}
if err := save(h.path, c); err != nil {
return err
}
h.cfg = c
return nil
}
func save(path string, c Config) error {
b, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
if dir := filepath.Dir(path); dir != "." {
os.MkdirAll(dir, 0o755)
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
// ---- passwords and tokens ----
const pbkdf2Rounds = 600_000
// HashPassword returns "pbkdf2$rounds$salt$key" for storing in a User.
func HashPassword(password string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
key, err := pbkdf2.Key(sha256.New, password, salt, pbkdf2Rounds, 32)
if err != nil {
return "", err
}
return fmt.Sprintf("pbkdf2$%d$%s$%s", pbkdf2Rounds, base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil
}
// CheckPassword compares in constant time. An unparseable hash never matches.
func CheckPassword(hash, password string) bool {
parts := strings.Split(hash, "$")
if len(parts) != 4 || parts[0] != "pbkdf2" {
return false
}
rounds, err := strconv.Atoi(parts[1])
if err != nil || rounds < 1 || rounds > 10_000_000 {
return false
}
salt, err1 := base64.RawStdEncoding.DecodeString(parts[2])
want, err2 := base64.RawStdEncoding.DecodeString(parts[3])
if err1 != nil || err2 != nil {
return false
}
got, err := pbkdf2.Key(sha256.New, password, salt, rounds, len(want))
return err == nil && subtle.ConstantTimeCompare(got, want) == 1
}
// NewToken makes an API token and the hash to store for it. The token itself is shown once.
func NewToken() (token, hash string, err error) {
b := make([]byte, 32)
if _, err = rand.Read(b); err != nil {
return
}
token = "mgl_" + base64.RawURLEncoding.EncodeToString(b)
return token, HashToken(token), nil
}
func HashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// RandomPassword makes a password for a first-run admin.
func RandomPassword() string {
b := make([]byte, 12)
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}