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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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