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>
446 lines
13 KiB
Go
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)
|
|
}
|