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

Watches any number of ARK:SE servers (RCON for players and chat, the query
port for map, version and player cap), records joins, leaves, chat and an
audit trail, and keeps a player database across servers.

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

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

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-30 23:36:13 -06:00
commit 0d2cb31abf
32 files changed
+6280

No files matched your search

+500
View File
@@ -0,0 +1,500 @@
package web
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"git.veillax.com/Veillax/megalodon/internal/ark"
"git.veillax.com/Veillax/megalodon/internal/config"
"git.veillax.com/Veillax/megalodon/internal/manager"
)
func (s *Server) me(w http.ResponseWriter, r *http.Request, p principal) {
type macro struct {
Name string `json:"name"`
}
var macros []macro
for _, m := range s.cfg.Get().Macros {
if p.Role.Allows(m.Role) {
macros = append(macros, macro{m.Name})
}
}
writeJSON(w, map[string]any{"name": p.Name, "role": p.Role, "version": s.version, "macros": macros, "reference": ark.Reference})
}
// ---- servers ----
func (s *Server) listServers(w http.ResponseWriter, r *http.Request, p principal) {
writeJSON(w, s.m.Views())
}
var nonID = regexp.MustCompile(`[^a-z0-9]+`)
func (s *Server) addServer(w http.ResponseWriter, r *http.Request, p principal) {
var in config.Server
if !decode(w, r, &in) {
return
}
if in.ID == "" {
in.ID = strings.Trim(nonID.ReplaceAllString(strings.ToLower(in.Name), "-"), "-")
}
err := s.cfg.Update(func(c *config.Config) error {
c.Servers = append(c.Servers, in)
return nil
})
if err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
s.m.Reload()
s.m.Audit(p.Name, in.ID, "added server "+in.Name)
writeJSON(w, map[string]string{"id": in.ID})
}
func (s *Server) editServer(w http.ResponseWriter, r *http.Request, p principal) {
id := r.PathValue("id")
var in config.Server
if !decode(w, r, &in) {
return
}
in.ID = id
found := false
err := s.cfg.Update(func(c *config.Config) error {
for k := range c.Servers {
if c.Servers[k].ID == id {
if in.Password == "" { // blank means keep the current one
in.Password = c.Servers[k].Password
}
c.Servers[k], found = in, true
}
}
return nil
})
switch {
case err != nil:
fail(w, http.StatusBadRequest, err.Error())
case !found:
fail(w, http.StatusNotFound, "no such server")
default:
s.m.Reload()
s.m.Audit(p.Name, id, "edited server settings")
writeJSON(w, map[string]bool{"ok": true})
}
}
func (s *Server) deleteServer(w http.ResponseWriter, r *http.Request, p principal) {
id := r.PathValue("id")
err := s.cfg.Update(func(c *config.Config) error {
var keep []config.Server
for _, sv := range c.Servers {
if sv.ID != id {
keep = append(keep, sv)
}
}
c.Servers = keep
for k := range c.Jobs { // a job aimed only at this server would now hit every server
var left []string
for _, sid := range c.Jobs[k].Servers {
if sid != id {
left = append(left, sid)
}
}
if len(c.Jobs[k].Servers) > 0 && len(left) == 0 {
c.Jobs[k].Enabled = false
}
c.Jobs[k].Servers = left
}
return nil
})
if err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
s.m.Reload()
s.m.Audit(p.Name, "", "removed server "+id)
writeJSON(w, map[string]bool{"ok": true})
}
func (s *Server) samples(w http.ResponseWriter, r *http.Request, p principal) {
i, ok := s.m.Instance(r.PathValue("id"))
if !ok {
fail(w, http.StatusNotFound, "no such server")
return
}
writeJSON(w, i.Samples())
}
// ---- actions ----
func (s *Server) serverAction(w http.ResponseWriter, r *http.Request, p principal) {
var a manager.Action
if !decode(w, r, &a) {
return
}
a.Servers = []string{r.PathValue("id")}
s.runAction(w, p, a)
}
func (s *Server) clusterAction(w http.ResponseWriter, r *http.Request, p principal) {
var a manager.Action
if !decode(w, r, &a) {
return
}
s.runAction(w, p, a)
}
func (s *Server) runAction(w http.ResponseWriter, p principal, a manager.Action) {
if !p.Role.Allows(manager.ActionRole(a.Name)) {
fail(w, http.StatusForbidden, "your role cannot do this")
return
}
if a.Minutes < 0 || a.Minutes > 24*60*365 {
fail(w, http.StatusBadRequest, "minutes out of range")
return
}
results, err := s.m.Do(p.Name, a)
if err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, results)
}
func (s *Server) runMacro(w http.ResponseWriter, r *http.Request, p principal) {
var in struct{ Name string }
if !decode(w, r, &in) {
return
}
for _, m := range s.cfg.Get().Macros {
if m.Name == in.Name {
if !p.Role.Allows(m.Role) {
fail(w, http.StatusForbidden, "your role cannot run this macro")
return
}
res, err := s.m.RunMacro(p.Name, r.PathValue("id"), m)
if err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, res)
return
}
}
fail(w, http.StatusNotFound, "no such macro")
}
func (s *Server) runJob(w http.ResponseWriter, r *http.Request, p principal) {
for _, j := range s.cfg.Get().Jobs {
if j.ID == r.PathValue("id") {
writeJSON(w, s.m.RunJob(j, p.Name+" (run now)"))
return
}
}
fail(w, http.StatusNotFound, "no such job")
}
func (s *Server) testNotify(w http.ResponseWriter, r *http.Request, p principal) {
var in struct{ Name string }
if !decode(w, r, &in) {
return
}
if err := s.m.TestNotify(in.Name); err != nil {
fail(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, map[string]bool{"ok": true})
}
// ---- events, players, bans ----
func (s *Server) events(w http.ResponseWriter, r *http.Request, p principal) {
q := r.URL.Query()
f := manager.Filter{Server: q.Get("server"), Query: q.Get("q")}
if t := q.Get("type"); t != "" {
f.Types = strings.Split(t, ",")
}
f.Before, _ = strconv.ParseInt(q.Get("before"), 10, 64)
f.Limit, _ = strconv.Atoi(q.Get("limit"))
// Console audit lines can hold commands with sensitive values; only moderators and up see them.
events := s.m.Hub().Recent(f)
if !p.Role.Allows(config.Moderator) {
kept := events[:0]
for _, e := range events {
if e.Type != "audit" && e.Type != "error" {
kept = append(kept, e)
}
}
events = kept
}
writeJSON(w, events)
}
func (s *Server) stream(w http.ResponseWriter, r *http.Request, p principal) {
fl, ok := w.(http.Flusher)
if !ok {
fail(w, http.StatusInternalServerError, "streaming not supported")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("X-Accel-Buffering", "no")
ch, cancel := s.m.Hub().Subscribe()
defer cancel()
fmt.Fprint(w, ": connected\n\n")
fl.Flush()
ping := time.NewTicker(20 * time.Second)
defer ping.Stop()
for {
select {
case <-r.Context().Done():
return
case <-ping.C:
fmt.Fprint(w, ": ping\n\n")
fl.Flush()
case e := <-ch:
if !p.Role.Allows(config.Moderator) && (e.Type == "audit" || e.Type == "error") {
continue
}
b, _ := jsonBytes(e)
fmt.Fprintf(w, "data: %s\n\n", b)
fl.Flush()
}
}
}
func (s *Server) players(w http.ResponseWriter, r *http.Request, p principal) {
recs := s.m.Players()
if !p.Role.Allows(config.Moderator) {
for k := range recs {
recs[k].Notes = "" // moderator notes stay between moderators
}
}
writeJSON(w, recs)
}
func (s *Server) annotate(w http.ResponseWriter, r *http.Request, p principal) {
var in struct {
Notes string `json:"notes"`
Watch bool `json:"watch"`
}
if !decode(w, r, &in) {
return
}
if err := s.m.Annotate(r.PathValue("id"), in.Notes, in.Watch); err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, map[string]bool{"ok": true})
}
func (s *Server) bans(w http.ResponseWriter, r *http.Request, p principal) {
writeJSON(w, s.m.Bans())
}
// ---- settings ----
type settings struct {
Jobs []config.Job `json:"jobs"`
Macros []config.Macro `json:"macros"`
Notify []config.Notify `json:"notify"`
Welcome config.Welcome `json:"welcome"`
ChatFilter config.ChatFilter `json:"chat_filter"`
}
func (s *Server) getSettings(w http.ResponseWriter, r *http.Request, p principal) {
c := s.cfg.Get()
writeJSON(w, settings{c.Jobs, c.Macros, c.Notify, c.Welcome, c.ChatFilter})
}
func (s *Server) putSettings(w http.ResponseWriter, r *http.Request, p principal) {
var in settings
if !decode(w, r, &in) {
return
}
for k := range in.Jobs {
if in.Jobs[k].ID == "" {
b := make([]byte, 4)
rand.Read(b)
in.Jobs[k].ID = hex.EncodeToString(b)
}
}
err := s.cfg.Update(func(c *config.Config) error {
c.Jobs, c.Macros, c.Notify, c.Welcome, c.ChatFilter = in.Jobs, in.Macros, in.Notify, in.Welcome, in.ChatFilter
return nil
})
if err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
s.m.Audit(p.Name, "", "changed schedules, macros, notifications or filters")
c := s.cfg.Get()
writeJSON(w, settings{c.Jobs, c.Macros, c.Notify, c.Welcome, c.ChatFilter})
}
// ---- users and tokens ----
func (s *Server) listUsers(w http.ResponseWriter, r *http.Request, p principal) {
type user struct {
Name string `json:"name"`
Role config.Role `json:"role"`
}
out := []user{}
for _, u := range s.cfg.Get().Users {
out = append(out, user{u.Name, u.Role})
}
writeJSON(w, out)
}
func (s *Server) saveUser(w http.ResponseWriter, r *http.Request, p principal) {
var in struct {
Name string `json:"name"`
Role config.Role `json:"role"`
Password string `json:"password"`
}
if !decode(w, r, &in) {
return
}
in.Name = strings.TrimSpace(in.Name)
err := s.cfg.Update(func(c *config.Config) error {
for k := range c.Users {
if c.Users[k].Name == in.Name {
if c.Users[k].Role == config.Admin && in.Role != config.Admin && countAdmins(c) == 1 {
return fmt.Errorf("there must be at least one admin")
}
c.Users[k].Role = in.Role
if in.Password != "" {
h, err := newHash(in.Password)
if err != nil {
return err
}
c.Users[k].Hash = h
}
return nil
}
}
h, err := newHash(in.Password)
if err != nil {
return err
}
c.Users = append(c.Users, config.User{Name: in.Name, Role: in.Role, Hash: h})
return nil
})
if err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
s.m.Audit(p.Name, "", "saved user "+in.Name+" ("+string(in.Role)+")")
writeJSON(w, map[string]bool{"ok": true})
}
func newHash(password string) (string, error) {
if len(password) < 8 {
return "", fmt.Errorf("the password must be at least 8 characters")
}
return config.HashPassword(password)
}
func countAdmins(c *config.Config) int {
n := 0
for _, u := range c.Users {
if u.Role == config.Admin {
n++
}
}
return n
}
func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request, p principal) {
name := r.PathValue("name")
err := s.cfg.Update(func(c *config.Config) error {
var keep []config.User
for _, u := range c.Users {
if u.Name != name {
keep = append(keep, u)
}
}
c.Users = keep
if countAdmins(c) == 0 {
return fmt.Errorf("there must be at least one admin")
}
return nil
})
if err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
s.mu.Lock()
for k, sess := range s.sessions {
if sess.user == name {
delete(s.sessions, k)
}
}
s.mu.Unlock()
s.m.Audit(p.Name, "", "deleted user "+name)
writeJSON(w, map[string]bool{"ok": true})
}
func (s *Server) listTokens(w http.ResponseWriter, r *http.Request, p principal) {
type token struct {
Name string `json:"name"`
Role config.Role `json:"role"`
}
out := []token{}
for _, t := range s.cfg.Get().Tokens {
out = append(out, token{t.Name, t.Role})
}
writeJSON(w, out)
}
func (s *Server) newToken(w http.ResponseWriter, r *http.Request, p principal) {
var in struct {
Name string `json:"name"`
Role config.Role `json:"role"`
}
if !decode(w, r, &in) {
return
}
token, hash, err := config.NewToken()
if err == nil {
err = s.cfg.Update(func(c *config.Config) error {
for _, t := range c.Tokens {
if t.Name == in.Name {
return fmt.Errorf("a token named %q already exists", in.Name)
}
}
c.Tokens = append(c.Tokens, config.Token{Name: in.Name, Role: in.Role, Hash: hash})
return nil
})
}
if err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
s.m.Audit(p.Name, "", "created API token "+in.Name+" ("+string(in.Role)+")")
writeJSON(w, map[string]string{"token": token}) // the only time it is shown
}
func (s *Server) deleteToken(w http.ResponseWriter, r *http.Request, p principal) {
name := r.PathValue("name")
err := s.cfg.Update(func(c *config.Config) error {
var keep []config.Token
for _, t := range c.Tokens {
if t.Name != name {
keep = append(keep, t)
}
}
c.Tokens = keep
return nil
})
if err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
s.m.Audit(p.Name, "", "deleted API token "+name)
writeJSON(w, map[string]bool{"ok": true})
}