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,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
|
||||
}
|
||||
Reference in new issue
Block a user