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,242 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
func saveJSON(path string, v any) error {
|
||||
b, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
func loadJSON(path string, v any) error {
|
||||
b, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(b, v)
|
||||
}
|
||||
|
||||
// PlayerRecord is what Megalodon remembers about someone who has been on any server.
|
||||
type PlayerRecord struct {
|
||||
SteamID string `json:"steam_id"`
|
||||
Name string `json:"name"`
|
||||
FirstSeen time.Time `json:"first_seen"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
Seconds int64 `json:"seconds"` // time spent online, across all servers
|
||||
Joins int `json:"joins"`
|
||||
LastOn string `json:"last_server,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Watch bool `json:"watch,omitempty"` // raise an event when they join
|
||||
}
|
||||
|
||||
// PlayerDB is the list of everyone seen, saved to a file.
|
||||
type PlayerDB struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
recs map[string]*PlayerRecord
|
||||
dirty bool
|
||||
}
|
||||
|
||||
func openPlayers(dir string) (*PlayerDB, error) {
|
||||
db := &PlayerDB{path: filepath.Join(dir, "players.json"), recs: map[string]*PlayerRecord{}}
|
||||
var list []*PlayerRecord
|
||||
if err := loadJSON(db.path, &list); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range list {
|
||||
db.recs[r.SteamID] = r
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// Seen notes that a player is online on server. online is the time since the previous poll to
|
||||
// credit them with; joined is true the moment they appear.
|
||||
func (db *PlayerDB) Seen(id, name, server string, online time.Duration, joined bool) PlayerRecord {
|
||||
db.mu.Lock()
|
||||
defer db.mu.Unlock()
|
||||
now := time.Now()
|
||||
r := db.recs[id]
|
||||
if r == nil {
|
||||
r = &PlayerRecord{SteamID: id, FirstSeen: now}
|
||||
db.recs[id] = r
|
||||
}
|
||||
r.Name, r.LastSeen, r.LastOn = name, now, server
|
||||
r.Seconds += int64(online / time.Second)
|
||||
if joined {
|
||||
r.Joins++
|
||||
}
|
||||
db.dirty = true
|
||||
return *r
|
||||
}
|
||||
|
||||
func (db *PlayerDB) Get(id string) (PlayerRecord, bool) {
|
||||
db.mu.Lock()
|
||||
defer db.mu.Unlock()
|
||||
if r := db.recs[id]; r != nil {
|
||||
return *r, true
|
||||
}
|
||||
return PlayerRecord{}, false
|
||||
}
|
||||
|
||||
// List returns everyone, most recently seen first.
|
||||
func (db *PlayerDB) List() []PlayerRecord {
|
||||
db.mu.Lock()
|
||||
defer db.mu.Unlock()
|
||||
out := make([]PlayerRecord, 0, len(db.recs))
|
||||
for _, r := range db.recs {
|
||||
out = append(out, *r)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].LastSeen.After(out[j].LastSeen) })
|
||||
return out
|
||||
}
|
||||
|
||||
// Annotate sets the notes and watch flag for a player, creating the record if needed.
|
||||
func (db *PlayerDB) Annotate(id, notes string, watch bool) {
|
||||
db.mu.Lock()
|
||||
defer db.mu.Unlock()
|
||||
r := db.recs[id]
|
||||
if r == nil {
|
||||
r = &PlayerRecord{SteamID: id, FirstSeen: time.Now(), LastSeen: time.Now()}
|
||||
db.recs[id] = r
|
||||
}
|
||||
r.Notes, r.Watch = notes, watch
|
||||
db.dirty = true
|
||||
}
|
||||
|
||||
func (db *PlayerDB) Flush() {
|
||||
db.mu.Lock()
|
||||
defer db.mu.Unlock()
|
||||
if !db.dirty {
|
||||
return
|
||||
}
|
||||
list := make([]*PlayerRecord, 0, len(db.recs))
|
||||
for _, r := range db.recs {
|
||||
list = append(list, r)
|
||||
}
|
||||
if saveJSON(db.path, list) == nil {
|
||||
db.dirty = false
|
||||
}
|
||||
}
|
||||
|
||||
// Ban is a ban Megalodon enforces on top of the game's own list: it can expire, carries a
|
||||
// reason, and follows the player across a whole cluster.
|
||||
type Ban struct {
|
||||
SteamID string `json:"steam_id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
By string `json:"by"`
|
||||
At time.Time `json:"at"`
|
||||
Expires *time.Time `json:"expires,omitempty"`
|
||||
Servers []string `json:"servers,omitempty"` // empty means every server
|
||||
}
|
||||
|
||||
func (b Ban) Active(now time.Time) bool { return b.Expires == nil || now.Before(*b.Expires) }
|
||||
|
||||
func (b Ban) Covers(server string) bool {
|
||||
if len(b.Servers) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, s := range b.Servers {
|
||||
if s == server {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type BanDB struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
bans map[string]Ban
|
||||
}
|
||||
|
||||
func openBans(dir string) (*BanDB, error) {
|
||||
db := &BanDB{path: filepath.Join(dir, "bans.json"), bans: map[string]Ban{}}
|
||||
var list []Ban
|
||||
if err := loadJSON(db.path, &list); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, b := range list {
|
||||
db.bans[b.SteamID] = b
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func (db *BanDB) saveLocked() {
|
||||
list := make([]Ban, 0, len(db.bans))
|
||||
for _, b := range db.bans {
|
||||
list = append(list, b)
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool { return list[i].At.After(list[j].At) })
|
||||
saveJSON(db.path, list)
|
||||
}
|
||||
|
||||
func (db *BanDB) Add(b Ban) {
|
||||
db.mu.Lock()
|
||||
defer db.mu.Unlock()
|
||||
db.bans[b.SteamID] = b
|
||||
db.saveLocked()
|
||||
}
|
||||
|
||||
func (db *BanDB) Remove(id string) (Ban, bool) {
|
||||
db.mu.Lock()
|
||||
defer db.mu.Unlock()
|
||||
b, ok := db.bans[id]
|
||||
if ok {
|
||||
delete(db.bans, id)
|
||||
db.saveLocked()
|
||||
}
|
||||
return b, ok
|
||||
}
|
||||
|
||||
// Active returns the ban that applies to id on server right now, if any.
|
||||
func (db *BanDB) Active(id, server string) (Ban, bool) {
|
||||
db.mu.Lock()
|
||||
defer db.mu.Unlock()
|
||||
b, ok := db.bans[id]
|
||||
if !ok || !b.Active(time.Now()) || !b.Covers(server) {
|
||||
return Ban{}, false
|
||||
}
|
||||
return b, true
|
||||
}
|
||||
|
||||
func (db *BanDB) List() []Ban {
|
||||
db.mu.Lock()
|
||||
defer db.mu.Unlock()
|
||||
out := make([]Ban, 0, len(db.bans))
|
||||
for _, b := range db.bans {
|
||||
out = append(out, b)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].At.After(out[j].At) })
|
||||
return out
|
||||
}
|
||||
|
||||
// Expired returns (without removing) the bans whose time is up.
|
||||
func (db *BanDB) Expired(now time.Time) []Ban {
|
||||
db.mu.Lock()
|
||||
defer db.mu.Unlock()
|
||||
var out []Ban
|
||||
for _, b := range db.bans {
|
||||
if !b.Active(now) {
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user