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,445 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in new issue
Block a user