// 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) }