Files
Megalodon/internal/manager/instance.go
T
VeillaxandClaude Sonnet 5.5 0d2cb31abf
CI / test (push) Successful in 2m26s
Release / release (push) Successful in 2m36s
Megalodon: manage ARK servers over RCON from a web dashboard
Watches any number of ARK:SE servers (RCON for players and chat, the query
port for map, version and player cap), records joins, leaves, chat and an
audit trail, and keeps a player database across servers.

Control: save, broadcast, chat, whisper, kick, time of day, MOTD, wild dino
wipe, a raw console with history and macros, restarts with timed warnings and
a world save, optional start command and auto-start. Bans are kept by
Megalodon: timed, cluster-wide and re-enforced when the player returns.

Automation: schedules (every N or at times of day), Discord/ntfy/webhook
notifications, welcome message, chat word filter.

Access: viewer, moderator and admin roles, API tokens, Prometheus metrics.
cmd/fakeark simulates a server for trying it out.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 23:36:13 -06:00

736 lines
19 KiB
Go

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
}