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,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})
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
// Package web serves Megalodon's dashboard and its JSON API.
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/megalodon/internal/config"
|
||||
"git.veillax.com/Veillax/megalodon/internal/manager"
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
var staticFiles embed.FS
|
||||
|
||||
const (
|
||||
cookieName = "megalodon_session"
|
||||
sessionTTL = 12 * time.Hour
|
||||
maxLoginFails = 6
|
||||
loginFailSpan = 5 * time.Minute
|
||||
maxRequestBody = 1 << 20
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
cfg *config.Holder
|
||||
m *manager.Manager
|
||||
version string
|
||||
|
||||
mu sync.Mutex
|
||||
sessions map[string]session
|
||||
fails map[string]failure
|
||||
|
||||
dummyOnce sync.Once
|
||||
dummyHash string
|
||||
}
|
||||
|
||||
type session struct {
|
||||
user string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
type failure struct {
|
||||
n int
|
||||
until time.Time
|
||||
}
|
||||
|
||||
// principal is who is making a request.
|
||||
type principal struct {
|
||||
Name string
|
||||
Role config.Role
|
||||
API bool // came with an API token rather than a browser session
|
||||
}
|
||||
|
||||
func New(h *config.Holder, m *manager.Manager, version string) *Server {
|
||||
return &Server{cfg: h, m: m, version: version, sessions: map[string]session{}, fails: map[string]failure{}}
|
||||
}
|
||||
|
||||
// Handler returns the whole site.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "ok") })
|
||||
|
||||
mux.HandleFunc("POST /api/login", s.login)
|
||||
mux.HandleFunc("POST /api/logout", s.api(config.Viewer, s.logout))
|
||||
mux.HandleFunc("GET /api/me", s.api(config.Viewer, s.me))
|
||||
|
||||
mux.HandleFunc("GET /api/servers", s.api(config.Viewer, s.listServers))
|
||||
mux.HandleFunc("POST /api/servers", s.api(config.Admin, s.addServer))
|
||||
mux.HandleFunc("PUT /api/servers/{id}", s.api(config.Admin, s.editServer))
|
||||
mux.HandleFunc("DELETE /api/servers/{id}", s.api(config.Admin, s.deleteServer))
|
||||
mux.HandleFunc("GET /api/servers/{id}/samples", s.api(config.Viewer, s.samples))
|
||||
mux.HandleFunc("POST /api/servers/{id}/action", s.api(config.Viewer, s.serverAction))
|
||||
mux.HandleFunc("POST /api/servers/{id}/macro", s.api(config.Viewer, s.runMacro))
|
||||
mux.HandleFunc("POST /api/actions", s.api(config.Viewer, s.clusterAction))
|
||||
|
||||
mux.HandleFunc("GET /api/events", s.api(config.Viewer, s.events))
|
||||
mux.HandleFunc("GET /api/stream", s.api(config.Viewer, s.stream))
|
||||
mux.HandleFunc("GET /api/players", s.api(config.Viewer, s.players))
|
||||
mux.HandleFunc("PUT /api/players/{id}", s.api(config.Moderator, s.annotate))
|
||||
mux.HandleFunc("GET /api/bans", s.api(config.Moderator, s.bans))
|
||||
|
||||
mux.HandleFunc("GET /api/settings", s.api(config.Admin, s.getSettings))
|
||||
mux.HandleFunc("PUT /api/settings", s.api(config.Admin, s.putSettings))
|
||||
mux.HandleFunc("POST /api/jobs/{id}/run", s.api(config.Admin, s.runJob))
|
||||
mux.HandleFunc("POST /api/notify/test", s.api(config.Admin, s.testNotify))
|
||||
mux.HandleFunc("GET /api/users", s.api(config.Admin, s.listUsers))
|
||||
mux.HandleFunc("POST /api/users", s.api(config.Admin, s.saveUser))
|
||||
mux.HandleFunc("DELETE /api/users/{name}", s.api(config.Admin, s.deleteUser))
|
||||
mux.HandleFunc("GET /api/tokens", s.api(config.Admin, s.listTokens))
|
||||
mux.HandleFunc("POST /api/tokens", s.api(config.Admin, s.newToken))
|
||||
mux.HandleFunc("DELETE /api/tokens/{name}", s.api(config.Admin, s.deleteToken))
|
||||
|
||||
mux.HandleFunc("GET /metrics", s.api(config.Viewer, func(w http.ResponseWriter, r *http.Request, _ principal) {
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
|
||||
fmt.Fprint(w, s.m.Metrics())
|
||||
}))
|
||||
|
||||
static, _ := fs.Sub(staticFiles, "static")
|
||||
mux.Handle("GET /", http.FileServerFS(static))
|
||||
return secure(mux)
|
||||
}
|
||||
|
||||
// secure adds headers that keep the dashboard from being framed, sniffed or fed outside scripts.
|
||||
func secure(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
h.Set("Cache-Control", "no-store")
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// ---- authentication ----
|
||||
|
||||
type handlerFunc func(w http.ResponseWriter, r *http.Request, p principal)
|
||||
|
||||
// api wraps a handler with sign-in, a role check and CSRF protection.
|
||||
func (s *Server) api(min config.Role, fn handlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
p, ok := s.authenticate(r)
|
||||
if !ok {
|
||||
fail(w, http.StatusUnauthorized, "sign in required")
|
||||
return
|
||||
}
|
||||
// Browser sessions must send a header a web page on another site cannot add.
|
||||
if !p.API && r.Method != http.MethodGet && r.Header.Get("X-Megalodon") != "1" {
|
||||
fail(w, http.StatusForbidden, "missing X-Megalodon header")
|
||||
return
|
||||
}
|
||||
if !p.Role.Allows(min) {
|
||||
fail(w, http.StatusForbidden, "your role cannot do this")
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
|
||||
fn(w, r, p)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) authenticate(r *http.Request) (principal, bool) {
|
||||
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
||||
sum := config.HashToken(strings.TrimPrefix(h, "Bearer "))
|
||||
for _, t := range s.cfg.Get().Tokens {
|
||||
if subtle.ConstantTimeCompare([]byte(t.Hash), []byte(sum)) == 1 {
|
||||
return principal{Name: "token:" + t.Name, Role: t.Role, API: true}, true
|
||||
}
|
||||
}
|
||||
return principal{}, false
|
||||
}
|
||||
c, err := r.Cookie(cookieName)
|
||||
if err != nil {
|
||||
return principal{}, false
|
||||
}
|
||||
s.mu.Lock()
|
||||
sess, ok := s.sessions[c.Value]
|
||||
if ok && time.Now().After(sess.expires) {
|
||||
delete(s.sessions, c.Value)
|
||||
ok = false
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if !ok {
|
||||
return principal{}, false
|
||||
}
|
||||
// Look the user up again so a deleted user or a changed role takes effect at once.
|
||||
for _, u := range s.cfg.Get().Users {
|
||||
if u.Name == sess.user {
|
||||
return principal{Name: u.Name, Role: u.Role}, true
|
||||
}
|
||||
}
|
||||
return principal{}, false
|
||||
}
|
||||
|
||||
func (s *Server) clientIP(r *http.Request) string {
|
||||
if s.cfg.Get().TrustProxy {
|
||||
if f := r.Header.Get("X-Forwarded-For"); f != "" {
|
||||
return strings.TrimSpace(strings.Split(f, ",")[0])
|
||||
}
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func (s *Server) secureCookie(r *http.Request) bool {
|
||||
c := s.cfg.Get()
|
||||
return c.CookieSecure || r.TLS != nil || (c.TrustProxy && r.Header.Get("X-Forwarded-Proto") == "https")
|
||||
}
|
||||
|
||||
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4096)
|
||||
if r.Header.Get("X-Megalodon") != "1" {
|
||||
fail(w, http.StatusForbidden, "missing X-Megalodon header")
|
||||
return
|
||||
}
|
||||
var in struct{ Username, Password string }
|
||||
if json.NewDecoder(r.Body).Decode(&in) != nil {
|
||||
fail(w, http.StatusBadRequest, "bad request")
|
||||
return
|
||||
}
|
||||
ip := s.clientIP(r)
|
||||
s.mu.Lock()
|
||||
f := s.fails[ip]
|
||||
if f.n >= maxLoginFails && time.Now().Before(f.until) {
|
||||
s.mu.Unlock()
|
||||
fail(w, http.StatusTooManyRequests, "too many failed attempts; wait a few minutes")
|
||||
return
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
var user *config.User
|
||||
for _, u := range s.cfg.Get().Users {
|
||||
if u.Name == in.Username {
|
||||
u := u
|
||||
user = &u
|
||||
}
|
||||
}
|
||||
hash := ""
|
||||
if user != nil {
|
||||
hash = user.Hash
|
||||
} else {
|
||||
// Spend the same time as for a real user, so the answer does not reveal who exists.
|
||||
s.dummyOnce.Do(func() { s.dummyHash, _ = config.HashPassword("dummy") })
|
||||
hash = s.dummyHash
|
||||
}
|
||||
ok := config.CheckPassword(hash, in.Password) && user != nil
|
||||
s.mu.Lock()
|
||||
if !ok {
|
||||
if time.Now().After(f.until) {
|
||||
f = failure{}
|
||||
}
|
||||
f.n++
|
||||
f.until = time.Now().Add(loginFailSpan)
|
||||
s.fails[ip] = f
|
||||
s.mu.Unlock()
|
||||
s.m.Audit("", "", fmt.Sprintf("failed sign-in for %q from %s", truncate(in.Username, 40), ip))
|
||||
fail(w, http.StatusUnauthorized, "wrong username or password")
|
||||
return
|
||||
}
|
||||
delete(s.fails, ip)
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
id := base64.RawURLEncoding.EncodeToString(b)
|
||||
for k, v := range s.sessions { // drop old sessions while we are here
|
||||
if time.Now().After(v.expires) {
|
||||
delete(s.sessions, k)
|
||||
}
|
||||
}
|
||||
s.sessions[id] = session{user: user.Name, expires: time.Now().Add(sessionTTL)}
|
||||
s.mu.Unlock()
|
||||
http.SetCookie(w, &http.Cookie{Name: cookieName, Value: id, Path: "/", HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: s.secureCookie(r), MaxAge: int(sessionTTL.Seconds())})
|
||||
s.m.Audit(user.Name, "", "signed in from "+ip)
|
||||
writeJSON(w, map[string]any{"name": user.Name, "role": user.Role})
|
||||
}
|
||||
|
||||
func (s *Server) logout(w http.ResponseWriter, r *http.Request, p principal) {
|
||||
if c, err := r.Cookie(cookieName); err == nil {
|
||||
s.mu.Lock()
|
||||
delete(s.sessions, c.Value)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
|
||||
writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func fail(w http.ResponseWriter, code int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
|
||||
var mbe *http.MaxBytesError
|
||||
if errors.As(err, &mbe) {
|
||||
fail(w, http.StatusRequestEntityTooLarge, "request too large")
|
||||
} else {
|
||||
fail(w, http.StatusBadRequest, "bad request: "+err.Error())
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if r := []rune(s); len(r) > n {
|
||||
return string(r[:n])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func jsonBytes(v any) ([]byte, error) { return json.Marshal(v) }
|
||||
@@ -0,0 +1,656 @@
|
||||
'use strict';
|
||||
|
||||
// ---------- tiny helpers ----------
|
||||
const $ = (s, el = document) => el.querySelector(s);
|
||||
|
||||
function add(el, kids) {
|
||||
for (const k of kids.flat(Infinity)) {
|
||||
if (k == null || k === false) continue;
|
||||
el.append(k.nodeType ? k : document.createTextNode(String(k)));
|
||||
}
|
||||
}
|
||||
// h builds an element. Strings become text nodes, never HTML, so names from the game are safe.
|
||||
function h(tag, props, ...kids) {
|
||||
const el = document.createElement(tag);
|
||||
let value;
|
||||
for (const [k, v] of Object.entries(props || {})) {
|
||||
if (v == null || v === false) continue;
|
||||
if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
|
||||
else if (k === 'class') el.className = v;
|
||||
else if (k === 'value') value = v;
|
||||
else if (k === 'checked') el.checked = true;
|
||||
else el.setAttribute(k, v === true ? '' : v);
|
||||
}
|
||||
add(el, kids);
|
||||
if (value !== undefined) el.value = value;
|
||||
return el;
|
||||
}
|
||||
|
||||
const RANK = { viewer: 1, moderator: 2, admin: 3 };
|
||||
const can = role => me && RANK[me.role] >= RANK[role];
|
||||
|
||||
let me = null;
|
||||
let servers = [];
|
||||
let view = null; // the page on screen: { el, update?, onEvent?, dispose? }
|
||||
let stream = null;
|
||||
|
||||
async function api(method, path, body) {
|
||||
const opt = { method, headers: { 'X-Megalodon': '1' }, credentials: 'same-origin' };
|
||||
if (body !== undefined) { opt.headers['Content-Type'] = 'application/json'; opt.body = JSON.stringify(body); }
|
||||
const resp = await fetch(path, opt);
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (resp.status === 401 && me) { me = null; showLogin(); throw new Error('Signed out'); }
|
||||
if (!resp.ok) throw new Error(data.error || resp.statusText);
|
||||
return data;
|
||||
}
|
||||
|
||||
function toast(msg, bad) {
|
||||
const t = h('div', { class: 'toast' + (bad ? ' bad' : '') }, msg);
|
||||
$('#toasts').append(t);
|
||||
setTimeout(() => t.remove(), bad ? 8000 : 4000);
|
||||
}
|
||||
const fail = e => toast(e.message || String(e), true);
|
||||
|
||||
function fmtDur(sec) {
|
||||
sec = Math.max(0, Math.round(sec));
|
||||
const d = Math.floor(sec / 86400), hr = Math.floor(sec % 86400 / 3600), m = Math.floor(sec % 3600 / 60);
|
||||
if (d) return `${d}d ${hr}h`;
|
||||
if (hr) return `${hr}h ${m}m`;
|
||||
if (m) return `${m}m`;
|
||||
return `${sec}s`;
|
||||
}
|
||||
const since = iso => iso ? fmtDur((Date.now() - new Date(iso)) / 1000) : '';
|
||||
const clock = iso => new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
const stamp = iso => new Date(iso).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' });
|
||||
const serverName = id => (servers.find(s => s.id === id) || {}).name || id || '';
|
||||
const steamLink = id => h('a', { href: 'https://steamcommunity.com/profiles/' + id, target: '_blank', rel: 'noopener noreferrer', class: 'mono' }, id);
|
||||
|
||||
// ---------- dialogs ----------
|
||||
// ask shows a form and resolves with its values, or null if cancelled.
|
||||
// Field types: text, password, number, textarea, lines, select, checkbox, multi, note.
|
||||
function ask(title, fields, okLabel = 'Save') {
|
||||
return new Promise(resolve => {
|
||||
const dlg = h('dialog');
|
||||
const form = h('form');
|
||||
const inputs = {};
|
||||
const err = h('p', { class: 'err' });
|
||||
add(form, [h('h2', null, title)]);
|
||||
for (const f of fields) {
|
||||
if (f.type === 'note') { add(form, [h('p', { class: 'muted' }, f.text)]); continue; }
|
||||
let input;
|
||||
if (f.type === 'select') {
|
||||
input = h('select', { value: f.value }, (f.options || []).map(o => typeof o === 'string' ? h('option', { value: o }, o) : h('option', { value: o.v }, o.l)));
|
||||
input.value = f.value ?? input.options[0]?.value;
|
||||
} else if (f.type === 'textarea' || f.type === 'lines') {
|
||||
input = h('textarea', { placeholder: f.placeholder, rows: 5 });
|
||||
input.value = f.type === 'lines' ? (f.value || []).join('\n') : (f.value || '');
|
||||
} else if (f.type === 'checkbox') {
|
||||
input = h('input', { type: 'checkbox', checked: !!f.value });
|
||||
} else if (f.type === 'multi') {
|
||||
input = h('div', { class: 'multi' }, f.options.map(o => {
|
||||
const v = typeof o === 'string' ? o : o.v, l = typeof o === 'string' ? o : o.l;
|
||||
return h('label', null, h('input', { type: 'checkbox', value: v, checked: (f.value || []).includes(v) }), l);
|
||||
}));
|
||||
} else {
|
||||
input = h('input', { type: f.type || 'text', placeholder: f.placeholder, autocomplete: f.type === 'password' ? 'new-password' : 'off', step: f.type === 'number' ? 'any' : null });
|
||||
input.value = f.value ?? '';
|
||||
}
|
||||
inputs[f.name] = { f, input };
|
||||
if (f.type === 'checkbox') add(form, [h('label', { class: 'check' }, input, f.label)]);
|
||||
else add(form, [h('label', null, f.label), input]);
|
||||
}
|
||||
const close = v => { dlg.close(); dlg.remove(); resolve(v); };
|
||||
form.addEventListener('submit', e => {
|
||||
e.preventDefault();
|
||||
const out = {};
|
||||
for (const [name, { f, input }] of Object.entries(inputs)) {
|
||||
let v;
|
||||
if (f.type === 'checkbox') v = input.checked;
|
||||
else if (f.type === 'multi') v = [...input.querySelectorAll('input:checked')].map(i => i.value);
|
||||
else if (f.type === 'lines') v = input.value.split('\n').map(s => s.trim()).filter(Boolean);
|
||||
else if (f.type === 'number') v = input.value === '' ? null : Number(input.value);
|
||||
else v = input.value.trim();
|
||||
if (f.required && (v === '' || v == null || (Array.isArray(v) && !v.length))) { err.textContent = `${f.label} is required.`; return; }
|
||||
out[name] = v;
|
||||
}
|
||||
close(out);
|
||||
});
|
||||
add(form, [err, h('div', { class: 'buttons' }, h('button', { type: 'button', onclick: () => close(null) }, 'Cancel'), h('button', { class: 'primary', type: 'submit' }, okLabel))]);
|
||||
dlg.append(form);
|
||||
dlg.addEventListener('cancel', () => close(null));
|
||||
document.body.append(dlg);
|
||||
dlg.showModal();
|
||||
(dlg.querySelector('input:not([type=checkbox]), textarea, select') || dlg).focus();
|
||||
});
|
||||
}
|
||||
const confirmDlg = (title, text, ok = 'Yes') => ask(title, [{ type: 'note', text }], ok).then(Boolean);
|
||||
|
||||
// ---------- shell ----------
|
||||
const app = $('#app');
|
||||
|
||||
function showLogin() {
|
||||
stopAll();
|
||||
const err = h('p', { class: 'err' });
|
||||
const user = h('input', { type: 'text', placeholder: 'Username', autocomplete: 'username', required: true });
|
||||
const pass = h('input', { type: 'password', placeholder: 'Password', autocomplete: 'current-password', required: true });
|
||||
const form = h('form', {
|
||||
onsubmit: async e => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api('POST', '/api/login', { username: user.value, password: pass.value });
|
||||
await boot();
|
||||
} catch (x) { err.textContent = x.message; pass.value = ''; }
|
||||
}
|
||||
}, user, pass, h('button', { class: 'primary', type: 'submit' }, 'Sign in'), err);
|
||||
app.replaceChildren(h('div', { class: 'login panel' }, h('h1', null, 'Megalodon'), h('p', { class: 'muted' }, 'ARK server management'), form));
|
||||
user.focus();
|
||||
}
|
||||
|
||||
let pollTimer = null;
|
||||
function stopAll() {
|
||||
clearInterval(pollTimer);
|
||||
if (stream) stream.close();
|
||||
stream = null; view = null;
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
try { me = await api('GET', '/api/me'); } catch { me = null; return showLogin(); }
|
||||
servers = await api('GET', '/api/servers').catch(() => []);
|
||||
startStream();
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = setInterval(async () => {
|
||||
try { servers = await api('GET', '/api/servers'); view?.update?.(); } catch { /* shown by api() when signed out */ }
|
||||
}, 5000);
|
||||
route();
|
||||
}
|
||||
|
||||
function startStream() {
|
||||
if (stream) stream.close();
|
||||
stream = new EventSource('/api/stream');
|
||||
stream.onmessage = m => { try { view?.onEvent?.(JSON.parse(m.data)); } catch { /* ignore */ } };
|
||||
}
|
||||
|
||||
const NAV = [['#/', 'Overview', 'viewer'], ['#/players', 'Players', 'viewer'], ['#/bans', 'Bans', 'moderator'], ['#/schedules', 'Schedules', 'admin'], ['#/settings', 'Settings', 'admin']];
|
||||
|
||||
function shell(active, content) {
|
||||
const nav = h('nav', null, NAV.filter(n => can(n[2])).map(n => h('a', { href: n[0], class: n[0] === active ? 'on' : '' }, n[1])));
|
||||
const bar = h('header', { class: 'bar' }, h('span', { class: 'brand' }, 'Megalodon'), nav,
|
||||
h('span', { class: 'muted small' }, `${me.name} · ${me.role}`),
|
||||
h('button', { class: 'small', onclick: async () => { await api('POST', '/api/logout').catch(() => { }); me = null; showLogin(); } }, 'Sign out'));
|
||||
app.replaceChildren(bar, h('main', null, content));
|
||||
}
|
||||
|
||||
function route() {
|
||||
const parts = (location.hash.slice(2) || '').split('/');
|
||||
let page;
|
||||
switch (parts[0]) {
|
||||
case 's': page = serverPage(decodeURIComponent(parts[1] || ''), parts[2] || 'players'); shell('#/', page.el); break;
|
||||
case 'players': page = playersPage(); shell('#/players', page.el); break;
|
||||
case 'bans': page = can('moderator') ? bansPage() : overview(); shell('#/bans', page.el); break;
|
||||
case 'schedules': page = can('admin') ? schedulesPage() : overview(); shell('#/schedules', page.el); break;
|
||||
case 'settings': page = can('admin') ? settingsPage() : overview(); shell('#/settings', page.el); break;
|
||||
default: page = overview(); shell('#/', page.el);
|
||||
}
|
||||
view = page;
|
||||
page.update?.();
|
||||
}
|
||||
window.addEventListener('hashchange', () => me && route());
|
||||
|
||||
// ---------- actions ----------
|
||||
async function act(server, body, quiet) {
|
||||
try {
|
||||
const res = await api('POST', server ? `/api/servers/${encodeURIComponent(server)}/action` : '/api/actions', body);
|
||||
const bad = res.filter(r => !r.ok);
|
||||
if (bad.length) toast(bad.map(r => `${serverName(r.server)}: ${r.error}`).join('\n'), true);
|
||||
else if (!quiet) toast(res.length === 1 && res[0].output ? res[0].output : 'Done');
|
||||
servers = await api('GET', '/api/servers');
|
||||
view?.update?.();
|
||||
return res;
|
||||
} catch (e) { fail(e); return null; }
|
||||
}
|
||||
|
||||
async function banDialog(steamID, name, server) {
|
||||
const r = await ask(`Ban ${name || steamID}`, [
|
||||
{ name: 'steam_id', label: 'Steam ID', value: steamID, required: true },
|
||||
{ name: 'reason', label: 'Reason', placeholder: 'Shown in the ban list' },
|
||||
{ name: 'minutes', label: 'Length', type: 'select', value: '0', options: [{ v: '60', l: '1 hour' }, { v: '1440', l: '1 day' }, { v: '10080', l: '7 days' }, { v: '43200', l: '30 days' }, { v: '0', l: 'Permanent' }] },
|
||||
{ name: 'all', label: 'Ban on every server (recommended for a cluster)', type: 'checkbox', value: true },
|
||||
], 'Ban');
|
||||
if (!r) return;
|
||||
const only = !r.all && server;
|
||||
act(only ? server : null, { action: 'ban', steam_id: r.steam_id, player: name, reason: r.reason, minutes: Number(r.minutes), servers: only ? [server] : [] });
|
||||
}
|
||||
|
||||
// ---------- event rows ----------
|
||||
function eventRow(e, withServer = true) {
|
||||
const text = e.type === 'chat' ? [h('span', { class: 'who' }, e.actor || 'SERVER'), ': ', e.message] : [e.actor && e.type !== 'audit' ? e.actor + ': ' : '', e.type === 'audit' && e.actor ? e.actor + ' — ' : '', e.message];
|
||||
return h('div', { class: 'ev' }, h('time', null, clock(e.time)), h('span', { class: 'tag ' + e.type }, e.type),
|
||||
withServer && e.server ? h('span', { class: 'srv muted' }, serverName(e.server)) : null, h('span', { class: 'txt' }, text));
|
||||
}
|
||||
|
||||
// ---------- sparkline ----------
|
||||
function spark(canvas, samples) {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = canvas.clientWidth, hgt = canvas.clientHeight;
|
||||
canvas.width = w * dpr; canvas.height = hgt * dpr;
|
||||
const g = canvas.getContext('2d');
|
||||
g.scale(dpr, dpr);
|
||||
const css = getComputedStyle(document.documentElement);
|
||||
const line = css.getPropertyValue('--accent').trim(), mute = css.getPropertyValue('--line').trim(), bad = css.getPropertyValue('--bad').trim();
|
||||
g.clearRect(0, 0, w, hgt);
|
||||
if (!samples.length) return;
|
||||
const t1 = Date.now() / 1000, t0 = t1 - 86400;
|
||||
const max = Math.max(4, ...samples.map(s => s.n));
|
||||
const x = t => (t - t0) / 86400 * w, y = n => hgt - 3 - n / max * (hgt - 6);
|
||||
g.strokeStyle = mute; g.beginPath(); g.moveTo(0, hgt - .5); g.lineTo(w, hgt - .5); g.stroke();
|
||||
g.fillStyle = bad;
|
||||
samples.forEach(s => { if (!s.up) g.fillRect(x(s.t), hgt - 4, Math.max(2, w / 1440), 4); });
|
||||
g.strokeStyle = line; g.lineWidth = 1.6; g.beginPath();
|
||||
let pen = false;
|
||||
samples.forEach(s => { if (!s.up) { pen = false; return; } const px = x(s.t), py = y(s.n); pen ? g.lineTo(px, py) : g.moveTo(px, py); pen = true; });
|
||||
g.stroke();
|
||||
}
|
||||
|
||||
// ---------- overview ----------
|
||||
function statusPill(s) {
|
||||
if (s.restart) return h('span', { class: 'pill warn' }, s.restart.stop ? 'Shutting down soon' : 'Restarting soon');
|
||||
if (s.online) return h('span', { class: 'pill ok' }, 'Online');
|
||||
return h('span', { class: 'pill bad' }, s.held ? 'Stopped' : 'Offline');
|
||||
}
|
||||
|
||||
function overview() {
|
||||
const cards = h('div', { class: 'grid' });
|
||||
const feed = h('div', { class: 'log' });
|
||||
const tools = h('div', { class: 'row' });
|
||||
if (can('moderator')) add(tools, [
|
||||
h('button', { onclick: async () => { const r = await ask('Broadcast to every server', [{ name: 'message', label: 'Message', required: true }], 'Send'); if (r) act(null, { action: 'broadcast', message: r.message }); } }, 'Broadcast to all'),
|
||||
h('button', { onclick: () => act(null, { action: 'save' }) }, 'Save all worlds')]);
|
||||
if (can('admin')) add(tools, [h('button', { onclick: addServerDialog }, 'Add server')]);
|
||||
const samples = {};
|
||||
async function loadSamples(id) { try { samples[id] = await api('GET', `/api/servers/${encodeURIComponent(id)}/samples`); drawSpark(id); } catch { /* ignore */ } }
|
||||
const canvases = {};
|
||||
const drawSpark = id => canvases[id] && spark(canvases[id], samples[id] || []);
|
||||
let lastLoad = 0;
|
||||
const page = {
|
||||
el: h('div', { class: 'stack' }, h('div', { class: 'row between' }, h('h1', null, 'Servers'), tools), cards,
|
||||
h('div', { class: 'panel' }, h('h2', null, 'Recent activity'), feed)),
|
||||
update() {
|
||||
cards.replaceChildren();
|
||||
if (!servers.length) cards.append(h('div', { class: 'empty panel' }, can('admin') ? 'No servers yet. Use "Add server" to connect one over RCON.' : 'No servers are configured.'));
|
||||
for (const s of servers) {
|
||||
const canvas = h('canvas'); canvases[s.id] = canvas;
|
||||
const info = s.info;
|
||||
cards.append(h('div', { class: 'card' },
|
||||
h('h2', null, h('a', { href: '#/s/' + encodeURIComponent(s.id) }, s.name), statusPill(s)),
|
||||
h('div', { class: 'muted small' }, s.online ? [`${s.players.length}${info ? '/' + info.max_players : ''} players`, info?.map ? ' · ' + info.map : '', info?.version ? ' · v' + info.version : '', s.since ? ' · up ' + since(s.since) : ''] : (s.last_error ? s.last_error : 'Waiting for a response…')),
|
||||
canvas,
|
||||
h('div', { class: 'row small muted' }, s.online ? `RCON ${s.rtt_ms} ms` : '', s.peak ? ` · peak ${s.peak}` : '', h('span', { class: 'spacer' }),
|
||||
h('a', { href: '#/s/' + encodeURIComponent(s.id) }, 'Manage'))));
|
||||
requestAnimationFrame(() => drawSpark(s.id));
|
||||
}
|
||||
if (Date.now() - lastLoad > 60000) { lastLoad = Date.now(); servers.forEach(s => loadSamples(s.id)); }
|
||||
},
|
||||
onEvent(e) { if (e.type !== 'chat') { feed.prepend(eventRow(e)); while (feed.children.length > 40) feed.lastChild.remove(); } },
|
||||
};
|
||||
api('GET', '/api/events?limit=40&type=join,leave,online,offline,restart,start,ban,unban,kick,filter,watch,schedule,audit,error').then(evs => evs.forEach(e => feed.append(eventRow(e)))).catch(() => { });
|
||||
return page;
|
||||
}
|
||||
|
||||
async function addServerDialog(existing) {
|
||||
const e = existing || {};
|
||||
const r = await ask(existing ? `Edit ${e.name}` : 'Add a server', [
|
||||
{ name: 'name', label: 'Name', value: e.name, required: true, placeholder: 'The Island' },
|
||||
{ name: 'host', label: 'Host or IP', value: e.host, required: true, placeholder: '192.168.1.20' },
|
||||
{ name: 'rcon_port', label: 'RCON port', type: 'number', value: e.rcon_port || 27020, required: true },
|
||||
{ name: 'password', label: existing ? 'RCON password (leave blank to keep)' : 'RCON password', type: 'password', required: !existing },
|
||||
{ name: 'query_port', label: 'Query port (UDP, 0 = off)', type: 'number', value: e.query_port ?? 27015 },
|
||||
{ name: 'start_command', label: 'Start command (optional, runs on this machine)', value: e.start_command, placeholder: 'docker start ark-island' },
|
||||
{ name: 'auto_start', label: 'Start it automatically when it has been down for a while', type: 'checkbox', value: e.auto_start },
|
||||
]);
|
||||
if (!r) return;
|
||||
try {
|
||||
const body = { ...r, rcon_port: r.rcon_port || 27020, query_port: r.query_port || 0 };
|
||||
if (existing) await api('PUT', '/api/servers/' + encodeURIComponent(e.id), body);
|
||||
else await api('POST', '/api/servers', body);
|
||||
servers = await api('GET', '/api/servers');
|
||||
view?.update?.();
|
||||
toast('Saved');
|
||||
} catch (x) { fail(x); }
|
||||
}
|
||||
|
||||
// ---------- one server ----------
|
||||
function serverPage(id, tab) {
|
||||
const get = () => servers.find(s => s.id === id);
|
||||
if (!get()) return { el: h('div', { class: 'empty panel' }, 'No such server. ', h('a', { href: '#/' }, 'Back to overview')) };
|
||||
const head = h('div', { class: 'panel' });
|
||||
const body = h('div');
|
||||
const tabs = [['players', 'Players', 'viewer'], ['chat', 'Chat', 'viewer'], ['console', 'Console', 'admin'], ['events', 'Events', 'viewer']].filter(t => can(t[2]));
|
||||
if (!tabs.some(t => t[0] === tab)) tab = 'players';
|
||||
const tabBar = h('div', { class: 'tabs' }, tabs.map(t => h('a', { href: `#/s/${encodeURIComponent(id)}/${t[0]}`, class: t[0] === tab ? 'on' : '' }, t[1])));
|
||||
const sub = { players: playersTab, chat: chatTab, console: consoleTab, events: eventsTab }[tab](id, get);
|
||||
body.append(sub.el);
|
||||
|
||||
function drawHead() {
|
||||
const s = get();
|
||||
if (!s) return;
|
||||
const info = s.info;
|
||||
const ctl = h('div', { class: 'row' });
|
||||
if (can('moderator')) add(ctl, [
|
||||
h('button', { onclick: () => act(id, { action: 'save' }) }, 'Save world'),
|
||||
h('button', { onclick: async () => { const r = await ask('Broadcast', [{ name: 'message', label: 'Message to every player', required: true }], 'Send'); if (r) act(id, { action: 'broadcast', message: r.message }); } }, 'Broadcast')]);
|
||||
if (can('admin')) {
|
||||
add(ctl, [
|
||||
s.restart ? h('button', { onclick: () => act(id, { action: 'cancel_restart' }) }, 'Cancel ' + (s.restart.stop ? 'shutdown' : 'restart')) : [
|
||||
h('button', { onclick: () => restartDialog(id, false) }, 'Restart…'),
|
||||
h('button', { class: 'danger', onclick: () => restartDialog(id, true) }, 'Stop…')],
|
||||
s.can_start && !s.online ? h('button', { onclick: () => act(id, { action: 'start' }) }, 'Start') : null,
|
||||
h('button', { onclick: async () => { const r = await ask('Set time of day', [{ name: 'time', label: 'Time (HH:MM)', value: '12:00', required: true }]); if (r) act(id, { action: 'set_time', time: r.time }); } }, 'Set time'),
|
||||
h('button', { onclick: async () => { const r = await ask('Message of the day', [{ name: 'message', label: 'Shown to players when they join', required: true }]); if (r) act(id, { action: 'motd', message: r.message }); } }, 'MOTD'),
|
||||
h('button', { class: 'danger', onclick: async () => { if (await confirmDlg('Destroy wild creatures?', 'All wild creatures on this server will be removed and respawn.', 'Destroy')) act(id, { action: 'destroy_wild_dinos' }); } }, 'Wipe wild dinos'),
|
||||
h('button', { onclick: () => addServerDialog(s) }, 'Edit'),
|
||||
h('button', { class: 'danger', onclick: async () => { if (await confirmDlg('Remove ' + s.name + '?', 'Megalodon stops managing this server. The server itself is not touched.', 'Remove')) { await api('DELETE', '/api/servers/' + encodeURIComponent(id)).catch(fail); servers = await api('GET', '/api/servers'); location.hash = '#/'; } } }, 'Remove')]);
|
||||
}
|
||||
head.replaceChildren(...[
|
||||
h('div', { class: 'row between' }, h('h1', null, s.name), statusPill(s)),
|
||||
h('div', { class: 'muted small' }, `${s.host}:${s.rcon_port}`, s.online ? ` · ${s.players.length}${info ? '/' + info.max_players : ''} players` : '', info?.map ? ' · ' + info.map : '', info?.version ? ' · v' + info.version : '', s.since ? ' · up ' + since(s.since) : '', s.online ? ` · RCON ${s.rtt_ms} ms` : ''),
|
||||
!s.online ? h('div', { class: 'banner bad' }, s.held ? 'Stopped from Megalodon.' : 'Not responding.', s.last_error ? ' ' + s.last_error : '', s.offline_since ? ' Down for ' + since(s.offline_since) + '.' : '') : null,
|
||||
s.restart ? h('div', { class: 'banner' }, `${s.restart.stop ? 'Shutdown' : 'Restart'} at ${clock(s.restart.at)} (in ${fmtDur((new Date(s.restart.at) - Date.now()) / 1000)}) — started by ${s.restart.by}`, s.restart.reason ? ' · ' + s.restart.reason : '') : null,
|
||||
ctl].filter(Boolean));
|
||||
}
|
||||
return {
|
||||
el: h('div', null, h('p', { class: 'small' }, h('a', { href: '#/' }, '← All servers')), head, tabBar, body),
|
||||
update() { drawHead(); sub.update?.(); },
|
||||
onEvent(e) { if (e.server === id) sub.onEvent?.(e); },
|
||||
};
|
||||
}
|
||||
|
||||
async function restartDialog(id, stop) {
|
||||
const r = await ask(stop ? 'Stop the server' : 'Restart the server', [
|
||||
{ name: 'minutes', label: 'Countdown in minutes (players get warnings; 0 = now)', type: 'number', value: 5, required: true },
|
||||
{ name: 'reason', label: 'Reason (shown to players)', placeholder: 'Server update' },
|
||||
], stop ? 'Stop' : 'Restart');
|
||||
if (r) act(id, { action: stop ? 'stop' : 'restart', minutes: r.minutes, reason: r.reason });
|
||||
}
|
||||
|
||||
function playersTab(id, get) {
|
||||
const table = h('div', { class: 'tablewrap panel' });
|
||||
return {
|
||||
el: table,
|
||||
update() {
|
||||
const s = get();
|
||||
if (!s?.players.length) { table.replaceChildren(h('div', { class: 'empty' }, s?.online ? 'Nobody is online.' : 'The server is offline.')); return; }
|
||||
table.replaceChildren(h('table', null, h('thead', null, h('tr', null, ['Player', 'Steam ID', 'Online for', ''].map(t => h('th', null, t)))),
|
||||
h('tbody', null, s.players.map(p => h('tr', null,
|
||||
h('td', null, p.name, p.banned ? h('span', { class: 'pill bad' }, ' banned') : null, p.watch ? h('span', { class: 'pill warn' }, ' watched') : null, p.notes ? h('div', { class: 'muted small' }, p.notes) : null),
|
||||
h('td', null, steamLink(p.steam_id)), h('td', null, since(p.joined_at) || 'just now'),
|
||||
h('td', { class: 'actions' }, can('moderator') ? [
|
||||
h('button', { class: 'small', onclick: async () => { const r = await ask('Message to ' + p.name, [{ name: 'message', label: 'Private message', required: true }], 'Send'); if (r) act(id, { action: 'whisper', steam_id: p.steam_id, message: r.message }); } }, 'Message'), ' ',
|
||||
h('button', { class: 'small', onclick: async () => { if (await confirmDlg('Kick ' + p.name + '?', 'They can rejoin straight away.', 'Kick')) act(id, { action: 'kick', steam_id: p.steam_id }); } }, 'Kick'), ' ',
|
||||
h('button', { class: 'small danger', onclick: () => banDialog(p.steam_id, p.name, id) }, 'Ban'), ' ',
|
||||
h('button', { class: 'small', onclick: () => noteDialog(p.steam_id, p.name, p.notes, p.watch) }, 'Note')] : null))))));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function noteDialog(steamID, name, notes, watch) {
|
||||
const r = await ask('Notes on ' + name, [
|
||||
{ name: 'notes', label: 'Notes (visible to moderators)', type: 'textarea', value: notes },
|
||||
{ name: 'watch', label: 'Alert me when this player joins', type: 'checkbox', value: watch }]);
|
||||
if (r) api('PUT', '/api/players/' + steamID, r).then(() => toast('Saved')).catch(fail);
|
||||
}
|
||||
|
||||
function chatTab(id) {
|
||||
const log = h('div', { class: 'log chat' });
|
||||
const input = h('input', { type: 'text', placeholder: 'Message to everyone', maxlength: 300, class: 'grow' });
|
||||
const line = e => {
|
||||
const near = log.scrollHeight - log.scrollTop - log.clientHeight < 60;
|
||||
log.append(h('div', { class: 'ev' }, h('time', null, clock(e.time)), h('span', { class: e.actor === 'SERVER' ? 'who srv' : 'who' }, e.actor || 'SERVER'), h('span', { class: 'txt' }, e.message, e.detail ? h('span', { class: 'muted small' }, ` (${e.detail})`) : null)));
|
||||
if (near) log.scrollTop = log.scrollHeight;
|
||||
};
|
||||
api('GET', `/api/events?server=${encodeURIComponent(id)}&type=chat&limit=200`).then(evs => { evs.reverse().forEach(line); log.scrollTop = log.scrollHeight; }).catch(fail);
|
||||
const send = (action) => { const m = input.value.trim(); if (!m) return; act(id, { action, message: m }, true).then(() => { input.value = ''; }); };
|
||||
return {
|
||||
el: h('div', { class: 'panel' }, log, can('moderator') ? h('form', { class: 'row mt', onsubmit: e => { e.preventDefault(); send('say'); } }, input,
|
||||
h('button', { class: 'primary', type: 'submit' }, 'Say as SERVER'), h('button', { type: 'button', onclick: () => send('broadcast') }, 'Broadcast')) : null),
|
||||
onEvent(e) { if (e.type === 'chat') line(e); },
|
||||
};
|
||||
}
|
||||
|
||||
function consoleTab(id) {
|
||||
const out = h('pre', { class: 'out' });
|
||||
const input = h('input', { type: 'text', list: 'cmds', placeholder: 'ListPlayers', class: 'grow', autocomplete: 'off', spellcheck: 'false' });
|
||||
const list = h('datalist', { id: 'cmds' }, (me.reference || []).map(c => h('option', { value: c })));
|
||||
let hist = []; try { hist = JSON.parse(localStorage.getItem('megalodon.history') || '[]'); } catch { /* none */ }
|
||||
let pos = hist.length;
|
||||
const print = (cls, text) => { out.append(h('div', { class: cls }, text)); out.scrollTop = out.scrollHeight; };
|
||||
async function run(cmd) {
|
||||
print('cmd', '> ' + cmd);
|
||||
try {
|
||||
const res = await api('POST', `/api/servers/${encodeURIComponent(id)}/action`, { action: 'exec', command: cmd });
|
||||
res.forEach(r => print(r.ok ? '' : 'bad', r.ok ? (r.output || '(no reply)') : r.error));
|
||||
} catch (e) { print('bad', e.message); }
|
||||
}
|
||||
const macros = h('select', null, h('option', { value: '' }, 'Macro…'), (me.macros || []).map(m => h('option', { value: m.name }, m.name)));
|
||||
macros.addEventListener('change', async () => {
|
||||
const name = macros.value; macros.value = '';
|
||||
if (!name) return;
|
||||
print('cmd', '> macro ' + name);
|
||||
try { (await api('POST', `/api/servers/${encodeURIComponent(id)}/macro`, { name })).forEach(r => print(r.ok ? '' : 'bad', r.ok ? (r.output || '(no reply)') : r.error)); } catch (e) { print('bad', e.message); }
|
||||
});
|
||||
input.addEventListener('keydown', e => {
|
||||
if (e.key === 'ArrowUp' && pos > 0) { input.value = hist[--pos]; e.preventDefault(); }
|
||||
if (e.key === 'ArrowDown' && pos < hist.length) { input.value = hist[++pos] || ''; e.preventDefault(); }
|
||||
});
|
||||
return {
|
||||
el: h('div', { class: 'panel' }, out, list,
|
||||
h('form', { class: 'row', onsubmit: e => {
|
||||
e.preventDefault();
|
||||
const c = input.value.trim(); if (!c) return;
|
||||
if (hist[hist.length - 1] !== c) hist.push(c);
|
||||
hist = hist.slice(-100); pos = hist.length;
|
||||
try { localStorage.setItem('megalodon.history', JSON.stringify(hist)); } catch { /* private mode */ }
|
||||
input.value = ''; run(c);
|
||||
} }, input, h('button', { class: 'primary', type: 'submit' }, 'Run'), (me.macros || []).length ? macros : null),
|
||||
h('p', { class: 'muted small' }, 'Commands go straight to the server\'s console and are recorded in the event log.')),
|
||||
};
|
||||
}
|
||||
|
||||
function eventsTab(id) {
|
||||
const log = h('div', { class: 'log' });
|
||||
const types = ['', 'join', 'leave', 'chat', 'online', 'offline', 'restart', 'ban', 'kick', 'filter', 'watch', 'schedule', 'audit', 'error'].filter(t => !['audit', 'error'].includes(t) || can('moderator'));
|
||||
const sel = h('select', null, types.map(t => h('option', { value: t }, t || 'All events')));
|
||||
const q = h('input', { type: 'text', placeholder: 'Search' });
|
||||
const more = h('button', { class: 'small' }, 'Older');
|
||||
let oldest = 0;
|
||||
async function load(reset) {
|
||||
if (reset) { log.replaceChildren(); oldest = 0; }
|
||||
const qs = new URLSearchParams({ server: id, limit: 100 });
|
||||
if (sel.value) qs.set('type', sel.value);
|
||||
if (q.value) qs.set('q', q.value);
|
||||
if (oldest) qs.set('before', oldest);
|
||||
try { const evs = await api('GET', '/api/events?' + qs); evs.forEach(e => { log.append(eventRow(e, false)); oldest = e.id; }); more.hidden = evs.length < 100; } catch (e) { fail(e); }
|
||||
}
|
||||
sel.addEventListener('change', () => load(true)); q.addEventListener('change', () => load(true)); more.addEventListener('click', () => load(false));
|
||||
load(true);
|
||||
return {
|
||||
el: h('div', { class: 'panel' }, h('div', { class: 'row' }, sel, q), log, h('div', { class: 'row end' }, more)),
|
||||
onEvent(e) { if ((!sel.value || sel.value === e.type) && !q.value) log.prepend(eventRow(e, false)); },
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- players database ----------
|
||||
function playersPage() {
|
||||
const body = h('div', { class: 'tablewrap' });
|
||||
const q = h('input', { type: 'text', placeholder: 'Search by name or Steam ID' });
|
||||
let all = [];
|
||||
const draw = () => {
|
||||
const f = q.value.toLowerCase();
|
||||
const rows = all.filter(p => !f || p.name.toLowerCase().includes(f) || p.steam_id.includes(f));
|
||||
body.replaceChildren(rows.length ? h('table', null, h('thead', null, h('tr', null, ['Player', 'Steam ID', 'Last seen', 'Play time', 'Joins', ''].map(t => h('th', null, t)))),
|
||||
h('tbody', null, rows.slice(0, 500).map(p => h('tr', null,
|
||||
h('td', null, p.name, p.watch ? h('span', { class: 'pill warn' }, ' watched') : null, p.notes ? h('div', { class: 'muted small' }, p.notes) : null),
|
||||
h('td', null, steamLink(p.steam_id)),
|
||||
h('td', null, stamp(p.last_seen), p.last_server ? h('span', { class: 'muted small' }, ' · ' + serverName(p.last_server)) : null),
|
||||
h('td', null, fmtDur(p.seconds)), h('td', null, p.joins),
|
||||
h('td', { class: 'actions' }, can('moderator') ? [h('button', { class: 'small', onclick: async () => { await noteDialog(p.steam_id, p.name, p.notes, p.watch); setTimeout(load, 300); } }, 'Note'), ' ', h('button', { class: 'small danger', onclick: () => banDialog(p.steam_id, p.name) }, 'Ban')] : null)))))
|
||||
: h('div', { class: 'empty' }, 'No players recorded yet.'));
|
||||
};
|
||||
const load = () => api('GET', '/api/players').then(l => { all = l; draw(); }).catch(fail);
|
||||
q.addEventListener('input', draw);
|
||||
load();
|
||||
return { el: h('div', { class: 'stack' }, h('div', { class: 'row between' }, h('h1', null, 'Players'), q), h('div', { class: 'panel' }, body)) };
|
||||
}
|
||||
|
||||
// ---------- bans ----------
|
||||
function bansPage() {
|
||||
const body = h('div', { class: 'tablewrap' });
|
||||
const load = () => api('GET', '/api/bans').then(list => {
|
||||
body.replaceChildren(list.length ? h('table', null, h('thead', null, h('tr', null, ['Player', 'Steam ID', 'Reason', 'By', 'Until', ''].map(t => h('th', null, t)))),
|
||||
h('tbody', null, list.map(b => h('tr', null, h('td', null, b.name || '—'), h('td', null, steamLink(b.steam_id)), h('td', null, b.reason || ''), h('td', null, b.by, h('div', { class: 'muted small' }, stamp(b.at))),
|
||||
h('td', null, b.expires ? stamp(b.expires) : 'Never', b.servers?.length ? h('div', { class: 'muted small' }, b.servers.map(serverName).join(', ')) : null),
|
||||
h('td', { class: 'actions' }, h('button', { class: 'small', onclick: async () => { if (await confirmDlg('Unban ' + (b.name || b.steam_id) + '?', 'They will be able to join again.', 'Unban')) { await act(null, { action: 'unban', steam_id: b.steam_id }); load(); } } }, 'Unban')))))) : h('div', { class: 'empty' }, 'No bans.'));
|
||||
}).catch(fail);
|
||||
load();
|
||||
return { el: h('div', { class: 'stack' }, h('div', { class: 'row between' }, h('h1', null, 'Bans'), h('button', { onclick: async () => { await banDialog('', ''); setTimeout(load, 500); } }, 'Ban by Steam ID')),
|
||||
h('p', { class: 'muted small' }, 'Bans are kept by Megalodon and sent to every server. A banned player who shows up anywhere is removed again, even if that server was offline when the ban was made.'), h('div', { class: 'panel' }, body)) };
|
||||
}
|
||||
|
||||
// ---------- schedules ----------
|
||||
const ACTIONS = [['save', 'Save the world'], ['broadcast', 'Broadcast a message'], ['restart', 'Restart with warnings'], ['command', 'Run a console command'], ['destroy_wild_dinos', 'Wipe wild dinos'], ['motd', 'Set the message of the day']];
|
||||
const DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
|
||||
const serverOptions = () => servers.map(s => ({ v: s.id, l: s.name }));
|
||||
|
||||
function describeJob(j) {
|
||||
const when = j.every ? 'every ' + j.every : 'at ' + j.at.join(', ') + (j.days?.length ? ' on ' + j.days.join(', ') : ' daily');
|
||||
return `${(ACTIONS.find(a => a[0] === j.action) || [, j.action])[1]} · ${when} · ${j.servers?.length ? j.servers.map(serverName).join(', ') : 'all servers'}`;
|
||||
}
|
||||
|
||||
function schedulesPage() {
|
||||
const body = h('div');
|
||||
let st;
|
||||
const save = async () => { try { st = await api('PUT', '/api/settings', st); draw(); } catch (e) { fail(e); await load(); } };
|
||||
async function edit(j) {
|
||||
const e = j || { enabled: true, action: 'save', every: '1h', at: [], days: [], servers: [] };
|
||||
const r = await ask(j ? 'Edit job' : 'New job', [
|
||||
{ name: 'name', label: 'Name', value: e.name, required: true },
|
||||
{ name: 'action', label: 'Action', type: 'select', value: e.action, options: ACTIONS.map(a => ({ v: a[0], l: a[1] })) },
|
||||
{ name: 'servers', label: 'Servers (none ticked = all)', type: 'multi', options: serverOptions(), value: e.servers },
|
||||
{ name: 'every', label: 'Repeat every (e.g. 30m, 6h) — leave empty to use times of day', value: e.every },
|
||||
{ name: 'at', label: 'Times of day (HH:MM, comma separated)', value: (e.at || []).join(', ') },
|
||||
{ name: 'days', label: 'Only on these days (times of day only)', type: 'multi', options: DAYS, value: e.days },
|
||||
{ name: 'message', label: 'Message (broadcast / MOTD)', value: e.message },
|
||||
{ name: 'command', label: 'Console command', value: e.command },
|
||||
{ name: 'countdown', label: 'Restart countdown (e.g. 10m)', value: e.countdown },
|
||||
{ name: 'reason', label: 'Restart reason shown to players', value: e.reason },
|
||||
{ name: 'enabled', label: 'Enabled', type: 'checkbox', value: e.enabled },
|
||||
]);
|
||||
if (!r) return;
|
||||
r.at = r.at.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const nj = { ...e, ...r };
|
||||
st.jobs = j ? st.jobs.map(x => x.id === j.id ? nj : x) : [...st.jobs, nj];
|
||||
save();
|
||||
}
|
||||
function draw() {
|
||||
body.replaceChildren(st.jobs.length ? h('table', null, h('thead', null, h('tr', null, ['', 'Job', 'What', ''].map(t => h('th', null, t)))),
|
||||
h('tbody', null, st.jobs.map(j => h('tr', null,
|
||||
h('td', null, h('input', { type: 'checkbox', checked: j.enabled, title: 'Enabled', onchange: ev => { j.enabled = ev.target.checked; save(); } })),
|
||||
h('td', null, j.name), h('td', { class: 'muted small' }, describeJob(j)),
|
||||
h('td', { class: 'actions' },
|
||||
h('button', { class: 'small', onclick: async () => { const r = await api('POST', `/api/jobs/${j.id}/run`).catch(fail); if (r) toast('Ran on ' + r.length + ' server(s)'); } }, 'Run now'), ' ',
|
||||
h('button', { class: 'small', onclick: () => edit(j) }, 'Edit'), ' ',
|
||||
h('button', { class: 'small danger', onclick: async () => { if (await confirmDlg('Delete ' + j.name + '?', '', 'Delete')) { st.jobs = st.jobs.filter(x => x.id !== j.id); save(); } } }, 'Delete')))))) : h('div', { class: 'empty' }, 'No scheduled jobs. Try a save every 30 minutes, or a daily restart at 04:00.'));
|
||||
}
|
||||
const load = () => api('GET', '/api/settings').then(s => { st = s; st.jobs ||= []; draw(); }).catch(fail);
|
||||
load();
|
||||
return { el: h('div', { class: 'stack' }, h('div', { class: 'row between' }, h('h1', null, 'Schedules'), h('button', { class: 'primary', onclick: () => st && edit() }, 'New job')),
|
||||
h('p', { class: 'muted small' }, 'Times use the clock of the machine Megalodon runs on. Restarts warn players at 30, 15, 10, 5, 2 and 1 minute, then 30 and 10 seconds, and save the world first.'), h('div', { class: 'panel' }, body)) };
|
||||
}
|
||||
|
||||
// ---------- settings ----------
|
||||
function settingsPage() {
|
||||
const root = h('div', { class: 'stack' });
|
||||
let st;
|
||||
const save = async () => { try { st = await api('PUT', '/api/settings', st); toast('Saved'); draw(); } catch (e) { fail(e); await load(); } };
|
||||
|
||||
function section(title, hint, actions, content) {
|
||||
return h('div', { class: 'panel' }, h('div', { class: 'row between' }, h('h2', null, title), h('div', { class: 'row' }, actions)), hint ? h('p', { class: 'muted small' }, hint) : null, content);
|
||||
}
|
||||
const table = (cols, rows) => rows.length ? h('div', { class: 'tablewrap' }, h('table', null, h('thead', null, h('tr', null, cols.map(c => h('th', null, c)))), h('tbody', null, rows))) : h('div', { class: 'empty' }, 'Nothing yet.');
|
||||
const EVENTS = ['join', 'leave', 'online', 'offline', 'restart', 'start', 'ban', 'unban', 'kick', 'filter', 'watch', 'schedule', 'error', 'audit', 'chat'];
|
||||
|
||||
async function editNotify(n) {
|
||||
const e = n || { kind: 'discord', events: [], servers: [] };
|
||||
const r = await ask(n ? 'Edit notification' : 'Add a notification', [
|
||||
{ name: 'name', label: 'Name', value: e.name, required: true },
|
||||
{ name: 'kind', label: 'Kind', type: 'select', value: e.kind, options: [{ v: 'discord', l: 'Discord webhook' }, { v: 'ntfy', l: 'ntfy topic URL' }, { v: 'json', l: 'Generic webhook (JSON)' }] },
|
||||
{ name: 'url', label: 'URL', value: e.url, required: true },
|
||||
{ name: 'events', label: 'Events (none ticked = joins, leaves, up/down, restarts, bans)', type: 'multi', options: EVENTS, value: e.events },
|
||||
{ name: 'servers', label: 'Servers (none ticked = all)', type: 'multi', options: serverOptions(), value: e.servers },
|
||||
]);
|
||||
if (!r) return;
|
||||
st.notify = n ? st.notify.map(x => x === n ? r : x) : [...st.notify, r];
|
||||
save();
|
||||
}
|
||||
async function editMacro(m) {
|
||||
const e = m || { role: 'admin', commands: [] };
|
||||
const r = await ask(m ? 'Edit macro' : 'Add a macro', [
|
||||
{ name: 'name', label: 'Name', value: e.name, required: true },
|
||||
{ name: 'commands', label: 'Console commands, one per line', type: 'lines', value: e.commands, required: true },
|
||||
{ name: 'role', label: 'Who may run it', type: 'select', value: e.role, options: ['moderator', 'admin'] },
|
||||
]);
|
||||
if (!r) return;
|
||||
st.macros = m ? st.macros.map(x => x === m ? r : x) : [...st.macros, r];
|
||||
save();
|
||||
}
|
||||
async function editUser(u) {
|
||||
const r = await ask(u ? 'Edit ' + u.name : 'Add a user', [
|
||||
u ? { type: 'note', text: 'Leave the password empty to keep it.' } : { name: 'name', label: 'Username', required: true },
|
||||
{ name: 'role', label: 'Role', type: 'select', value: u?.role || 'viewer', options: ['viewer', 'moderator', 'admin'] },
|
||||
{ name: 'password', label: 'Password (8+ characters)', type: 'password', required: !u },
|
||||
].filter(Boolean));
|
||||
if (!r) return;
|
||||
try { await api('POST', '/api/users', { name: u?.name || r.name, role: r.role, password: r.password }); toast('Saved'); load(); } catch (e) { fail(e); }
|
||||
}
|
||||
async function newToken() {
|
||||
const r = await ask('Create an API token', [
|
||||
{ name: 'name', label: 'Name', required: true, placeholder: 'prometheus' },
|
||||
{ name: 'role', label: 'Role', type: 'select', value: 'viewer', options: ['viewer', 'moderator', 'admin'] },
|
||||
], 'Create');
|
||||
if (!r) return;
|
||||
try {
|
||||
const { token } = await api('POST', '/api/tokens', r);
|
||||
await ask('Copy your token now', [{ type: 'note', text: 'It is shown only once. Send it as "Authorization: Bearer <token>".' }, { name: 't', label: 'Token', value: token }], 'Done');
|
||||
load();
|
||||
} catch (e) { fail(e); }
|
||||
}
|
||||
|
||||
async function draw() {
|
||||
const [users, tokens] = await Promise.all([api('GET', '/api/users'), api('GET', '/api/tokens')]);
|
||||
const w = st.welcome, f = st.chat_filter;
|
||||
root.replaceChildren(h('h1', null, 'Settings'),
|
||||
section('Notifications', 'Send events to Discord, ntfy or any webhook.', h('button', { onclick: () => editNotify() }, 'Add'),
|
||||
table(['Name', 'Kind', 'Events', ''], st.notify.map(n => h('tr', null, h('td', null, n.name), h('td', null, n.kind), h('td', { class: 'muted small' }, n.events?.length ? n.events.join(', ') : 'default'),
|
||||
h('td', { class: 'actions' }, h('button', { class: 'small', onclick: () => api('POST', '/api/notify/test', { name: n.name }).then(() => toast('Sent')).catch(fail) }, 'Test'), ' ', h('button', { class: 'small', onclick: () => editNotify(n) }, 'Edit'), ' ',
|
||||
h('button', { class: 'small danger', onclick: () => { st.notify = st.notify.filter(x => x !== n); save(); } }, 'Delete')))))),
|
||||
section('Welcome message', 'Whispered to each player a few seconds after they join. {name}, {server} and {players} are filled in.',
|
||||
h('button', { onclick: async () => { const r = await ask('Welcome message', [{ name: 'enabled', label: 'Enabled', type: 'checkbox', value: w.enabled }, { name: 'message', label: 'Message', value: w.message }]); if (r) { st.welcome = r; save(); } } }, 'Edit'),
|
||||
h('p', null, w.enabled ? '“' + w.message + '”' : h('span', { class: 'muted' }, 'Off'))),
|
||||
section('Chat filter', 'Watches player chat for words you list. Players are warned, and after enough strikes kicked or banned.',
|
||||
h('button', { onclick: async () => { const r = await ask('Chat filter', [{ name: 'enabled', label: 'Enabled', type: 'checkbox', value: f.enabled }, { name: 'words', label: 'Words, one per line', type: 'lines', value: f.words }, { name: 'action', label: 'After the strikes', type: 'select', value: f.action, options: [{ v: 'warn', l: 'Only warn' }, { v: 'kick', l: 'Kick' }, { v: 'ban', l: 'Temporary ban' }] }, { name: 'strikes', label: 'Strikes before the action', type: 'number', value: f.strikes }, { name: 'ban_minutes', label: 'Ban length in minutes', type: 'number', value: f.ban_minutes }]); if (r) { st.chat_filter = r; save(); } } }, 'Edit'),
|
||||
h('p', null, f.enabled ? `${f.words.length} word(s) · ${f.action} after ${f.strikes} strikes` : h('span', { class: 'muted' }, 'Off'))),
|
||||
section('Macros', 'Named lists of console commands you can run from a server\'s Console tab.', h('button', { onclick: () => editMacro() }, 'Add'),
|
||||
table(['Name', 'Commands', 'Who', ''], st.macros.map(m => h('tr', null, h('td', null, m.name), h('td', { class: 'mono' }, m.commands.join(' ; ')), h('td', null, m.role),
|
||||
h('td', { class: 'actions' }, h('button', { class: 'small', onclick: () => editMacro(m) }, 'Edit'), ' ', h('button', { class: 'small danger', onclick: () => { st.macros = st.macros.filter(x => x !== m); save(); } }, 'Delete')))))),
|
||||
section('Users', 'People who can sign in. Viewers look, moderators handle players, admins run everything.', h('button', { onclick: () => editUser() }, 'Add'),
|
||||
table(['User', 'Role', ''], users.map(u => h('tr', null, h('td', null, u.name), h('td', null, u.role), h('td', { class: 'actions' }, h('button', { class: 'small', onclick: () => editUser(u) }, 'Edit'), ' ',
|
||||
h('button', { class: 'small danger', onclick: async () => { if (await confirmDlg('Delete ' + u.name + '?', '', 'Delete')) api('DELETE', '/api/users/' + encodeURIComponent(u.name)).then(load).catch(fail); } }, 'Delete')))))),
|
||||
section('API tokens', 'For scripts and monitoring. Prometheus can scrape /metrics with a viewer token.', h('button', { onclick: newToken }, 'Create'),
|
||||
table(['Name', 'Role', ''], tokens.map(t => h('tr', null, h('td', null, t.name), h('td', null, t.role), h('td', { class: 'actions' },
|
||||
h('button', { class: 'small danger', onclick: async () => { if (await confirmDlg('Delete token ' + t.name + '?', 'Anything using it stops working.', 'Delete')) api('DELETE', '/api/tokens/' + encodeURIComponent(t.name)).then(load).catch(fail); } }, 'Delete')))))));
|
||||
}
|
||||
const load = () => api('GET', '/api/settings').then(s => { st = s; st.notify ||= []; st.macros ||= []; st.jobs ||= []; st.chat_filter.words ||= []; return draw(); }).catch(fail);
|
||||
load();
|
||||
return { el: root };
|
||||
}
|
||||
|
||||
boot();
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Megalodon</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpath d='M2 22c6-2 9-9 14-14 1 5 4 8 14 14-6 0-9 2-12 6-2-3-6-5-16-6z' fill='%232a7fb5'/%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div id="toasts" aria-live="polite"></div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,93 @@
|
||||
:root {
|
||||
--bg: #f4f6f8; --panel: #ffffff; --text: #17212b; --muted: #5d6b79; --line: #dde3e9;
|
||||
--accent: #1f6fa8; --accent-text: #ffffff; --ok: #1c8a4b; --warn: #b36b00; --bad: #c4332b;
|
||||
--ok-bg: #e2f4ea; --warn-bg: #fdf0d9; --bad-bg: #fbe5e3; --chip: #eaeff3;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0f151b; --panel: #172029; --text: #e4ebf1; --muted: #8fa0af; --line: #2a3744;
|
||||
--accent: #4aa3de; --accent-text: #0b1218; --ok: #4cc07d; --warn: #e0a243; --bad: #ef6b62;
|
||||
--ok-bg: #153325; --warn-bg: #382b12; --bad-bg: #3b1d1b; --chip: #212d38;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
||||
a { color: var(--accent); }
|
||||
h1, h2, h3 { margin: 0 0 .5rem; line-height: 1.2; }
|
||||
h1 { font-size: 1.5rem; } h2 { font-size: 1.15rem; } h3 { font-size: 1rem; }
|
||||
.muted { color: var(--muted); } .small { font-size: .85rem; } .mono { font-family: ui-monospace, Consolas, monospace; font-size: .85rem; }
|
||||
.err { color: var(--bad); min-height: 1.2em; margin: .4rem 0 0; }
|
||||
|
||||
header.bar { display: flex; align-items: center; gap: 1rem; padding: .6rem 16px; background: var(--panel); border-bottom: 1px solid var(--line); flex-wrap: wrap; position: sticky; top: 0; z-index: 5; }
|
||||
header.bar .brand { font-weight: 700; font-size: 1.1rem; margin-right: .5rem; }
|
||||
header.bar nav { display: flex; gap: .25rem; flex-wrap: wrap; flex: 1; }
|
||||
header.bar nav a { padding: .35rem .7rem; border-radius: 6px; color: var(--text); text-decoration: none; }
|
||||
header.bar nav a.on { background: var(--chip); font-weight: 600; }
|
||||
main { max-width: 1120px; margin: 0 auto; padding: 16px; }
|
||||
|
||||
.grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); }
|
||||
.card, .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; }
|
||||
.card h2 { display: flex; justify-content: space-between; align-items: center; gap: .5rem; }
|
||||
.card canvas { width: 100%; height: 44px; display: block; margin: .6rem 0; }
|
||||
.row { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
|
||||
.row.end { justify-content: flex-end; } .row.between { justify-content: space-between; }
|
||||
.spacer { flex: 1; }
|
||||
.stack > * + * { margin-top: 14px; }
|
||||
|
||||
.pill { display: inline-block; padding: .1rem .55rem; border-radius: 99px; font-size: .78rem; font-weight: 600; background: var(--chip); color: var(--muted); }
|
||||
.pill.ok { background: var(--ok-bg); color: var(--ok); } .pill.bad { background: var(--bad-bg); color: var(--bad); } .pill.warn { background: var(--warn-bg); color: var(--warn); }
|
||||
.banner { padding: .55rem .8rem; border-radius: 8px; background: var(--warn-bg); color: var(--warn); margin: .6rem 0; display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; }
|
||||
.banner.bad { background: var(--bad-bg); color: var(--bad); }
|
||||
|
||||
button, .btn { font: inherit; padding: .35rem .8rem; border-radius: 7px; border: 1px solid var(--line); background: var(--panel); color: var(--text); cursor: pointer; }
|
||||
button:hover { border-color: var(--accent); }
|
||||
button.primary { background: var(--accent); color: var(--accent-text); border-color: var(--accent); font-weight: 600; }
|
||||
button.danger { color: var(--bad); } button.danger:hover { border-color: var(--bad); }
|
||||
button.small { padding: .15rem .5rem; font-size: .82rem; }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
input, select, textarea { font: inherit; color: var(--text); background: var(--bg); border: 1px solid var(--line); border-radius: 7px; padding: .4rem .55rem; max-width: 100%; }
|
||||
input:focus, select:focus, textarea:focus, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
input[type=checkbox] { width: 1.1rem; height: 1.1rem; accent-color: var(--accent); }
|
||||
textarea { width: 100%; min-height: 5rem; font-family: ui-monospace, Consolas, monospace; font-size: .88rem; }
|
||||
|
||||
.tabs { display: flex; gap: .25rem; border-bottom: 1px solid var(--line); margin: 1rem 0; flex-wrap: wrap; }
|
||||
.tabs a { padding: .45rem .9rem; text-decoration: none; color: var(--muted); border-bottom: 2px solid transparent; }
|
||||
.tabs a.on { color: var(--text); border-color: var(--accent); font-weight: 600; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: .45rem .6rem; border-bottom: 1px solid var(--line); vertical-align: middle; }
|
||||
th { font-size: .78rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
|
||||
td.actions { white-space: nowrap; text-align: right; }
|
||||
.tablewrap { overflow-x: auto; }
|
||||
.empty { padding: 1.2rem; text-align: center; color: var(--muted); }
|
||||
|
||||
.log { max-height: 55vh; overflow-y: auto; font-size: .9rem; }
|
||||
.ev { display: flex; gap: .6rem; padding: .25rem 0; border-bottom: 1px solid var(--line); align-items: baseline; }
|
||||
.ev time { color: var(--muted); font-size: .8rem; white-space: nowrap; min-width: 4.8rem; }
|
||||
.ev .tag { font-size: .7rem; text-transform: uppercase; padding: 0 .4rem; border-radius: 4px; background: var(--chip); color: var(--muted); white-space: nowrap; }
|
||||
.ev .tag.join, .ev .tag.online, .ev .tag.start { background: var(--ok-bg); color: var(--ok); }
|
||||
.ev .tag.leave { background: var(--chip); }
|
||||
.ev .tag.offline, .ev .tag.ban, .ev .tag.kick, .ev .tag.error { background: var(--bad-bg); color: var(--bad); }
|
||||
.ev .tag.restart, .ev .tag.filter, .ev .tag.watch, .ev .tag.schedule { background: var(--warn-bg); color: var(--warn); }
|
||||
.ev .txt { overflow-wrap: anywhere; }
|
||||
.chat .who { font-weight: 600; } .chat .srv { color: var(--accent); }
|
||||
pre.out { background: var(--bg); border: 1px solid var(--line); border-radius: 8px; padding: .7rem; min-height: 12rem; max-height: 50vh; overflow: auto; margin: 0 0 .6rem; white-space: pre-wrap; overflow-wrap: anywhere; font-size: .85rem; }
|
||||
pre.out .cmd { color: var(--accent); } pre.out .bad { color: var(--bad); }
|
||||
|
||||
dialog { border: 1px solid var(--line); border-radius: 12px; background: var(--panel); color: var(--text); padding: 18px 20px; width: min(520px, calc(100vw - 32px)); }
|
||||
dialog::backdrop { background: rgba(0,0,0,.45); }
|
||||
dialog label { display: block; margin: .6rem 0 .15rem; font-size: .85rem; color: var(--muted); }
|
||||
dialog label.check { display: flex; gap: .5rem; align-items: center; color: var(--text); font-size: .95rem; }
|
||||
dialog input[type=text], dialog input[type=number], dialog input[type=password], dialog select { width: 100%; }
|
||||
dialog .multi { display: flex; flex-wrap: wrap; gap: .3rem .9rem; }
|
||||
dialog .multi label { display: flex; gap: .35rem; align-items: center; margin: 0; color: var(--text); font-size: .9rem; }
|
||||
dialog .buttons { display: flex; justify-content: flex-end; gap: .5rem; margin-top: 1rem; }
|
||||
|
||||
#toasts { position: fixed; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: .5rem; z-index: 50; max-width: min(420px, calc(100vw - 32px)); }
|
||||
.toast { background: var(--text); color: var(--bg); padding: .6rem .9rem; border-radius: 8px; box-shadow: 0 4px 16px rgba(0,0,0,.25); overflow-wrap: anywhere; }
|
||||
.toast.bad { background: var(--bad); color: #fff; }
|
||||
|
||||
.login { max-width: 340px; margin: 14vh auto 0; }
|
||||
.login form > * { display: block; width: 100%; margin-top: .6rem; }
|
||||
@media (max-width: 640px) { main { padding: 10px; } td.actions { white-space: normal; } }
|
||||
.grow { flex: 1; min-width: 0; } .mt { margin-top: .7rem; }
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user