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>
501 lines
12 KiB
Go
501 lines
12 KiB
Go
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})
|
|
}
|