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

+223
View File
@@ -0,0 +1,223 @@
package web
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"git.veillax.com/Veillax/megalodon/internal/config"
"git.veillax.com/Veillax/megalodon/internal/manager"
)
type env struct {
t *testing.T
ts *httptest.Server
cfg *config.Holder
tokens map[config.Role]string
}
func setup(t *testing.T) *env {
t.Helper()
h, err := config.Load(filepath.Join(t.TempDir(), "megalodon.json"))
if err != nil {
t.Fatal(err)
}
e := &env{t: t, cfg: h, tokens: map[config.Role]string{}}
hash, _ := config.HashPassword("hunter2hunter2")
err = h.Update(func(c *config.Config) error {
c.DataDir = t.TempDir()
c.Users = []config.User{{Name: "root", Role: config.Admin, Hash: hash}, {Name: "mod", Role: config.Moderator, Hash: hash}}
for _, r := range []config.Role{config.Viewer, config.Moderator, config.Admin} {
tok, th, _ := config.NewToken()
e.tokens[r] = tok
c.Tokens = append(c.Tokens, config.Token{Name: string(r), Role: r, Hash: th})
}
return nil
})
if err != nil {
t.Fatal(err)
}
m, err := manager.New(h)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
m.Start(ctx)
e.ts = httptest.NewServer(New(h, m, "test").Handler())
t.Cleanup(func() { e.ts.Close(); cancel(); m.Wait() })
return e
}
func (e *env) do(method, path, token string, body any, hdr ...string) *http.Response {
e.t.Helper()
var rd *bytes.Reader
if body != nil {
b, _ := json.Marshal(body)
rd = bytes.NewReader(b)
} else {
rd = bytes.NewReader(nil)
}
req, _ := http.NewRequest(method, e.ts.URL+path, rd)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
for i := 0; i+1 < len(hdr); i += 2 {
req.Header.Set(hdr[i], hdr[i+1])
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
e.t.Fatal(err)
}
return resp
}
func status(t *testing.T, resp *http.Response, want int) {
t.Helper()
defer resp.Body.Close()
if resp.StatusCode != want {
t.Fatalf("%s %s: got %d, want %d", resp.Request.Method, resp.Request.URL.Path, resp.StatusCode, want)
}
}
func TestRolesAreEnforced(t *testing.T) {
e := setup(t)
status(t, e.do("GET", "/api/servers", "", nil), 401)
status(t, e.do("GET", "/api/servers", "mgl_bogus", nil), 401)
status(t, e.do("GET", "/api/servers", e.tokens[config.Viewer], nil), 200)
status(t, e.do("GET", "/api/bans", e.tokens[config.Viewer], nil), 403)
status(t, e.do("GET", "/api/bans", e.tokens[config.Moderator], nil), 200)
status(t, e.do("GET", "/api/settings", e.tokens[config.Moderator], nil), 403)
status(t, e.do("GET", "/api/settings", e.tokens[config.Admin], nil), 200)
// The raw console is admin only; a moderator may kick but the check comes before any server is looked up.
status(t, e.do("POST", "/api/actions", e.tokens[config.Moderator], map[string]any{"action": "exec", "command": "DoExit"}), 403)
status(t, e.do("POST", "/api/actions", e.tokens[config.Viewer], map[string]any{"action": "broadcast", "message": "hi"}), 403)
status(t, e.do("POST", "/api/actions", e.tokens[config.Moderator], map[string]any{"action": "broadcast", "message": "hi"}), 400) // no servers configured
}
func TestLoginSessionAndCSRF(t *testing.T) {
e := setup(t)
login := func(pw string) *http.Response {
return e.do("POST", "/api/login", "", map[string]string{"username": "root", "password": pw}, "X-Megalodon", "1")
}
status(t, login("wrong"), 401)
status(t, e.do("POST", "/api/login", "", map[string]string{"username": "root", "password": "hunter2hunter2"}), 403) // no CSRF header
resp := login("hunter2hunter2")
var cookie *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == cookieName {
cookie = c
}
}
status(t, resp, 200)
if cookie == nil || !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode {
t.Fatalf("session cookie is not locked down: %+v", cookie)
}
withCookie := func(method, path string, hdr ...string) *http.Response {
req, _ := http.NewRequest(method, e.ts.URL+path, strings.NewReader("{}"))
req.AddCookie(cookie)
for i := 0; i+1 < len(hdr); i += 2 {
req.Header.Set(hdr[i], hdr[i+1])
}
r, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
return r
}
status(t, withCookie("GET", "/api/me"), 200)
status(t, withCookie("POST", "/api/actions"), 403) // cookie without the header: a cross-site form post
status(t, withCookie("POST", "/api/actions", "X-Megalodon", "1"), 400) // allowed through, fails only for lack of servers
status(t, withCookie("POST", "/api/logout", "X-Megalodon", "1"), 200)
status(t, withCookie("GET", "/api/me"), 401)
}
func TestLoginIsRateLimited(t *testing.T) {
e := setup(t)
var last int
for i := 0; i < maxLoginFails+2; i++ {
resp := e.do("POST", "/api/login", "", map[string]string{"username": "root", "password": "nope"}, "X-Megalodon", "1")
last = resp.StatusCode
resp.Body.Close()
}
if last != http.StatusTooManyRequests {
t.Fatalf("got %d after repeated failures", last)
}
// Even the right password is refused during the lockout.
status(t, e.do("POST", "/api/login", "", map[string]string{"username": "root", "password": "hunter2hunter2"}, "X-Megalodon", "1"), 429)
}
func TestSettingsAreValidated(t *testing.T) {
e := setup(t)
admin := e.tokens[config.Admin]
bad := map[string]any{"jobs": []map[string]any{{"name": "x", "enabled": true, "action": "save", "every": "5s"}}}
status(t, e.do("PUT", "/api/settings", admin, bad), 400)
good := map[string]any{
"jobs": []map[string]any{{"name": "Hourly save", "enabled": true, "action": "save", "every": "1h"}},
"macros": []map[string]any{{"name": "Day", "commands": []string{"SetTimeOfDay 12:00"}, "role": "moderator"}},
"chat_filter": map[string]any{"action": "warn"},
}
status(t, e.do("PUT", "/api/settings", admin, good), 200)
resp := e.do("GET", "/api/settings", admin, nil)
var got settings
json.NewDecoder(resp.Body).Decode(&got)
resp.Body.Close()
if len(got.Jobs) != 1 || got.Jobs[0].ID == "" {
t.Fatalf("job was not saved with an id: %+v", got.Jobs)
}
// Moderators see the macros they may run, admins' secrets stay out of /api/me.
resp = e.do("GET", "/api/me", e.tokens[config.Moderator], nil)
var me struct{ Macros []struct{ Name string } }
json.NewDecoder(resp.Body).Decode(&me)
resp.Body.Close()
if len(me.Macros) != 1 || me.Macros[0].Name != "Day" {
t.Fatalf("got %+v", me.Macros)
}
}
func TestUsersCannotLockEveryoneOut(t *testing.T) {
e := setup(t)
admin := e.tokens[config.Admin]
status(t, e.do("DELETE", "/api/users/root", admin, nil), 400) // the only admin
status(t, e.do("POST", "/api/users", admin, map[string]string{"name": "root", "role": "viewer"}), 400)
status(t, e.do("POST", "/api/users", admin, map[string]string{"name": "new", "role": "viewer", "password": "short"}), 400)
status(t, e.do("POST", "/api/users", admin, map[string]string{"name": "new", "role": "viewer", "password": "long enough pw"}), 200)
status(t, e.do("DELETE", "/api/users/new", admin, nil), 200)
}
func TestViewersDoNotSeeAuditEvents(t *testing.T) {
e := setup(t)
status(t, e.do("POST", "/api/users", e.tokens[config.Admin], map[string]string{"name": "n", "role": "viewer", "password": "long enough pw"}), 200)
count := func(token string) int {
resp := e.do("GET", "/api/events?type=audit", token, nil)
defer resp.Body.Close()
var evs []manager.Event
json.NewDecoder(resp.Body).Decode(&evs)
return len(evs)
}
if count(e.tokens[config.Moderator]) == 0 {
t.Fatal("moderators should see audit events")
}
if count(e.tokens[config.Viewer]) != 0 {
t.Fatal("viewers must not see audit events")
}
}
func TestSecurityHeadersAndStaticPage(t *testing.T) {
e := setup(t)
resp := e.do("GET", "/", "", nil)
defer resp.Body.Close()
if resp.StatusCode != 200 || !strings.Contains(resp.Header.Get("Content-Security-Policy"), "default-src 'none'") || resp.Header.Get("X-Frame-Options") != "DENY" {
t.Fatalf("status %d, headers %v", resp.StatusCode, resp.Header)
}
status(t, e.do("GET", "/healthz", "", nil), 200)
}