Megalodon: manage ARK servers over RCON from a web dashboard
CI / test (push) Successful in 2m26s
Release / release (push) Successful in 2m36s

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:
VeillaxandClaude Sonnet 5.5 committed 2026-10-30 23:36:13 -06:00
commit 0d2cb31abf
32 files changed
+6280

No files matched your search

+177
View File
@@ -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
}
+735
View File
@@ -0,0 +1,735 @@
package manager
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
"git.veillax.com/Veillax/megalodon/internal/ark"
"git.veillax.com/Veillax/megalodon/internal/config"
"git.veillax.com/Veillax/megalodon/internal/rcon"
)
const (
sampleEvery = time.Minute
sampleKeep = 1440 // a day
)
// Sample is one point of the player-count history.
type Sample struct {
T int64 `json:"t"` // unix seconds
N int `json:"n"` // players online
Up bool `json:"up"`
}
// PlayerView is a connected player as the UI sees them.
type PlayerView struct {
ark.Player
JoinedAt time.Time `json:"joined_at"`
Watch bool `json:"watch,omitempty"`
Notes string `json:"notes,omitempty"`
Banned bool `json:"banned,omitempty"`
}
// RestartPlan is a restart or shutdown that is counting down.
type RestartPlan struct {
At time.Time `json:"at"`
By string `json:"by"`
Reason string `json:"reason,omitempty"`
Stop bool `json:"stop"` // shut down and stay down, instead of restarting
}
// State is what is currently known about a server.
type State struct {
Online bool `json:"online"`
Since *time.Time `json:"since,omitempty"` // when it came up, as far as Megalodon saw
OfflineSince *time.Time `json:"offline_since,omitempty"`
LastSeen *time.Time `json:"last_seen,omitempty"`
LastError string `json:"last_error,omitempty"`
RTTMS int64 `json:"rtt_ms"`
Players []PlayerView `json:"players"`
Info *ark.Info `json:"info,omitempty"`
Held bool `json:"held"` // stopped on purpose: auto-start stays off
Restart *RestartPlan `json:"restart,omitempty"`
Peak int `json:"peak"` // most players seen at once since Megalodon started
}
// View is a server plus its state, as sent to the UI.
type View struct {
ID string `json:"id"`
Name string `json:"name"`
Host string `json:"host"`
RCONPort int `json:"rcon_port"`
QueryPort int `json:"query_port"`
CanStart bool `json:"can_start"`
AutoStart bool `json:"auto_start"`
State
}
type strike struct {
n int
last time.Time
}
// Instance watches and controls one server.
type Instance struct {
cfg config.Server
m *Manager
rc *rcon.Client
ctx context.Context
cancel context.CancelFunc
done chan struct{}
mu sync.RWMutex
st State
known bool // a poll has finished since Megalodon started, so changes are real events
fails int
lastPoll time.Time
lastSample time.Time
samples []Sample
planCancel context.CancelFunc
autoStarted time.Time
strikes map[string]strike
}
func newInstance(m *Manager, cfg config.Server) *Instance {
i := &Instance{
cfg: cfg, m: m, done: make(chan struct{}), strikes: map[string]strike{},
rc: rcon.New(fmt.Sprintf("%s:%d", cfg.Host, cfg.RCONPort), cfg.Password),
}
i.rc.Timeout = 6 * time.Second
i.loadSamples()
return i
}
func (i *Instance) start(parent context.Context) {
i.ctx, i.cancel = context.WithCancel(parent)
go func() {
defer close(i.done)
i.run()
}()
}
func (i *Instance) stop() {
i.cancel()
<-i.done
i.rc.Close()
}
func (i *Instance) event(typ, actor, msg string) Event {
return i.m.hub.Publish(Event{Server: i.cfg.ID, Type: typ, Actor: actor, Message: msg})
}
// View returns the server's current state.
func (i *Instance) View() View {
i.mu.RLock()
defer i.mu.RUnlock()
st := i.st
st.Players = append([]PlayerView(nil), i.st.Players...)
return View{
ID: i.cfg.ID, Name: i.cfg.Name, Host: i.cfg.Host, RCONPort: i.cfg.RCONPort, QueryPort: i.cfg.QueryPort,
CanStart: i.cfg.StartCommand != "", AutoStart: i.cfg.AutoStart, State: st,
}
}
// Samples returns the player-count history, oldest first.
func (i *Instance) Samples() []Sample {
i.mu.RLock()
defer i.mu.RUnlock()
return append([]Sample(nil), i.samples...)
}
// Exec sends one console command.
func (i *Instance) Exec(cmd string) (string, error) { return i.rc.Exec(cmd) }
func (i *Instance) run() {
cfg := i.m.cfg.Get()
poll := time.NewTicker(time.Duration(cfg.PollSeconds) * time.Second)
chat := time.NewTicker(time.Duration(cfg.ChatSeconds) * time.Second)
defer poll.Stop()
defer chat.Stop()
i.poll()
for {
select {
case <-i.ctx.Done():
return
case <-poll.C:
i.poll()
case <-chat.C:
i.pollChat()
}
}
}
// ---- polling ----
func (i *Instance) poll() {
start := time.Now()
out, err := i.rc.Exec(ark.ListPlayers)
rtt := time.Since(start)
if err != nil {
i.markFailed(err)
return
}
var info *ark.Info
if i.cfg.QueryPort > 0 {
info, _ = ark.Query(fmt.Sprintf("%s:%d", i.cfg.Host, i.cfg.QueryPort), 2*time.Second)
}
i.apply(ark.ParsePlayers(out), info, rtt)
}
func (i *Instance) markFailed(err error) {
now := time.Now()
i.mu.Lock()
i.fails++
i.st.LastError = err.Error()
wentDown := i.st.Online && (i.fails >= 2 || errors.Is(err, rcon.ErrAuth))
if wentDown {
i.st.Online = false
i.st.Players, i.st.Info, i.st.Since = nil, nil, nil
}
if !i.st.Online && i.st.OfflineSince == nil {
i.st.OfflineSince = &now
}
i.known = true
i.recordSample(now, 0, false)
i.mu.Unlock()
if wentDown {
i.event("offline", "", "Server went offline: "+err.Error())
}
i.maybeAutoStart()
}
func (i *Instance) apply(players []ark.Player, info *ark.Info, rtt time.Duration) {
now := time.Now()
i.mu.Lock()
wasOnline, known := i.st.Online, i.known
prev := map[string]PlayerView{}
for _, p := range i.st.Players {
prev[p.SteamID] = p
}
dt := time.Duration(0)
if wasOnline && !i.lastPoll.IsZero() {
dt = now.Sub(i.lastPoll)
if limit := 3 * time.Duration(i.m.cfg.Get().PollSeconds) * time.Second; dt > limit {
dt = limit
}
}
i.lastPoll, i.fails, i.known = now, 0, true
i.st.Online, i.st.LastSeen, i.st.LastError, i.st.RTTMS, i.st.Info = true, &now, "", rtt.Milliseconds(), info
if !wasOnline {
i.st.Since, i.st.OfflineSince = &now, nil
}
views := make([]PlayerView, 0, len(players))
var joined, left []PlayerView
cur := map[string]bool{}
for _, p := range players {
cur[p.SteamID] = true
v, was := prev[p.SteamID]
if !was {
v = PlayerView{Player: p, JoinedAt: now}
joined = append(joined, v)
}
v.Name = p.Name
views = append(views, v)
}
for id, v := range prev {
if !cur[id] {
left = append(left, v)
}
}
if len(views) > i.st.Peak {
i.st.Peak = len(views)
}
i.recordSample(now, len(views), true)
i.mu.Unlock()
// Who is on: remember them, and decorate with notes and ban status for the UI.
isNew := joinedSet(joined)
for k, v := range views {
_, joinedNow := isNew[v.SteamID]
rec := i.m.players.Seen(v.SteamID, v.Name, i.cfg.ID, dt, joinedNow && known)
_, banned := i.m.bans.Active(v.SteamID, i.cfg.ID)
views[k].Watch, views[k].Notes, views[k].Banned = rec.Watch, rec.Notes, banned
}
i.mu.Lock()
i.st.Players = views
i.mu.Unlock()
if !wasOnline && known {
i.event("online", "", "Server is online")
}
for _, v := range left {
i.event("leave", "", fmt.Sprintf("%s left after %s", v.Name, ark.Duration(now.Sub(v.JoinedAt))))
}
for _, v := range views {
if b, banned := i.m.bans.Active(v.SteamID, i.cfg.ID); banned {
i.enforceBan(v, b)
continue
}
if _, fresh := isNew[v.SteamID]; fresh && known {
i.onJoin(v, len(views))
}
}
}
func joinedSet(l []PlayerView) map[string]struct{} {
m := make(map[string]struct{}, len(l))
for _, v := range l {
m[v.SteamID] = struct{}{}
}
return m
}
func (i *Instance) onJoin(v PlayerView, online int) {
i.m.hub.Publish(Event{Server: i.cfg.ID, Type: "join", Message: v.Name + " joined", SteamID: v.SteamID})
if v.Watch {
note := ""
if v.Notes != "" {
note = " (" + v.Notes + ")"
}
i.event("watch", "", "Watched player "+v.Name+" joined"+note)
}
if w := i.m.cfg.Get().Welcome; w.Enabled && w.Message != "" {
msg := strings.NewReplacer("{name}", v.Name, "{server}", i.cfg.Name, "{players}", strconv.Itoa(online)).Replace(w.Message)
if cmd, err := ark.ServerChatTo(v.SteamID, msg); err == nil {
// Give the player a moment to finish loading in, or they never see it.
go func() {
select {
case <-time.After(15 * time.Second):
i.rc.Exec(cmd)
case <-i.ctx.Done():
}
}()
}
}
}
func (i *Instance) enforceBan(v PlayerView, b Ban) {
cmd, err := ark.ForPlayer("BanPlayer", v.SteamID)
if err != nil {
return
}
if _, err := i.rc.Exec(cmd); err == nil {
why := b.Reason
if why == "" {
why = "banned"
}
i.event("kick", "megalodon", fmt.Sprintf("Removed banned player %s (%s)", v.Name, why))
}
}
// recordSample must be called with i.mu held.
func (i *Instance) recordSample(now time.Time, n int, up bool) {
if now.Sub(i.lastSample) < sampleEvery {
return
}
i.lastSample = now
s := Sample{T: now.Unix(), N: n, Up: up}
i.samples = append(i.samples, s)
if len(i.samples) > sampleKeep {
i.samples = i.samples[len(i.samples)-sampleKeep:]
}
if f, err := os.OpenFile(i.samplePath(), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600); err == nil {
b, _ := json.Marshal(s)
f.Write(append(b, '\n'))
f.Close()
}
}
func (i *Instance) samplePath() string {
return filepath.Join(i.m.dir, "samples-"+i.cfg.ID+".jsonl")
}
func (i *Instance) loadSamples() {
f, err := os.Open(i.samplePath())
if err != nil {
return
}
defer f.Close()
cutoff := time.Now().Add(-24 * time.Hour).Unix()
sc := bufio.NewScanner(f)
for sc.Scan() {
var s Sample
if json.Unmarshal(sc.Bytes(), &s) == nil && s.T >= cutoff {
i.samples = append(i.samples, s)
}
}
// Rewrite the file so it only ever holds about a day.
var sb strings.Builder
for _, s := range i.samples {
b, _ := json.Marshal(s)
sb.Write(b)
sb.WriteByte('\n')
}
os.WriteFile(i.samplePath(), []byte(sb.String()), 0o600)
}
// ---- chat ----
func (i *Instance) pollChat() {
i.mu.RLock()
online := i.st.Online
i.mu.RUnlock()
if !online {
return
}
out, err := i.rc.Exec(ark.GetChat)
if err != nil {
return
}
for _, cl := range ark.ParseChat(out) {
who := cl.Sender
if cl.Server && who == "" {
who = "SERVER"
}
i.m.hub.Publish(Event{Server: i.cfg.ID, Type: "chat", Actor: who, Message: cl.Message, Detail: cl.Character})
if !cl.Server {
i.filter(cl)
}
}
}
// filter applies the word filter to one line of player chat.
func (i *Instance) filter(cl ark.ChatLine) {
cf := i.m.cfg.Get().ChatFilter
if !cf.Enabled {
return
}
low := strings.ToLower(cl.Message)
hit := false
for _, w := range cf.Words {
if w = strings.ToLower(strings.TrimSpace(w)); w != "" && strings.Contains(low, w) {
hit = true
break
}
}
if !hit {
return
}
i.mu.Lock()
var who *PlayerView
for k := range i.st.Players {
if strings.EqualFold(i.st.Players[k].Name, cl.Sender) {
who = &i.st.Players[k]
break
}
}
if who == nil {
i.mu.Unlock()
return
}
p := *who
s := i.strikes[p.SteamID]
if time.Since(s.last) > time.Hour {
s.n = 0
}
s.n++
s.last = time.Now()
i.strikes[p.SteamID] = s
over := s.n >= cf.Strikes
if over {
delete(i.strikes, p.SteamID)
}
i.mu.Unlock()
switch {
case over && cf.Action == "kick":
i.run1("KickPlayer", p.SteamID)
i.event("filter", "megalodon", fmt.Sprintf("Kicked %s for language (%d strikes)", p.Name, cf.Strikes))
case over && cf.Action == "ban":
i.m.Ban("megalodon", p.SteamID, p.Name, "chat filter", time.Duration(cf.BanMinutes)*time.Minute, []string{i.cfg.ID})
i.event("filter", "megalodon", fmt.Sprintf("Banned %s for %d minutes for language", p.Name, cf.BanMinutes))
default:
msg := fmt.Sprintf("Please keep chat friendly (warning %d of %d).", s.n, cf.Strikes)
if over {
msg = "Please keep chat friendly."
}
if cmd, err := ark.ServerChatTo(p.SteamID, msg); err == nil {
i.rc.Exec(cmd)
}
i.event("filter", "megalodon", fmt.Sprintf("Warned %s (%d of %d)", p.Name, s.n, cf.Strikes))
}
}
func (i *Instance) run1(cmd, steamID string) {
if c, err := ark.ForPlayer(cmd, steamID); err == nil {
i.rc.Exec(c)
}
}
// ---- actions ----
// Perform runs one action on this server and returns the server's reply.
func (i *Instance) Perform(actor string, a Action) (string, error) {
var cmd string
var err error
switch a.Name {
case "save":
cmd = ark.SaveWorld
case "broadcast":
cmd, err = ark.Broadcast(a.Message)
case "say":
cmd, err = ark.ServerChat(a.Message)
case "whisper":
cmd, err = ark.ServerChatTo(a.SteamID, a.Message)
case "kick":
cmd, err = ark.ForPlayer("KickPlayer", a.SteamID)
case "allow":
cmd, err = ark.ForPlayer("AllowPlayerToJoinNoCheck", a.SteamID)
case "disallow":
cmd, err = ark.ForPlayer("DisallowPlayerToJoinNoCheck", a.SteamID)
case "destroy_wild_dinos":
cmd = ark.DestroyWildDinos
case "set_time":
cmd, err = ark.SetTimeOfDay(a.Time)
case "motd":
cmd, err = ark.SetMOTD(a.Message)
case "exec":
if cmd = strings.TrimSpace(a.Command); cmd == "" {
err = errors.New("no command given")
}
case "restart", "stop":
err = i.ScheduleRestart(actor, time.Duration(a.Minutes*float64(time.Minute)), a.Reason, a.Name == "stop")
if err == nil {
return "scheduled", nil
}
case "cancel_restart":
err = i.CancelRestart(actor)
if err == nil {
return "cancelled", nil
}
case "start":
return i.Start(actor)
default:
err = fmt.Errorf("unknown action %q", a.Name)
}
if err != nil {
return "", err
}
out, err := i.rc.Exec(cmd)
if err != nil {
return "", err
}
i.audit(actor, a)
if ark.IsEmptyReply(out) {
return "", nil
}
return strings.TrimSpace(out), nil
}
func (i *Instance) audit(actor string, a Action) {
detail := a.Name
switch a.Name {
case "exec":
detail = "console: " + a.Command
case "broadcast", "say", "motd":
detail = a.Name + ": " + a.Message
case "whisper":
detail = "whisper to " + a.SteamID + ": " + a.Message
case "kick", "allow", "disallow":
detail = a.Name + " " + a.SteamID
case "set_time":
detail = "set time of day to " + a.Time
}
i.event("audit", actor, detail)
}
// ScheduleRestart announces a restart (or, with stop, a shutdown) and carries it out after delay.
// Players are warned along the way; the world is saved first.
func (i *Instance) ScheduleRestart(by string, delay time.Duration, reason string, stop bool) error {
if delay < 0 || delay > 24*time.Hour {
return errors.New("the delay must be between 0 and 24 hours")
}
i.mu.Lock()
if i.st.Restart != nil {
i.mu.Unlock()
return errors.New("a restart is already scheduled; cancel it first")
}
at := time.Now().Add(delay)
plan := &RestartPlan{At: at, By: by, Reason: ark.Clean(reason), Stop: stop}
ctx, cancel := context.WithCancel(i.ctx)
i.st.Restart, i.planCancel = plan, cancel
i.mu.Unlock()
verb := "restart"
if stop {
verb = "shutdown"
}
i.event("restart", by, fmt.Sprintf("Scheduled %s in %s%s", verb, ark.Duration(delay), withReason(plan.Reason)))
go i.runPlan(ctx, plan)
return nil
}
func withReason(r string) string {
if r == "" {
return ""
}
return " (" + r + ")"
}
// warnings are the moments before a restart at which players are told.
var warnings = []time.Duration{30 * time.Minute, 15 * time.Minute, 10 * time.Minute, 5 * time.Minute, 2 * time.Minute, time.Minute, 30 * time.Second, 10 * time.Second}
func (i *Instance) runPlan(ctx context.Context, plan *RestartPlan) {
finish := func() {
i.mu.Lock()
if i.st.Restart == plan {
i.st.Restart, i.planCancel = nil, nil
}
i.mu.Unlock()
}
defer finish()
verb, ing := "restarting", "restart"
if plan.Stop {
verb, ing = "shutting down", "shutdown"
}
say := func(until time.Duration) {
msg := fmt.Sprintf("Server %s in %s.", verb, ark.Duration(until))
if plan.Reason != "" {
msg += " " + plan.Reason
}
if cmd, err := ark.Broadcast(msg); err == nil {
i.rc.Exec(cmd)
}
}
wait := func(until time.Time) bool {
t := time.NewTimer(time.Until(until))
defer t.Stop()
select {
case <-t.C:
return true
case <-ctx.Done():
return false
}
}
total := time.Until(plan.At)
if total >= 15*time.Second {
say(total) // tell everyone straight away, not at the next fixed warning
}
for _, w := range warnings {
if w >= total-5*time.Second { // already announced above, or too late to matter
continue
}
if !wait(plan.At.Add(-w)) {
return
}
say(w)
}
if !wait(plan.At) {
return
}
i.event("restart", plan.By, "Saving and "+ing+"ing now")
i.rc.Exec(ark.SaveWorld)
time.Sleep(2 * time.Second)
i.rc.Exec(ark.DoExit) // the connection usually drops before a reply
if plan.Stop {
i.mu.Lock()
i.st.Held = true
i.mu.Unlock()
finish()
return
}
if i.cfg.StartCommand == "" {
return // a supervisor (docker, systemd) is expected to bring it back
}
// Wait for the server to be seen going down, then start it.
deadline := time.Now().Add(3 * time.Minute)
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return
case <-time.After(time.Second):
}
i.mu.RLock()
online := i.st.Online
i.mu.RUnlock()
if !online {
break
}
}
select {
case <-ctx.Done():
case <-time.After(5 * time.Second):
i.Start(plan.By)
}
}
// CancelRestart stops a countdown.
func (i *Instance) CancelRestart(by string) error {
i.mu.Lock()
defer i.mu.Unlock()
if i.st.Restart == nil {
return errors.New("no restart is scheduled")
}
i.planCancel()
i.st.Restart, i.planCancel = nil, nil
go func() {
if cmd, err := ark.Broadcast("The scheduled restart was cancelled."); err == nil {
i.rc.Exec(cmd)
}
}()
i.m.hub.Publish(Event{Server: i.cfg.ID, Type: "restart", Actor: by, Message: "Scheduled restart cancelled"})
return nil
}
// Start runs the server's start command.
func (i *Instance) Start(by string) (string, error) {
if i.cfg.StartCommand == "" {
return "", errors.New("this server has no start command configured")
}
i.mu.Lock()
i.st.Held = false
i.autoStarted = time.Now()
i.mu.Unlock()
out, err := runShell(i.cfg.StartCommand)
if err != nil {
i.event("error", by, "Start command failed: "+err.Error()+" "+out)
return out, err
}
i.event("start", by, "Ran the start command")
return out, nil
}
func (i *Instance) maybeAutoStart() {
if !i.cfg.AutoStart || i.cfg.StartCommand == "" {
return
}
after := time.Duration(i.cfg.AutoStartSec) * time.Second
if after <= 0 {
after = 2 * time.Minute
}
i.mu.RLock()
due := !i.st.Online && !i.st.Held && i.st.OfflineSince != nil && time.Since(*i.st.OfflineSince) >= after && time.Since(i.autoStarted) >= 5*time.Minute
i.mu.RUnlock()
if due {
i.Start("auto-start")
}
}
func runShell(cmd string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
var c *exec.Cmd
if runtime.GOOS == "windows" {
c = exec.CommandContext(ctx, "cmd", "/C", cmd)
} else {
c = exec.CommandContext(ctx, "sh", "-c", cmd)
}
out, err := c.CombinedOutput()
s := strings.TrimSpace(string(out))
if len(s) > 500 {
s = s[:500]
}
return s, err
}
+479
View File
@@ -0,0 +1,479 @@
// Package manager runs Megalodon's side of things: it keeps a connection to every server, tracks
// who is online, records events, enforces bans, runs scheduled jobs and sends notifications.
package manager
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
"git.veillax.com/Veillax/megalodon/internal/ark"
"git.veillax.com/Veillax/megalodon/internal/config"
)
// Action is something a person or a schedule asks to be done.
type Action struct {
Name string `json:"action"`
Servers []string `json:"servers,omitempty"` // empty or ["*"]: every server
SteamID string `json:"steam_id,omitempty"`
Player string `json:"player,omitempty"` // name, for the record
Message string `json:"message,omitempty"`
Command string `json:"command,omitempty"`
Time string `json:"time,omitempty"`
Reason string `json:"reason,omitempty"`
Minutes float64 `json:"minutes,omitempty"` // restart countdown, or ban length (0 = permanent)
}
// Result is how an action went on one server.
type Result struct {
Server string `json:"server"`
OK bool `json:"ok"`
Output string `json:"output,omitempty"`
Error string `json:"error,omitempty"`
}
// ActionRole is the lowest role allowed to run an action.
func ActionRole(name string) config.Role {
switch name {
case "broadcast", "say", "whisper", "kick", "ban", "unban", "allow", "disallow", "save":
return config.Moderator
}
return config.Admin
}
type Manager struct {
cfg *config.Holder
dir string
hub *Hub
players *PlayerDB
bans *BanDB
mu sync.RWMutex
inst map[string]*Instance
ctx context.Context
wg sync.WaitGroup
jobMu sync.Mutex
lastRun map[string]time.Time
lastFire map[string]string
client httpDoer
}
// New opens the data directory. Call Start to begin watching servers.
func New(h *config.Holder) (*Manager, error) {
dir := h.Get().DataDir
hub, err := NewHub(dir)
if err != nil {
return nil, err
}
players, err := openPlayers(dir)
if err != nil {
return nil, err
}
bans, err := openBans(dir)
if err != nil {
return nil, err
}
return &Manager{
cfg: h, dir: dir, hub: hub, players: players, bans: bans, inst: map[string]*Instance{},
lastRun: map[string]time.Time{}, lastFire: map[string]string{}, client: defaultClient(),
}, nil
}
func (m *Manager) Hub() *Hub { return m.hub }
// Start begins watching every configured server and running background work until ctx ends.
func (m *Manager) Start(ctx context.Context) {
m.ctx = ctx
m.Reload()
m.go_(m.scheduler)
m.go_(m.notifier)
m.go_(m.housekeeping)
}
func (m *Manager) go_(f func(context.Context)) {
m.wg.Add(1)
go func() { defer m.wg.Done(); f(m.ctx) }()
}
// Wait blocks until background work has finished (after the context given to Start is cancelled)
// and saves what is still in memory.
func (m *Manager) Wait() {
m.wg.Wait()
m.mu.Lock()
for id, i := range m.inst {
i.stop()
delete(m.inst, id)
}
m.mu.Unlock()
m.players.Flush()
m.hub.Close()
}
// Reload makes the running servers match the configuration: new ones start, removed ones stop,
// changed ones are restarted.
func (m *Manager) Reload() {
servers := m.cfg.Get().Servers
want := map[string]config.Server{}
for _, s := range servers {
want[s.ID] = s
}
m.mu.Lock()
defer m.mu.Unlock()
for id, i := range m.inst {
if s, ok := want[id]; !ok || !sameServer(s, i.cfg) {
i.stop()
delete(m.inst, id)
}
}
for _, s := range servers {
if _, ok := m.inst[s.ID]; !ok {
i := newInstance(m, s)
m.inst[s.ID] = i
i.start(m.ctx)
}
}
}
func sameServer(a, b config.Server) bool {
x, _ := json.Marshal(a)
y, _ := json.Marshal(b)
return string(x) == string(y)
}
func (m *Manager) Instance(id string) (*Instance, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
i, ok := m.inst[id]
return i, ok
}
// Views returns every server in configuration order.
func (m *Manager) Views() []View {
var out []View
for _, s := range m.cfg.Get().Servers {
if i, ok := m.Instance(s.ID); ok {
out = append(out, i.View())
}
}
return out
}
func (m *Manager) Players() []PlayerRecord { return m.players.List() }
func (m *Manager) Annotate(id, notes string, watch bool) error {
if !ark.ValidSteamID(id) {
return errors.New("not a Steam ID")
}
m.players.Annotate(id, ark.Clean(notes), watch)
return nil
}
func (m *Manager) Bans() []Ban { return m.bans.List() }
// Audit records something done outside a server (a settings change, say).
func (m *Manager) Audit(actor, server, msg string) {
m.hub.Publish(Event{Server: server, Type: "audit", Actor: actor, Message: msg})
}
// targets resolves server ids; empty or "*" means all.
func (m *Manager) targets(ids []string) ([]*Instance, error) {
if len(ids) == 0 || (len(ids) == 1 && ids[0] == "*") {
var out []*Instance
for _, v := range m.Views() {
if i, ok := m.Instance(v.ID); ok {
out = append(out, i)
}
}
if len(out) == 0 {
return nil, errors.New("no servers are configured")
}
return out, nil
}
var out []*Instance
for _, id := range ids {
i, ok := m.Instance(id)
if !ok {
return nil, fmt.Errorf("unknown server %q", id)
}
out = append(out, i)
}
return out, nil
}
// Do carries out an action on its target servers, one after another, and reports each result.
// The caller has already checked that actor may do this.
func (m *Manager) Do(actor string, a Action) ([]Result, error) {
if a.Name == "ban" || a.Name == "unban" {
return m.banAction(actor, a)
}
targets, err := m.targets(a.Servers)
if err != nil {
return nil, err
}
results := make([]Result, len(targets))
var wg sync.WaitGroup
for k, i := range targets {
wg.Add(1)
go func() {
defer wg.Done()
out, err := i.Perform(actor, a)
r := Result{Server: i.cfg.ID, OK: err == nil, Output: out}
if err != nil {
r.Error = err.Error()
}
results[k] = r
}()
}
wg.Wait()
return results, nil
}
func (m *Manager) banAction(actor string, a Action) ([]Result, error) {
if a.Name == "unban" {
return m.Unban(actor, a.SteamID)
}
var servers []string
if !(len(a.Servers) == 0 || (len(a.Servers) == 1 && a.Servers[0] == "*")) {
servers = a.Servers
}
return m.banOn(actor, a.SteamID, a.Player, a.Reason, time.Duration(a.Minutes*float64(time.Minute)), servers)
}
// Ban records a ban and applies it to the servers it covers (all if servers is empty). dur 0 is
// permanent. The ban is also enforced later: anyone banned who turns up on a covered server is
// removed, even if that server was offline when the ban was made.
func (m *Manager) Ban(actor, steamID, name, reason string, dur time.Duration, servers []string) error {
_, err := m.banOn(actor, steamID, name, reason, dur, servers)
return err
}
func (m *Manager) banOn(actor, steamID, name, reason string, dur time.Duration, servers []string) ([]Result, error) {
if !ark.ValidSteamID(steamID) {
return nil, errors.New("not a Steam ID")
}
targets, err := m.targets(servers)
if err != nil {
return nil, err
}
if name == "" {
if r, ok := m.players.Get(steamID); ok {
name = r.Name
}
}
b := Ban{SteamID: steamID, Name: ark.Clean(name), Reason: ark.Clean(reason), By: actor, At: time.Now(), Servers: servers}
desc := "permanently"
if dur > 0 {
exp := b.At.Add(dur)
b.Expires = &exp
desc = "for " + ark.Duration(dur)
}
m.bans.Add(b)
who := steamID
if b.Name != "" {
who = b.Name + " (" + steamID + ")"
}
m.hub.Publish(Event{Type: "ban", Actor: actor, SteamID: steamID, Message: fmt.Sprintf("Banned %s %s%s", who, desc, withReason(b.Reason))})
cmd, _ := ark.ForPlayer("BanPlayer", steamID)
var results []Result
for _, i := range targets {
out, err := i.rc.Exec(cmd)
r := Result{Server: i.cfg.ID, OK: err == nil, Output: strings.TrimSpace(out)}
if err != nil {
r.Error = err.Error()
}
results = append(results, r)
}
return results, nil
}
// Unban lifts a ban everywhere.
func (m *Manager) Unban(actor, steamID string) ([]Result, error) {
if !ark.ValidSteamID(steamID) {
return nil, errors.New("not a Steam ID")
}
b, _ := m.bans.Remove(steamID)
who := steamID
if b.Name != "" {
who = b.Name + " (" + steamID + ")"
}
m.hub.Publish(Event{Type: "unban", Actor: actor, SteamID: steamID, Message: "Unbanned " + who})
cmd, _ := ark.ForPlayer("UnbanPlayer", steamID)
targets, _ := m.targets(nil)
var results []Result
for _, i := range targets {
out, err := i.rc.Exec(cmd)
r := Result{Server: i.cfg.ID, OK: err == nil, Output: strings.TrimSpace(out)}
if err != nil {
r.Error = err.Error()
}
results = append(results, r)
}
return results, nil
}
// RunMacro sends each command of a macro to the server and gathers the replies.
func (m *Manager) RunMacro(actor, server string, mc config.Macro) ([]Result, error) {
i, ok := m.Instance(server)
if !ok {
return nil, fmt.Errorf("unknown server %q", server)
}
m.Audit(actor, server, "ran macro "+mc.Name)
var out []Result
for _, c := range mc.Commands {
reply, err := i.Exec(c)
r := Result{Server: server, OK: err == nil, Output: strings.TrimSpace(reply)}
if err != nil {
r.Error = err.Error()
}
out = append(out, r)
if err != nil {
break
}
}
return out, nil
}
// ---- background work ----
// housekeeping flushes the player list and lifts expired bans.
func (m *Manager) housekeeping(ctx context.Context) {
t := time.NewTicker(30 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
m.players.Flush()
for _, b := range m.bans.Expired(time.Now()) {
m.Unban("megalodon", b.SteamID)
}
}
}
}
// scheduler runs the configured jobs.
func (m *Manager) scheduler(ctx context.Context) {
t := time.NewTicker(15 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-t.C:
for _, j := range m.cfg.Get().Jobs {
if j.Enabled && m.due(j, now) {
go m.RunJob(j, "job: "+j.Name)
}
}
}
}
}
var dayNames = [...]string{"sun", "mon", "tue", "wed", "thu", "fri", "sat"}
func (m *Manager) due(j config.Job, now time.Time) bool {
m.jobMu.Lock()
defer m.jobMu.Unlock()
if j.Every != "" {
d, err := time.ParseDuration(j.Every)
if err != nil {
return false
}
last, ok := m.lastRun[j.ID]
if !ok { // first sight: count from now rather than firing at once
m.lastRun[j.ID] = now
return false
}
if now.Sub(last) >= d {
m.lastRun[j.ID] = now
return true
}
return false
}
if len(j.Days) > 0 {
ok := false
for _, d := range j.Days {
ok = ok || d == dayNames[now.Weekday()]
}
if !ok {
return false
}
}
minute := now.Format("2006-01-02 15:04")
for _, at := range j.At {
if at == now.Format("15:04") && m.lastFire[j.ID] != minute {
m.lastFire[j.ID] = minute
return true
}
}
return false
}
// RunJob runs a job now.
func (m *Manager) RunJob(j config.Job, actor string) []Result {
a := Action{Name: j.Action, Servers: j.Servers, Message: j.Message, Reason: j.Reason}
switch j.Action {
case "command":
a.Name, a.Command = "exec", j.Command
case "restart":
d := 5 * time.Minute
if j.Countdown != "" {
d, _ = time.ParseDuration(j.Countdown)
}
a.Minutes = d.Minutes()
}
results, err := m.Do(actor, a)
if err != nil {
m.hub.Publish(Event{Type: "error", Actor: actor, Message: "Job " + j.Name + " failed: " + err.Error()})
return nil
}
failed := 0
for _, r := range results {
if !r.OK {
failed++
}
}
msg := fmt.Sprintf("Ran job %q on %d server(s)", j.Name, len(results))
typ := "schedule"
if failed > 0 {
msg += fmt.Sprintf(", %d failed", failed)
typ = "error"
}
m.hub.Publish(Event{Type: typ, Actor: actor, Message: msg})
return results
}
// Metrics renders the current state in Prometheus text format.
func (m *Manager) Metrics() string {
var sb strings.Builder
views := m.Views()
sort.Slice(views, func(a, b int) bool { return views[a].ID < views[b].ID })
sb.WriteString("# TYPE megalodon_server_up gauge\n# TYPE megalodon_players gauge\n# TYPE megalodon_players_max gauge\n# TYPE megalodon_rcon_rtt_ms gauge\n# TYPE megalodon_uptime_seconds gauge\n# TYPE megalodon_restart_pending gauge\n")
for _, v := range views {
l := fmt.Sprintf(`{server=%q}`, v.ID)
up := 0
if v.Online {
up = 1
}
fmt.Fprintf(&sb, "megalodon_server_up%s %d\nmegalodon_players%s %d\nmegalodon_rcon_rtt_ms%s %d\n", l, up, l, len(v.Players), l, v.RTTMS)
if v.Info != nil {
fmt.Fprintf(&sb, "megalodon_players_max%s %d\n", l, v.Info.MaxPlayers)
}
if v.Since != nil {
fmt.Fprintf(&sb, "megalodon_uptime_seconds%s %d\n", l, int(time.Since(*v.Since).Seconds()))
}
pending := 0
if v.Restart != nil {
pending = 1
}
fmt.Fprintf(&sb, "megalodon_restart_pending%s %d\n", l, pending)
}
return sb.String()
}
+285
View File
@@ -0,0 +1,285 @@
package manager
import (
"context"
"encoding/binary"
"fmt"
"io"
"net"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"git.veillax.com/Veillax/megalodon/internal/config"
)
// fakeArk is a minimal ARK RCON server whose player list the test controls.
type fakeArk struct {
ln net.Listener
mu sync.Mutex
players []string // "Name, steamid" lines
got []string
}
func startFake(t *testing.T) *fakeArk {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
f := &fakeArk{ln: ln}
t.Cleanup(func() { ln.Close() })
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go f.handle(c)
}
}()
return f
}
func rawPacket(w io.Writer, id, typ int32, body string) {
buf := make([]byte, 12+len(body)+2)
binary.LittleEndian.PutUint32(buf[0:], uint32(10+len(body)))
binary.LittleEndian.PutUint32(buf[4:], uint32(id))
binary.LittleEndian.PutUint32(buf[8:], uint32(typ))
copy(buf[12:], body)
w.Write(buf)
}
func rawRead(r io.Reader) (id, typ int32, body string, err error) {
var h [4]byte
if _, err = io.ReadFull(r, h[:]); err != nil {
return
}
buf := make([]byte, binary.LittleEndian.Uint32(h[:]))
if _, err = io.ReadFull(r, buf); err != nil {
return
}
return int32(binary.LittleEndian.Uint32(buf)), int32(binary.LittleEndian.Uint32(buf[4:])), string(buf[8 : len(buf)-2]), nil
}
func (f *fakeArk) handle(c net.Conn) {
defer c.Close()
id, _, _, err := rawRead(c)
if err != nil {
return
}
rawPacket(c, id, 0, "")
rawPacket(c, id, 2, "")
for {
id, _, cmd, err := rawRead(c)
if err != nil {
return
}
f.mu.Lock()
f.got = append(f.got, cmd)
reply := "ok"
switch cmd {
case "ListPlayers":
reply = "No Players Connected"
if len(f.players) > 0 {
var lines []string
for n, p := range f.players {
lines = append(lines, fmt.Sprintf("%d. %s", n, p))
}
reply = strings.Join(lines, "\n") + "\n"
}
case "GetChat":
reply = "Server received, But no response!!"
}
f.mu.Unlock()
rawPacket(c, id, 0, reply)
}
}
func (f *fakeArk) setPlayers(p ...string) { f.mu.Lock(); f.players = p; f.mu.Unlock() }
func (f *fakeArk) received(cmd string) int {
f.mu.Lock()
defer f.mu.Unlock()
n := 0
for _, c := range f.got {
if c == cmd {
n++
}
}
return n
}
func (f *fakeArk) port() int { return f.ln.Addr().(*net.TCPAddr).Port }
func newTestManager(t *testing.T, f *fakeArk) *Manager {
t.Helper()
h, err := config.Load(filepath.Join(t.TempDir(), "megalodon.json"))
if err != nil {
t.Fatal(err)
}
err = h.Update(func(c *config.Config) error {
c.DataDir = t.TempDir()
c.PollSeconds = 1
c.Servers = []config.Server{{ID: "island", Name: "The Island", Host: "127.0.0.1", RCONPort: f.port(), Password: "pw"}}
return nil
})
if err != nil {
t.Fatal(err)
}
m, err := New(h)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
m.Start(ctx)
t.Cleanup(func() { cancel(); m.Wait() })
return m
}
func waitFor(t *testing.T, what string, ok func() bool) {
t.Helper()
deadline := time.Now().Add(8 * time.Second)
for time.Now().Before(deadline) {
if ok() {
return
}
time.Sleep(50 * time.Millisecond)
}
t.Fatalf("timed out waiting for %s", what)
}
func hasEvent(m *Manager, typ, contains string) bool {
for _, e := range m.hub.Recent(Filter{Types: []string{typ}}) {
if strings.Contains(e.Message, contains) {
return true
}
}
return false
}
const (
alex = "76561198000000001"
bo = "76561198000000002"
)
func TestJoinLeaveAndBanEnforcement(t *testing.T) {
f := startFake(t)
f.setPlayers("Alex, " + alex)
m := newTestManager(t, f)
waitFor(t, "server online", func() bool { return m.Views()[0].Online })
if hasEvent(m, "join", "Alex") {
t.Fatal("someone already online at startup should not raise a join event")
}
f.setPlayers("Alex, "+alex, "Bo, "+bo)
waitFor(t, "join event", func() bool { return hasEvent(m, "join", "Bo joined") })
if v := m.Views()[0]; len(v.Players) != 2 {
t.Fatalf("expected two players, got %+v", v.Players)
}
f.setPlayers("Bo, " + bo)
waitFor(t, "leave event", func() bool { return hasEvent(m, "leave", "Alex left") })
// A ban is sent to the server, and enforced again when the player shows up.
if _, err := m.Do("tester", Action{Name: "ban", SteamID: alex, Reason: "griefing", Minutes: 60}); err != nil {
t.Fatal(err)
}
if f.received("BanPlayer "+alex) != 1 {
t.Fatal("the ban was not sent")
}
f.setPlayers("Alex, "+alex, "Bo, "+bo)
waitFor(t, "ban enforcement", func() bool { return f.received("BanPlayer "+alex) >= 2 })
waitFor(t, "kick event", func() bool { return hasEvent(m, "kick", "Removed banned player Alex") })
if _, err := m.Do("tester", Action{Name: "unban", SteamID: alex}); err != nil {
t.Fatal(err)
}
if f.received("UnbanPlayer "+alex) != 1 || len(m.Bans()) != 0 {
t.Fatal("the unban did not go through")
}
}
func TestActionsBuildSafeCommands(t *testing.T) {
f := startFake(t)
m := newTestManager(t, f)
waitFor(t, "server online", func() bool { return m.Views()[0].Online })
res, err := m.Do("tester", Action{Name: "broadcast", Message: "hello\nDoExit"})
if err != nil || len(res) != 1 || !res[0].OK {
t.Fatalf("broadcast: %+v, %v", res, err)
}
if f.received("Broadcast hello DoExit") != 1 || f.received("DoExit") != 0 {
t.Fatalf("commands received: %v", f.got)
}
res, _ = m.Do("tester", Action{Name: "kick", SteamID: "1; DoExit"})
if res[0].OK || f.received("DoExit") != 0 {
t.Fatal("an invalid Steam ID got through")
}
if _, err := m.Do("tester", Action{Name: "save", Servers: []string{"nope"}}); err == nil {
t.Fatal("unknown server accepted")
}
if !hasEvent(m, "audit", "broadcast") {
t.Fatal("the action was not audited")
}
}
func TestRestartCountdownCanBeCancelled(t *testing.T) {
f := startFake(t)
m := newTestManager(t, f)
waitFor(t, "server online", func() bool { return m.Views()[0].Online })
if _, err := m.Do("tester", Action{Name: "restart", Minutes: 10, Reason: "updates"}); err != nil {
t.Fatal(err)
}
if m.Views()[0].Restart == nil {
t.Fatal("no restart shown")
}
waitFor(t, "first warning", func() bool { return f.received("Broadcast Server restarting in 10 minutes. updates") == 1 })
if res, _ := m.Do("tester", Action{Name: "restart", Minutes: 5}); res[0].OK {
t.Fatal("a second restart was accepted")
}
m.Do("tester", Action{Name: "cancel_restart"})
if m.Views()[0].Restart != nil {
t.Fatal("restart still shown after cancelling")
}
if f.received("DoExit") != 0 {
t.Fatal("the server was stopped despite the cancel")
}
}
func TestSchedule(t *testing.T) {
m := &Manager{lastRun: map[string]time.Time{}, lastFire: map[string]string{}}
now := time.Date(2026, 3, 2, 4, 0, 10, 0, time.Local) // a Monday
every := config.Job{ID: "e", Every: "30m"}
if m.due(every, now) {
t.Fatal("an interval job must not fire the moment it is first seen")
}
if m.due(every, now.Add(29*time.Minute)) || !m.due(every, now.Add(31*time.Minute)) {
t.Fatal("interval job timing is wrong")
}
daily := config.Job{ID: "d", At: []string{"04:00"}, Days: []string{"mon"}}
if !m.due(daily, now) || m.due(daily, now.Add(5*time.Second)) {
t.Fatal("a timed job should fire once in its minute")
}
if m.due(config.Job{ID: "t", At: []string{"04:00"}, Days: []string{"tue"}}, now) {
t.Fatal("job fired on the wrong day")
}
}
func TestNotificationFilter(t *testing.T) {
join := Event{Type: "join", Server: "island"}
chat := Event{Type: "chat", Server: "island"}
if !wants(config.Notify{}, join) || wants(config.Notify{}, chat) {
t.Fatal("default event set is wrong")
}
if !wants(config.Notify{Events: []string{"chat"}}, chat) || wants(config.Notify{Events: []string{"chat"}}, join) {
t.Fatal("explicit event list ignored")
}
if wants(config.Notify{Servers: []string{"other"}}, join) || !wants(config.Notify{Servers: []string{"island"}}, join) {
t.Fatal("server filter is wrong")
}
}
+151
View File
@@ -0,0 +1,151 @@
package manager
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"git.veillax.com/Veillax/megalodon/internal/config"
)
type httpDoer interface {
Do(*http.Request) (*http.Response, error)
}
func defaultClient() httpDoer {
return &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
}
// defaultEvents is what a notification gets when it does not choose.
var defaultEvents = map[string]bool{
"join": true, "leave": true, "online": true, "offline": true, "restart": true, "start": true,
"ban": true, "unban": true, "kick": true, "filter": true, "watch": true, "error": true,
}
// notifier forwards events to the configured webhooks.
func (m *Manager) notifier(ctx context.Context) {
ch, cancel := m.hub.Subscribe()
defer cancel()
for {
select {
case <-ctx.Done():
return
case e := <-ch:
for _, n := range m.cfg.Get().Notify {
if wants(n, e) {
go func() {
if err := m.send(ctx, n, e); err != nil {
// Not published as an event: a broken webhook must not trigger itself.
fmt.Printf("notify %s: %v\n", n.Name, err)
}
}()
}
}
}
}
}
func wants(n config.Notify, e Event) bool {
if len(n.Servers) > 0 && e.Server != "" {
ok := false
for _, s := range n.Servers {
ok = ok || s == e.Server
}
if !ok {
return false
}
}
if len(n.Events) == 0 {
return defaultEvents[e.Type]
}
for _, t := range n.Events {
if t == e.Type || t == "*" {
return true
}
}
return false
}
// TestNotify sends a sample message through the named notification.
func (m *Manager) TestNotify(name string) error {
for _, n := range m.cfg.Get().Notify {
if n.Name == name {
return m.send(m.ctx, n, Event{Time: time.Now(), Type: "audit", Message: "This is a test message from Megalodon."})
}
}
return errors.New("no such notification")
}
func (m *Manager) send(ctx context.Context, n config.Notify, e Event) error {
server := e.Server
for _, s := range m.cfg.Get().Servers {
if s.ID == e.Server {
server = s.Name
}
}
text := e.Message
if e.Type == "chat" {
text = e.Actor + ": " + e.Message
}
var body []byte
ct := "application/json"
hdr := map[string]string{}
switch n.Kind {
case "discord":
prefix := ""
if server != "" {
prefix = "**" + server + "** · "
}
body, _ = json.Marshal(map[string]any{"username": "Megalodon", "content": truncate(prefix+text, 1900), "allowed_mentions": map[string]any{"parse": []string{}}})
case "ntfy":
body, ct = []byte(text), "text/plain"
if server != "" {
hdr["Title"] = server
}
hdr["Tags"] = e.Type
default:
body, _ = json.Marshal(e)
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "POST", n.URL, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", ct)
req.Header.Set("User-Agent", "megalodon")
for k, v := range hdr {
req.Header.Set(k, v)
}
resp, err := m.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))
if resp.StatusCode >= 300 {
return fmt.Errorf("%s answered %s", hostOf(n.URL), resp.Status)
}
return nil
}
func hostOf(u string) string {
u = strings.TrimPrefix(strings.TrimPrefix(u, "https://"), "http://")
if i := strings.IndexAny(u, "/?"); i >= 0 {
u = u[:i]
}
return u
}
func truncate(s string, n int) string {
if r := []rune(s); len(r) > n {
return string(r[:n])
}
return s
}
+242
View File
@@ -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
}