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

243 lines
5.6 KiB
Go

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
}