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,177 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Event is one thing that happened: a player joined, a server went down, someone ran a command.
|
||||
type Event struct {
|
||||
ID int64 `json:"id"`
|
||||
Time time.Time `json:"time"`
|
||||
Server string `json:"server,omitempty"`
|
||||
Type string `json:"type"` // online, offline, join, leave, chat, audit, restart, start, ban, unban, kick, filter, watch, schedule, error
|
||||
Actor string `json:"actor,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
SteamID string `json:"steam_id,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
ringMax = 3000
|
||||
rotateAfter = 20 << 20
|
||||
)
|
||||
|
||||
// Hub records events (in memory and in a JSON-lines file) and hands them to anyone listening.
|
||||
type Hub struct {
|
||||
mu sync.Mutex
|
||||
seq int64
|
||||
ring []Event
|
||||
subs map[chan Event]struct{}
|
||||
path string
|
||||
f *os.File
|
||||
written int64
|
||||
}
|
||||
|
||||
// NewHub opens dir/events.jsonl, loading the most recent events from it.
|
||||
func NewHub(dir string) (*Hub, error) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h := &Hub{path: filepath.Join(dir, "events.jsonl"), subs: map[chan Event]struct{}{}}
|
||||
if f, err := os.Open(h.path); err == nil {
|
||||
sc := bufio.NewScanner(f)
|
||||
sc.Buffer(make([]byte, 64<<10), 1<<20)
|
||||
for sc.Scan() {
|
||||
var e Event
|
||||
if json.Unmarshal(sc.Bytes(), &e) == nil && e.ID > 0 {
|
||||
h.ring = append(h.ring, e)
|
||||
if len(h.ring) > ringMax {
|
||||
h.ring = h.ring[len(h.ring)-ringMax:]
|
||||
}
|
||||
if e.ID > h.seq {
|
||||
h.seq = e.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
if err := h.open(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
func (h *Hub) open() error {
|
||||
f, err := os.OpenFile(h.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st, _ := f.Stat()
|
||||
if st != nil {
|
||||
h.written = st.Size()
|
||||
}
|
||||
h.f = f
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Hub) Close() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.f != nil {
|
||||
h.f.Close()
|
||||
h.f = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Publish stamps and stores an event and returns it as stored.
|
||||
func (h *Hub) Publish(e Event) Event {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.seq++
|
||||
e.ID = h.seq
|
||||
if e.Time.IsZero() {
|
||||
e.Time = time.Now()
|
||||
}
|
||||
h.ring = append(h.ring, e)
|
||||
if len(h.ring) > ringMax {
|
||||
h.ring = append([]Event(nil), h.ring[len(h.ring)-ringMax:]...)
|
||||
}
|
||||
if h.f != nil {
|
||||
if b, err := json.Marshal(e); err == nil {
|
||||
n, _ := h.f.Write(append(b, '\n'))
|
||||
h.written += int64(n)
|
||||
if h.written > rotateAfter {
|
||||
h.f.Close()
|
||||
os.Rename(h.path, h.path+".1")
|
||||
h.open()
|
||||
}
|
||||
}
|
||||
}
|
||||
for ch := range h.subs {
|
||||
select {
|
||||
case ch <- e:
|
||||
default: // a slow listener misses events rather than holding everyone up
|
||||
}
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// Subscribe returns a channel of new events and a function that stops it.
|
||||
func (h *Hub) Subscribe() (<-chan Event, func()) {
|
||||
ch := make(chan Event, 64)
|
||||
h.mu.Lock()
|
||||
h.subs[ch] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
return ch, func() {
|
||||
h.mu.Lock()
|
||||
delete(h.subs, ch)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Filter selects events for Recent.
|
||||
type Filter struct {
|
||||
Server string
|
||||
Types []string
|
||||
Query string // case-insensitive text in the message or actor
|
||||
Before int64 // only events with a lower id (for paging back)
|
||||
Limit int
|
||||
}
|
||||
|
||||
// Recent returns matching events, newest first.
|
||||
func (h *Hub) Recent(f Filter) []Event {
|
||||
if f.Limit <= 0 || f.Limit > 1000 {
|
||||
f.Limit = 200
|
||||
}
|
||||
q := strings.ToLower(f.Query)
|
||||
want := map[string]bool{}
|
||||
for _, t := range f.Types {
|
||||
want[t] = true
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
var out []Event
|
||||
for i := len(h.ring) - 1; i >= 0 && len(out) < f.Limit; i-- {
|
||||
e := h.ring[i]
|
||||
if f.Before > 0 && e.ID >= f.Before {
|
||||
continue
|
||||
}
|
||||
if f.Server != "" && e.Server != f.Server {
|
||||
continue
|
||||
}
|
||||
if len(want) > 0 && !want[e.Type] {
|
||||
continue
|
||||
}
|
||||
if q != "" && !strings.Contains(strings.ToLower(e.Message+" "+e.Actor), q) {
|
||||
continue
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user