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

+198
View File
@@ -0,0 +1,198 @@
// Package ark knows the text ARK: Survival Evolved's admin console speaks: the commands to send,
// and how to read what comes back from ListPlayers and GetChat.
package ark
import (
"errors"
"fmt"
"regexp"
"strings"
"time"
)
// NoResponse is what the server answers to a command that has nothing to say.
const NoResponse = "Server received, But no response!!"
// Player is one person connected to a server.
type Player struct {
Name string `json:"name"`
SteamID string `json:"steam_id"`
}
var playerLine = regexp.MustCompile(`^\s*\d+\.\s+(.*),\s*(\d{15,20})\s*$`)
// ParsePlayers reads the reply to ListPlayers: "0. Name, 76561198000000000" per line. Names may
// contain commas, so the Steam ID is taken from the end of the line.
func ParsePlayers(s string) []Player {
var out []Player
for _, line := range strings.Split(s, "\n") {
if m := playerLine.FindStringSubmatch(strings.TrimRight(line, "\r\x00 ")); m != nil {
out = append(out, Player{Name: strings.TrimSpace(m[1]), SteamID: m[2]})
}
}
return out
}
// ChatLine is one line from GetChat.
type ChatLine struct {
Sender string `json:"sender"`
Character string `json:"character,omitempty"`
Message string `json:"message"`
Server bool `json:"server"` // the server itself speaking (broadcasts, admin messages)
}
var timestamp = regexp.MustCompile(`^\[\d{4}\.\d\d\.\d\d[-_]\d\d\.\d\d\.\d\d\]\s*`)
// ParseChat reads the reply to GetChat. Player lines look like "Steam Name (Character): hello".
func ParseChat(s string) []ChatLine {
var out []ChatLine
for _, line := range strings.Split(s, "\n") {
line = strings.TrimSpace(strings.Trim(line, "\x00"))
line = timestamp.ReplaceAllString(line, "")
if line == "" || strings.HasPrefix(line, NoResponse) {
continue
}
cl := ChatLine{Message: line, Server: true}
if i := strings.Index(line, ": "); i > 0 {
who := line[:i]
cl.Message = line[i+2:]
cl.Sender = who
if j := strings.LastIndex(who, " ("); j > 0 && strings.HasSuffix(who, ")") {
cl.Sender = who[:j]
cl.Character = who[j+2 : len(who)-1]
}
cl.Server = cl.Sender == "SERVER"
}
out = append(out, cl)
}
return out
}
// IsEmptyReply reports whether a reply carries nothing worth showing.
func IsEmptyReply(s string) bool {
s = strings.TrimSpace(s)
return s == "" || strings.HasPrefix(s, NoResponse)
}
// ValidSteamID says whether s is a 64-bit Steam ID: digits only, so it can never smuggle anything
// else into a command.
func ValidSteamID(s string) bool {
if len(s) < 15 || len(s) > 20 {
return false
}
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return true
}
// Clean makes text safe to put in a command: one line, no control characters, no double quotes.
func Clean(s string) string {
var sb strings.Builder
for _, r := range s {
switch {
case r == '"':
sb.WriteRune('\'')
case r == '\n' || r == '\r' || r == '\t':
sb.WriteRune(' ')
case r < 0x20 || r == 0x7f:
default:
sb.WriteRune(r)
}
}
out := strings.TrimSpace(sb.String())
if r := []rune(out); len(r) > 400 {
out = string(r[:400])
}
return out
}
// Command lines with fixed text.
const (
ListPlayers = "ListPlayers"
GetChat = "GetChat"
SaveWorld = "SaveWorld"
DoExit = "DoExit"
DestroyWildDinos = "DestroyWildDinos"
)
// Broadcast shows a message in the middle of every player's screen.
func Broadcast(msg string) (string, error) { return withText("Broadcast", msg) }
// ServerChat posts a line to global chat as SERVER.
func ServerChat(msg string) (string, error) { return withText("ServerChat", msg) }
// SetMOTD changes the message of the day.
func SetMOTD(msg string) (string, error) { return withText("SetMessageOfTheDay", msg) }
func withText(cmd, msg string) (string, error) {
msg = Clean(msg)
if msg == "" {
return "", errors.New("the message is empty")
}
return cmd + " " + msg, nil
}
// ServerChatTo whispers a message to one player.
func ServerChatTo(steamID, msg string) (string, error) {
if !ValidSteamID(steamID) {
return "", errors.New("not a Steam ID")
}
msg = Clean(msg)
if msg == "" {
return "", errors.New("the message is empty")
}
return fmt.Sprintf(`ServerChatTo "%s" %s`, steamID, msg), nil
}
// ForPlayer builds a command that takes only a Steam ID: KickPlayer, BanPlayer, UnbanPlayer,
// AllowPlayerToJoinNoCheck, DisallowPlayerToJoinNoCheck.
func ForPlayer(cmd, steamID string) (string, error) {
if !ValidSteamID(steamID) {
return "", errors.New("not a Steam ID")
}
return cmd + " " + steamID, nil
}
var timeOfDay = regexp.MustCompile(`^([01]?\d|2[0-3]):[0-5]\d(:[0-5]\d)?$`)
// SetTimeOfDay sets the in-game clock to HH:MM or HH:MM:SS.
func SetTimeOfDay(t string) (string, error) {
t = strings.TrimSpace(t)
if !timeOfDay.MatchString(t) {
return "", errors.New("time must look like 06:30")
}
if strings.Count(t, ":") == 1 {
t += ":00"
}
return "SetTimeOfDay " + t, nil
}
// Reference lists the console commands worth offering as suggestions in a UI.
var Reference = []string{
"ListPlayers", "GetChat", "SaveWorld", "DoExit", "Broadcast ", "ServerChat ", `ServerChatTo "<steamid>" `,
`ServerChatToPlayer "<name>" `, "KickPlayer <steamid>", "BanPlayer <steamid>", "UnbanPlayer <steamid>",
"AllowPlayerToJoinNoCheck <steamid>", "DisallowPlayerToJoinNoCheck <steamid>", "DestroyWildDinos",
"SetTimeOfDay 12:00", "SetMessageOfTheDay ", "ShowMessageOfTheDay", "GiveItemToPlayer <playerid> <blueprint> 1 0 0",
"GiveExpToPlayer <playerid> 1000 0 0", "RenamePlayer \"<old>\" <new>", "RenameTribe \"<old>\" <new>",
}
// Duration formats d the way a player would say it ("10 minutes", "30 seconds").
func Duration(d time.Duration) string {
d = d.Round(time.Second)
switch {
case d >= 2*time.Hour:
return fmt.Sprintf("%d hours", int(d.Hours()))
case d >= time.Hour:
return "1 hour"
case d >= 2*time.Minute:
return fmt.Sprintf("%d minutes", int(d.Minutes()))
case d >= time.Minute:
return "1 minute"
case d == time.Second:
return "1 second"
}
return fmt.Sprintf("%d seconds", int(d.Seconds()))
}
+107
View File
@@ -0,0 +1,107 @@
package ark
import (
"net"
"testing"
"time"
)
func TestParsePlayers(t *testing.T) {
got := ParsePlayers("0. Alex, the Tamer, 76561198000000001\r\n1. Bo, 76561198000000002 \n")
if len(got) != 2 || got[0].Name != "Alex, the Tamer" || got[0].SteamID != "76561198000000001" || got[1].Name != "Bo" {
t.Fatalf("got %+v", got)
}
if n := len(ParsePlayers("No Players Connected \n")); n != 0 {
t.Fatalf("expected nobody, got %d", n)
}
}
func TestParseChat(t *testing.T) {
got := ParseChat("Alex (Rex Rider): hello: world\nSERVER: restarting soon\nsomething odd\n" + NoResponse + "\n")
if len(got) != 3 {
t.Fatalf("got %+v", got)
}
if a := got[0]; a.Sender != "Alex" || a.Character != "Rex Rider" || a.Message != "hello: world" || a.Server {
t.Fatalf("player line: %+v", a)
}
if !got[1].Server || got[1].Message != "restarting soon" {
t.Fatalf("server line: %+v", got[1])
}
if !got[2].Server || got[2].Message != "something odd" {
t.Fatalf("odd line: %+v", got[2])
}
if n := len(ParseChat("[2026.01.02-03.04.05] Bo: hi")); n != 1 {
t.Fatalf("timestamped line not parsed")
}
}
func TestCommandsAreSafe(t *testing.T) {
if _, err := ForPlayer("KickPlayer", "123; DoExit"); err == nil {
t.Fatal("a non-numeric id was accepted")
}
c, err := ForPlayer("KickPlayer", "76561198000000001")
if err != nil || c != "KickPlayer 76561198000000001" {
t.Fatalf("got %q, %v", c, err)
}
c, err = Broadcast("hi \"there\"\nDoExit")
if err != nil || c != "Broadcast hi 'there' DoExit" {
t.Fatalf("got %q, %v", c, err)
}
if _, err := Broadcast(" \n "); err == nil {
t.Fatal("empty message accepted")
}
if c, _ := SetTimeOfDay("6:30"); c != "SetTimeOfDay 6:30:00" {
t.Fatalf("got %q", c)
}
if _, err := SetTimeOfDay("25:00"); err == nil {
t.Fatal("bad time accepted")
}
if c, _ := ServerChatTo("76561198000000001", "hello"); c != `ServerChatTo "76561198000000001" hello` {
t.Fatalf("got %q", c)
}
}
func TestDuration(t *testing.T) {
for d, want := range map[time.Duration]string{
10 * time.Second: "10 seconds", time.Minute: "1 minute", 5 * time.Minute: "5 minutes", 3 * time.Hour: "3 hours",
} {
if got := Duration(d); got != want {
t.Errorf("%v: got %q, want %q", d, got, want)
}
}
}
// A fake game server that asks for a challenge first, as current servers do.
func TestQuery(t *testing.T) {
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer pc.Close()
go func() {
buf := make([]byte, 100)
for {
n, from, err := pc.ReadFrom(buf)
if err != nil {
return
}
if n == len(a2sRequest) {
pc.WriteTo([]byte{0xff, 0xff, 0xff, 0xff, 0x41, 1, 2, 3, 4}, from)
continue
}
reply := []byte{0xff, 0xff, 0xff, 0xff, 0x49, 17}
for _, s := range []string{"Test Island - (v350.2)", "TheIsland", "ark_survival_evolved", "ARK: Survival Evolved"} {
reply = append(append(reply, s...), 0)
}
reply = append(reply, 0, 0, 3, 20, 0, 'd', 'w', 0, 1, '1', '.', '0', 0)
pc.WriteTo(reply, from)
}
}()
in, err := Query(pc.LocalAddr().String(), 2*time.Second)
if err != nil {
t.Fatal(err)
}
if in.Name != "Test Island - (v350.2)" || in.Map != "TheIsland" || in.Players != 3 || in.MaxPlayers != 20 || in.Version != "350.2" {
t.Fatalf("got %+v", in)
}
}
+92
View File
@@ -0,0 +1,92 @@
package ark
import (
"bytes"
"errors"
"net"
"regexp"
"time"
)
// Info is what a server tells the Steam server browser (A2S_INFO).
type Info struct {
Name string `json:"name"`
Map string `json:"map"`
Game string `json:"game"`
Version string `json:"version,omitempty"`
Players int `json:"players"`
MaxPlayers int `json:"max_players"`
PingMS int64 `json:"ping_ms"`
}
var a2sRequest = []byte("\xff\xff\xff\xffTSource Engine Query\x00")
// ARK puts its version in the server name: "My Server - (v349.7)".
var nameVersion = regexp.MustCompile(`\(v(\d+(?:\.\d+)*)\)`)
// Query asks the game's query port (UDP, usually 27015) who is on the server, without needing the
// RCON password.
func Query(addr string, timeout time.Duration) (*Info, error) {
conn, err := net.Dial("udp", addr)
if err != nil {
return nil, err
}
defer conn.Close()
start := time.Now()
conn.SetDeadline(start.Add(timeout))
if _, err := conn.Write(a2sRequest); err != nil {
return nil, err
}
buf := make([]byte, 1400)
n, err := conn.Read(buf)
if err != nil {
return nil, err
}
if n >= 9 && buf[4] == 0x41 { // challenge: ask again with the number it gave
req := append(append([]byte{}, a2sRequest...), buf[5:9]...)
if _, err := conn.Write(req); err != nil {
return nil, err
}
if n, err = conn.Read(buf); err != nil {
return nil, err
}
}
info, err := parseInfo(buf[:n])
if err != nil {
return nil, err
}
info.PingMS = time.Since(start).Milliseconds()
return info, nil
}
func parseInfo(b []byte) (*Info, error) {
if len(b) < 6 || !bytes.Equal(b[:4], []byte{0xff, 0xff, 0xff, 0xff}) || b[4] != 0x49 {
return nil, errors.New("not an A2S_INFO reply")
}
r := b[6:] // skip the header and the protocol byte
str := func() string {
i := bytes.IndexByte(r, 0)
if i < 0 {
s := string(r)
r = nil
return s
}
s := string(r[:i])
r = r[i+1:]
return s
}
var in Info
in.Name, in.Map = str(), str()
str() // folder
in.Game = str()
if len(r) < 9 {
return nil, errors.New("short A2S_INFO reply")
}
in.Players, in.MaxPlayers = int(r[2]), int(r[3])
r = r[9:] // app id (2), players, max, bots, type, environment, visibility, VAC
in.Version = str()
if m := nameVersion.FindStringSubmatch(in.Name); m != nil {
in.Version = m[1]
}
return &in, nil
}
+445
View File
@@ -0,0 +1,445 @@
// Package config holds Megalodon's settings: a single JSON file that the web UI can also edit.
package config
import (
"crypto/pbkdf2"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
// Role says what a user may do. Each role includes everything the one before it can.
type Role string
const (
Viewer Role = "viewer" // look at servers, players, chat and events
Moderator Role = "moderator" // plus talk to players, kick, ban, save
Admin Role = "admin" // plus the raw console, restarts, schedules, settings and users
)
// Rank orders roles; an unknown role ranks 0 and may do nothing.
func (r Role) Rank() int {
switch r {
case Viewer:
return 1
case Moderator:
return 2
case Admin:
return 3
}
return 0
}
// Allows reports whether this role is at least min.
func (r Role) Allows(min Role) bool { return r.Rank() >= min.Rank() && r.Rank() > 0 }
type Config struct {
Listen string `json:"listen"` // web address, default 127.0.0.1:8480
DataDir string `json:"data_dir"` // events, players and bans live here
TrustProxy bool `json:"trust_proxy"` // believe X-Forwarded-For / -Proto (when behind a reverse proxy)
CookieSecure bool `json:"cookie_secure"` // always mark the session cookie Secure
PollSeconds int `json:"poll_seconds"` // how often each server is asked who is online
ChatSeconds int `json:"chat_seconds"` // how often chat is collected
Servers []Server `json:"servers"`
Users []User `json:"users"`
Tokens []Token `json:"tokens"`
Jobs []Job `json:"jobs"`
Macros []Macro `json:"macros"`
Notify []Notify `json:"notify"`
Welcome Welcome `json:"welcome"`
ChatFilter ChatFilter `json:"chat_filter"`
}
// Server is one ARK server (or one map of a cluster).
type Server struct {
ID string `json:"id"`
Name string `json:"name"`
Host string `json:"host"`
RCONPort int `json:"rcon_port"`
Password string `json:"password,omitempty"`
QueryPort int `json:"query_port"` // UDP, for the server browser info; 0 turns it off
// Optional, for when Megalodon runs on the same machine as the server, or can reach whatever
// starts it (docker, systemctl, a script). Run through the shell, must return promptly.
StartCommand string `json:"start_command,omitempty"`
AutoStart bool `json:"auto_start,omitempty"` // start it when it has been down for a while
AutoStartSec int `json:"auto_start_after,omitempty"` // seconds, default 120
}
type User struct {
Name string `json:"name"`
Role Role `json:"role"`
Hash string `json:"hash"`
}
type Token struct {
Name string `json:"name"`
Role Role `json:"role"`
Hash string `json:"hash"` // hex SHA-256 of the token
}
// Job is something done on a schedule.
type Job struct {
ID string `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
Servers []string `json:"servers"` // ids; empty means every server
Action string `json:"action"` // save, broadcast, restart, command, destroy_wild_dinos, motd
Every string `json:"every"` // a Go duration such as "30m"; or
At []string `json:"at"` // times of day as "HH:MM" (server local time)
Days []string `json:"days"` // limit At to these days ("mon".."sun"); empty means every day
Message string `json:"message"` // for broadcast and motd
Command string `json:"command"` // for command
Countdown string `json:"countdown"` // for restart, such as "10m"
Reason string `json:"reason"` // shown to players with a restart
}
// Macro is a named list of console commands that can be run in one click.
type Macro struct {
Name string `json:"name"`
Commands []string `json:"commands"`
Role Role `json:"role"` // who may run it; default admin
}
// Notify sends events somewhere else.
type Notify struct {
Name string `json:"name"`
Kind string `json:"kind"` // discord, ntfy or json
URL string `json:"url"` //
Events []string `json:"events"` // event types; empty means the default set
Servers []string `json:"servers"`
}
type Welcome struct {
Enabled bool `json:"enabled"`
Message string `json:"message"` // {name}, {server} and {players} are filled in
}
type ChatFilter struct {
Enabled bool `json:"enabled"`
Words []string `json:"words"`
Action string `json:"action"` // warn, kick or ban
Strikes int `json:"strikes"` // hits before the action; default 3
BanMinutes int `json:"ban_minutes"` // length of a ban made by the filter; default 60
}
// JobActions are the things a Job may do.
var JobActions = map[string]bool{"save": true, "broadcast": true, "restart": true, "command": true, "destroy_wild_dinos": true, "motd": true}
var (
idPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,31}$`)
clockRegex = regexp.MustCompile(`^([01]\d|2[0-3]):[0-5]\d$`)
weekdays = map[string]bool{"mon": true, "tue": true, "wed": true, "thu": true, "fri": true, "sat": true, "sun": true}
)
// Defaults fills in anything left blank.
func (c *Config) Defaults() {
if c.Listen == "" {
c.Listen = "127.0.0.1:8480"
}
if c.DataDir == "" {
c.DataDir = "data"
}
if c.PollSeconds <= 0 {
c.PollSeconds = 10
}
if c.ChatSeconds <= 0 {
c.ChatSeconds = 3
}
if c.ChatFilter.Action == "" {
c.ChatFilter.Action = "warn"
}
if c.ChatFilter.Strikes <= 0 {
c.ChatFilter.Strikes = 3
}
if c.ChatFilter.BanMinutes <= 0 {
c.ChatFilter.BanMinutes = 60
}
if c.Welcome.Message == "" {
c.Welcome.Message = "Welcome to {server}, {name}! {players} online."
}
for i := range c.Servers {
s := &c.Servers[i]
if s.Name == "" {
s.Name = s.ID
}
if s.RCONPort == 0 {
s.RCONPort = 27020
}
}
for i := range c.Macros {
if c.Macros[i].Role == "" {
c.Macros[i].Role = Admin
}
}
}
// Validate checks everything a person could have mistyped, so a bad edit is refused instead of
// taking effect.
func (c *Config) Validate() error {
seen := map[string]bool{}
for _, s := range c.Servers {
if !idPattern.MatchString(s.ID) {
return fmt.Errorf("server id %q: use lowercase letters, digits, - and _", s.ID)
}
if seen[s.ID] {
return fmt.Errorf("server id %q is used twice", s.ID)
}
seen[s.ID] = true
if strings.TrimSpace(s.Host) == "" {
return fmt.Errorf("server %s needs a host", s.ID)
}
if s.RCONPort < 1 || s.RCONPort > 65535 || s.QueryPort < 0 || s.QueryPort > 65535 {
return fmt.Errorf("server %s: ports must be 1-65535", s.ID)
}
}
names := map[string]bool{}
for _, u := range c.Users {
if u.Name == "" || names[u.Name] {
return fmt.Errorf("user %q: the name is empty or used twice", u.Name)
}
names[u.Name] = true
if u.Role.Rank() == 0 {
return fmt.Errorf("user %s: role must be viewer, moderator or admin", u.Name)
}
}
for _, t := range c.Tokens {
if t.Name == "" || t.Role.Rank() == 0 || t.Hash == "" {
return fmt.Errorf("token %q is incomplete", t.Name)
}
}
jobIDs := map[string]bool{}
for _, j := range c.Jobs {
if err := c.validJob(j, seen, jobIDs); err != nil {
return fmt.Errorf("job %q: %w", j.Name, err)
}
}
for _, m := range c.Macros {
if strings.TrimSpace(m.Name) == "" || len(m.Commands) == 0 {
return errors.New("a macro needs a name and at least one command")
}
if m.Role.Rank() == 0 {
return fmt.Errorf("macro %s: unknown role", m.Name)
}
}
for _, n := range c.Notify {
u, err := url.Parse(n.URL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return fmt.Errorf("notification %q needs an http(s) URL", n.Name)
}
switch n.Kind {
case "discord", "ntfy", "json":
default:
return fmt.Errorf("notification %q: kind must be discord, ntfy or json", n.Name)
}
}
if a := c.ChatFilter.Action; a != "warn" && a != "kick" && a != "ban" {
return errors.New("chat filter action must be warn, kick or ban")
}
return nil
}
func (c *Config) validJob(j Job, servers, ids map[string]bool) error {
if j.ID == "" || ids[j.ID] {
return errors.New("missing or repeated id")
}
ids[j.ID] = true
if strings.TrimSpace(j.Name) == "" {
return errors.New("needs a name")
}
if !JobActions[j.Action] {
return fmt.Errorf("unknown action %q", j.Action)
}
for _, s := range j.Servers {
if !servers[s] {
return fmt.Errorf("unknown server %q", s)
}
}
if (j.Every == "") == (len(j.At) == 0) {
return errors.New("set either every or at")
}
if j.Every != "" {
d, err := time.ParseDuration(j.Every)
if err != nil || d < time.Minute {
return errors.New("every must be a duration of at least 1m, such as 30m or 6h")
}
}
for _, t := range j.At {
if !clockRegex.MatchString(t) {
return fmt.Errorf("time %q must look like 04:30", t)
}
}
for _, d := range j.Days {
if !weekdays[d] {
return fmt.Errorf("day %q must be mon, tue, ... sun", d)
}
}
if j.Countdown != "" {
if _, err := time.ParseDuration(j.Countdown); err != nil {
return errors.New("countdown must be a duration such as 10m")
}
}
switch j.Action {
case "broadcast", "motd":
if strings.TrimSpace(j.Message) == "" {
return errors.New("needs a message")
}
case "command":
if strings.TrimSpace(j.Command) == "" {
return errors.New("needs a command")
}
}
return nil
}
// Holder keeps the live configuration and writes every change to disk.
type Holder struct {
path string
mu sync.RWMutex
cfg Config
}
// Load reads the file. A missing file gives the defaults (and is created by the first Update).
func Load(path string) (*Holder, error) {
h := &Holder{path: path}
b, err := os.ReadFile(path)
switch {
case errors.Is(err, os.ErrNotExist):
case err != nil:
return nil, err
default:
if err := json.Unmarshal(b, &h.cfg); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
}
h.cfg.Defaults()
if err := h.cfg.Validate(); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return h, nil
}
func (h *Holder) Path() string { return h.path }
// Exists says whether the file is on disk yet.
func (h *Holder) Exists() bool { _, err := os.Stat(h.path); return err == nil }
// Get returns a private copy; change it freely.
func (h *Holder) Get() Config {
h.mu.RLock()
defer h.mu.RUnlock()
return clone(h.cfg)
}
func clone(c Config) Config {
b, _ := json.Marshal(c)
var out Config
json.Unmarshal(b, &out)
return out
}
// Update applies fn to a copy, validates it, saves it and only then makes it current.
func (h *Holder) Update(fn func(*Config) error) error {
h.mu.Lock()
defer h.mu.Unlock()
c := clone(h.cfg)
if err := fn(&c); err != nil {
return err
}
c.Defaults()
if err := c.Validate(); err != nil {
return err
}
if err := save(h.path, c); err != nil {
return err
}
h.cfg = c
return nil
}
func save(path string, c Config) error {
b, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
if dir := filepath.Dir(path); dir != "." {
os.MkdirAll(dir, 0o755)
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
// ---- passwords and tokens ----
const pbkdf2Rounds = 600_000
// HashPassword returns "pbkdf2$rounds$salt$key" for storing in a User.
func HashPassword(password string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
key, err := pbkdf2.Key(sha256.New, password, salt, pbkdf2Rounds, 32)
if err != nil {
return "", err
}
return fmt.Sprintf("pbkdf2$%d$%s$%s", pbkdf2Rounds, base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil
}
// CheckPassword compares in constant time. An unparseable hash never matches.
func CheckPassword(hash, password string) bool {
parts := strings.Split(hash, "$")
if len(parts) != 4 || parts[0] != "pbkdf2" {
return false
}
rounds, err := strconv.Atoi(parts[1])
if err != nil || rounds < 1 || rounds > 10_000_000 {
return false
}
salt, err1 := base64.RawStdEncoding.DecodeString(parts[2])
want, err2 := base64.RawStdEncoding.DecodeString(parts[3])
if err1 != nil || err2 != nil {
return false
}
got, err := pbkdf2.Key(sha256.New, password, salt, rounds, len(want))
return err == nil && subtle.ConstantTimeCompare(got, want) == 1
}
// NewToken makes an API token and the hash to store for it. The token itself is shown once.
func NewToken() (token, hash string, err error) {
b := make([]byte, 32)
if _, err = rand.Read(b); err != nil {
return
}
token = "mgl_" + base64.RawURLEncoding.EncodeToString(b)
return token, HashToken(token), nil
}
func HashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// RandomPassword makes a password for a first-run admin.
func RandomPassword() string {
b := make([]byte, 12)
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
+91
View File
@@ -0,0 +1,91 @@
package config
import (
"path/filepath"
"strings"
"testing"
)
func TestPasswordRoundTrip(t *testing.T) {
h, err := HashPassword("correct horse")
if err != nil {
t.Fatal(err)
}
if !CheckPassword(h, "correct horse") || CheckPassword(h, "wrong") || CheckPassword("garbage", "x") {
t.Fatal("password check is wrong")
}
}
func TestRoles(t *testing.T) {
if !Admin.Allows(Moderator) || !Moderator.Allows(Viewer) || Viewer.Allows(Moderator) || Role("").Allows(Viewer) || Role("root").Allows(Viewer) {
t.Fatal("role ordering is wrong")
}
}
func TestValidate(t *testing.T) {
good := Config{Servers: []Server{{ID: "island", Host: "10.0.0.2", RCONPort: 27020}}}
good.Defaults()
if err := good.Validate(); err != nil {
t.Fatal(err)
}
cases := map[string]func(c *Config){
"bad id": func(c *Config) { c.Servers[0].ID = "The Island" },
"duplicate id": func(c *Config) { c.Servers = append(c.Servers, c.Servers[0]) },
"no host": func(c *Config) { c.Servers[0].Host = "" },
"bad port": func(c *Config) { c.Servers[0].RCONPort = 70000 },
"bad user role": func(c *Config) { c.Users = []User{{Name: "a", Role: "root"}} },
"job no timing": func(c *Config) { c.Jobs = []Job{{ID: "j", Name: "j", Action: "save"}} },
"job both": func(c *Config) {
c.Jobs = []Job{{ID: "j", Name: "j", Action: "save", Every: "1h", At: []string{"04:00"}}}
},
"job too often": func(c *Config) { c.Jobs = []Job{{ID: "j", Name: "j", Action: "save", Every: "5s"}} },
"job bad time": func(c *Config) { c.Jobs = []Job{{ID: "j", Name: "j", Action: "save", At: []string{"4am"}}} },
"job bad server": func(c *Config) {
c.Jobs = []Job{{ID: "j", Name: "j", Action: "save", Every: "1h", Servers: []string{"nope"}}}
},
"job no message": func(c *Config) { c.Jobs = []Job{{ID: "j", Name: "j", Action: "broadcast", Every: "1h"}} },
"notify bad url": func(c *Config) { c.Notify = []Notify{{Name: "n", Kind: "discord", URL: "javascript:alert(1)"}} },
"filter action": func(c *Config) { c.ChatFilter.Action = "nuke" },
}
for name, mutate := range cases {
c := clone(good)
mutate(&c)
if err := c.Validate(); err == nil {
t.Errorf("%s: expected an error", name)
}
}
}
func TestUpdatePersistsAndRejects(t *testing.T) {
path := filepath.Join(t.TempDir(), "megalodon.json")
h, err := Load(path)
if err != nil {
t.Fatal(err)
}
if h.Exists() {
t.Fatal("file should not exist before the first update")
}
err = h.Update(func(c *Config) error {
c.Servers = append(c.Servers, Server{ID: "island", Host: "ark.lan", Password: "secret"})
return nil
})
if err != nil {
t.Fatal(err)
}
if err := h.Update(func(c *Config) error { c.Servers[0].ID = "BAD ID"; return nil }); err == nil || !strings.Contains(err.Error(), "server id") {
t.Fatalf("invalid update accepted: %v", err)
}
h2, err := Load(path)
if err != nil {
t.Fatal(err)
}
c := h2.Get()
if len(c.Servers) != 1 || c.Servers[0].ID != "island" || c.Servers[0].Password != "secret" || c.Servers[0].RCONPort != 27020 {
t.Fatalf("reloaded config is wrong: %+v", c.Servers)
}
// Get returns a copy.
c.Servers[0].Host = "changed"
if h2.Get().Servers[0].Host != "ark.lan" {
t.Fatal("Get leaked internal state")
}
}
+177
View File
@@ -0,0 +1,177 @@
package manager
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// Event is one thing that happened: a player joined, a server went down, someone ran a command.
type Event struct {
ID int64 `json:"id"`
Time time.Time `json:"time"`
Server string `json:"server,omitempty"`
Type string `json:"type"` // online, offline, join, leave, chat, audit, restart, start, ban, unban, kick, filter, watch, schedule, error
Actor string `json:"actor,omitempty"`
Message string `json:"message"`
Detail string `json:"detail,omitempty"`
SteamID string `json:"steam_id,omitempty"`
}
const (
ringMax = 3000
rotateAfter = 20 << 20
)
// Hub records events (in memory and in a JSON-lines file) and hands them to anyone listening.
type Hub struct {
mu sync.Mutex
seq int64
ring []Event
subs map[chan Event]struct{}
path string
f *os.File
written int64
}
// NewHub opens dir/events.jsonl, loading the most recent events from it.
func NewHub(dir string) (*Hub, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
h := &Hub{path: filepath.Join(dir, "events.jsonl"), subs: map[chan Event]struct{}{}}
if f, err := os.Open(h.path); err == nil {
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 64<<10), 1<<20)
for sc.Scan() {
var e Event
if json.Unmarshal(sc.Bytes(), &e) == nil && e.ID > 0 {
h.ring = append(h.ring, e)
if len(h.ring) > ringMax {
h.ring = h.ring[len(h.ring)-ringMax:]
}
if e.ID > h.seq {
h.seq = e.ID
}
}
}
f.Close()
}
if err := h.open(); err != nil {
return nil, err
}
return h, nil
}
func (h *Hub) open() error {
f, err := os.OpenFile(h.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return err
}
st, _ := f.Stat()
if st != nil {
h.written = st.Size()
}
h.f = f
return nil
}
func (h *Hub) Close() {
h.mu.Lock()
defer h.mu.Unlock()
if h.f != nil {
h.f.Close()
h.f = nil
}
}
// Publish stamps and stores an event and returns it as stored.
func (h *Hub) Publish(e Event) Event {
h.mu.Lock()
defer h.mu.Unlock()
h.seq++
e.ID = h.seq
if e.Time.IsZero() {
e.Time = time.Now()
}
h.ring = append(h.ring, e)
if len(h.ring) > ringMax {
h.ring = append([]Event(nil), h.ring[len(h.ring)-ringMax:]...)
}
if h.f != nil {
if b, err := json.Marshal(e); err == nil {
n, _ := h.f.Write(append(b, '\n'))
h.written += int64(n)
if h.written > rotateAfter {
h.f.Close()
os.Rename(h.path, h.path+".1")
h.open()
}
}
}
for ch := range h.subs {
select {
case ch <- e:
default: // a slow listener misses events rather than holding everyone up
}
}
return e
}
// Subscribe returns a channel of new events and a function that stops it.
func (h *Hub) Subscribe() (<-chan Event, func()) {
ch := make(chan Event, 64)
h.mu.Lock()
h.subs[ch] = struct{}{}
h.mu.Unlock()
return ch, func() {
h.mu.Lock()
delete(h.subs, ch)
h.mu.Unlock()
}
}
// Filter selects events for Recent.
type Filter struct {
Server string
Types []string
Query string // case-insensitive text in the message or actor
Before int64 // only events with a lower id (for paging back)
Limit int
}
// Recent returns matching events, newest first.
func (h *Hub) Recent(f Filter) []Event {
if f.Limit <= 0 || f.Limit > 1000 {
f.Limit = 200
}
q := strings.ToLower(f.Query)
want := map[string]bool{}
for _, t := range f.Types {
want[t] = true
}
h.mu.Lock()
defer h.mu.Unlock()
var out []Event
for i := len(h.ring) - 1; i >= 0 && len(out) < f.Limit; i-- {
e := h.ring[i]
if f.Before > 0 && e.ID >= f.Before {
continue
}
if f.Server != "" && e.Server != f.Server {
continue
}
if len(want) > 0 && !want[e.Type] {
continue
}
if q != "" && !strings.Contains(strings.ToLower(e.Message+" "+e.Actor), q) {
continue
}
out = append(out, e)
}
return out
}
+735
View File
@@ -0,0 +1,735 @@
package manager
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
"git.veillax.com/Veillax/megalodon/internal/ark"
"git.veillax.com/Veillax/megalodon/internal/config"
"git.veillax.com/Veillax/megalodon/internal/rcon"
)
const (
sampleEvery = time.Minute
sampleKeep = 1440 // a day
)
// Sample is one point of the player-count history.
type Sample struct {
T int64 `json:"t"` // unix seconds
N int `json:"n"` // players online
Up bool `json:"up"`
}
// PlayerView is a connected player as the UI sees them.
type PlayerView struct {
ark.Player
JoinedAt time.Time `json:"joined_at"`
Watch bool `json:"watch,omitempty"`
Notes string `json:"notes,omitempty"`
Banned bool `json:"banned,omitempty"`
}
// RestartPlan is a restart or shutdown that is counting down.
type RestartPlan struct {
At time.Time `json:"at"`
By string `json:"by"`
Reason string `json:"reason,omitempty"`
Stop bool `json:"stop"` // shut down and stay down, instead of restarting
}
// State is what is currently known about a server.
type State struct {
Online bool `json:"online"`
Since *time.Time `json:"since,omitempty"` // when it came up, as far as Megalodon saw
OfflineSince *time.Time `json:"offline_since,omitempty"`
LastSeen *time.Time `json:"last_seen,omitempty"`
LastError string `json:"last_error,omitempty"`
RTTMS int64 `json:"rtt_ms"`
Players []PlayerView `json:"players"`
Info *ark.Info `json:"info,omitempty"`
Held bool `json:"held"` // stopped on purpose: auto-start stays off
Restart *RestartPlan `json:"restart,omitempty"`
Peak int `json:"peak"` // most players seen at once since Megalodon started
}
// View is a server plus its state, as sent to the UI.
type View struct {
ID string `json:"id"`
Name string `json:"name"`
Host string `json:"host"`
RCONPort int `json:"rcon_port"`
QueryPort int `json:"query_port"`
CanStart bool `json:"can_start"`
AutoStart bool `json:"auto_start"`
State
}
type strike struct {
n int
last time.Time
}
// Instance watches and controls one server.
type Instance struct {
cfg config.Server
m *Manager
rc *rcon.Client
ctx context.Context
cancel context.CancelFunc
done chan struct{}
mu sync.RWMutex
st State
known bool // a poll has finished since Megalodon started, so changes are real events
fails int
lastPoll time.Time
lastSample time.Time
samples []Sample
planCancel context.CancelFunc
autoStarted time.Time
strikes map[string]strike
}
func newInstance(m *Manager, cfg config.Server) *Instance {
i := &Instance{
cfg: cfg, m: m, done: make(chan struct{}), strikes: map[string]strike{},
rc: rcon.New(fmt.Sprintf("%s:%d", cfg.Host, cfg.RCONPort), cfg.Password),
}
i.rc.Timeout = 6 * time.Second
i.loadSamples()
return i
}
func (i *Instance) start(parent context.Context) {
i.ctx, i.cancel = context.WithCancel(parent)
go func() {
defer close(i.done)
i.run()
}()
}
func (i *Instance) stop() {
i.cancel()
<-i.done
i.rc.Close()
}
func (i *Instance) event(typ, actor, msg string) Event {
return i.m.hub.Publish(Event{Server: i.cfg.ID, Type: typ, Actor: actor, Message: msg})
}
// View returns the server's current state.
func (i *Instance) View() View {
i.mu.RLock()
defer i.mu.RUnlock()
st := i.st
st.Players = append([]PlayerView(nil), i.st.Players...)
return View{
ID: i.cfg.ID, Name: i.cfg.Name, Host: i.cfg.Host, RCONPort: i.cfg.RCONPort, QueryPort: i.cfg.QueryPort,
CanStart: i.cfg.StartCommand != "", AutoStart: i.cfg.AutoStart, State: st,
}
}
// Samples returns the player-count history, oldest first.
func (i *Instance) Samples() []Sample {
i.mu.RLock()
defer i.mu.RUnlock()
return append([]Sample(nil), i.samples...)
}
// Exec sends one console command.
func (i *Instance) Exec(cmd string) (string, error) { return i.rc.Exec(cmd) }
func (i *Instance) run() {
cfg := i.m.cfg.Get()
poll := time.NewTicker(time.Duration(cfg.PollSeconds) * time.Second)
chat := time.NewTicker(time.Duration(cfg.ChatSeconds) * time.Second)
defer poll.Stop()
defer chat.Stop()
i.poll()
for {
select {
case <-i.ctx.Done():
return
case <-poll.C:
i.poll()
case <-chat.C:
i.pollChat()
}
}
}
// ---- polling ----
func (i *Instance) poll() {
start := time.Now()
out, err := i.rc.Exec(ark.ListPlayers)
rtt := time.Since(start)
if err != nil {
i.markFailed(err)
return
}
var info *ark.Info
if i.cfg.QueryPort > 0 {
info, _ = ark.Query(fmt.Sprintf("%s:%d", i.cfg.Host, i.cfg.QueryPort), 2*time.Second)
}
i.apply(ark.ParsePlayers(out), info, rtt)
}
func (i *Instance) markFailed(err error) {
now := time.Now()
i.mu.Lock()
i.fails++
i.st.LastError = err.Error()
wentDown := i.st.Online && (i.fails >= 2 || errors.Is(err, rcon.ErrAuth))
if wentDown {
i.st.Online = false
i.st.Players, i.st.Info, i.st.Since = nil, nil, nil
}
if !i.st.Online && i.st.OfflineSince == nil {
i.st.OfflineSince = &now
}
i.known = true
i.recordSample(now, 0, false)
i.mu.Unlock()
if wentDown {
i.event("offline", "", "Server went offline: "+err.Error())
}
i.maybeAutoStart()
}
func (i *Instance) apply(players []ark.Player, info *ark.Info, rtt time.Duration) {
now := time.Now()
i.mu.Lock()
wasOnline, known := i.st.Online, i.known
prev := map[string]PlayerView{}
for _, p := range i.st.Players {
prev[p.SteamID] = p
}
dt := time.Duration(0)
if wasOnline && !i.lastPoll.IsZero() {
dt = now.Sub(i.lastPoll)
if limit := 3 * time.Duration(i.m.cfg.Get().PollSeconds) * time.Second; dt > limit {
dt = limit
}
}
i.lastPoll, i.fails, i.known = now, 0, true
i.st.Online, i.st.LastSeen, i.st.LastError, i.st.RTTMS, i.st.Info = true, &now, "", rtt.Milliseconds(), info
if !wasOnline {
i.st.Since, i.st.OfflineSince = &now, nil
}
views := make([]PlayerView, 0, len(players))
var joined, left []PlayerView
cur := map[string]bool{}
for _, p := range players {
cur[p.SteamID] = true
v, was := prev[p.SteamID]
if !was {
v = PlayerView{Player: p, JoinedAt: now}
joined = append(joined, v)
}
v.Name = p.Name
views = append(views, v)
}
for id, v := range prev {
if !cur[id] {
left = append(left, v)
}
}
if len(views) > i.st.Peak {
i.st.Peak = len(views)
}
i.recordSample(now, len(views), true)
i.mu.Unlock()
// Who is on: remember them, and decorate with notes and ban status for the UI.
isNew := joinedSet(joined)
for k, v := range views {
_, joinedNow := isNew[v.SteamID]
rec := i.m.players.Seen(v.SteamID, v.Name, i.cfg.ID, dt, joinedNow && known)
_, banned := i.m.bans.Active(v.SteamID, i.cfg.ID)
views[k].Watch, views[k].Notes, views[k].Banned = rec.Watch, rec.Notes, banned
}
i.mu.Lock()
i.st.Players = views
i.mu.Unlock()
if !wasOnline && known {
i.event("online", "", "Server is online")
}
for _, v := range left {
i.event("leave", "", fmt.Sprintf("%s left after %s", v.Name, ark.Duration(now.Sub(v.JoinedAt))))
}
for _, v := range views {
if b, banned := i.m.bans.Active(v.SteamID, i.cfg.ID); banned {
i.enforceBan(v, b)
continue
}
if _, fresh := isNew[v.SteamID]; fresh && known {
i.onJoin(v, len(views))
}
}
}
func joinedSet(l []PlayerView) map[string]struct{} {
m := make(map[string]struct{}, len(l))
for _, v := range l {
m[v.SteamID] = struct{}{}
}
return m
}
func (i *Instance) onJoin(v PlayerView, online int) {
i.m.hub.Publish(Event{Server: i.cfg.ID, Type: "join", Message: v.Name + " joined", SteamID: v.SteamID})
if v.Watch {
note := ""
if v.Notes != "" {
note = " (" + v.Notes + ")"
}
i.event("watch", "", "Watched player "+v.Name+" joined"+note)
}
if w := i.m.cfg.Get().Welcome; w.Enabled && w.Message != "" {
msg := strings.NewReplacer("{name}", v.Name, "{server}", i.cfg.Name, "{players}", strconv.Itoa(online)).Replace(w.Message)
if cmd, err := ark.ServerChatTo(v.SteamID, msg); err == nil {
// Give the player a moment to finish loading in, or they never see it.
go func() {
select {
case <-time.After(15 * time.Second):
i.rc.Exec(cmd)
case <-i.ctx.Done():
}
}()
}
}
}
func (i *Instance) enforceBan(v PlayerView, b Ban) {
cmd, err := ark.ForPlayer("BanPlayer", v.SteamID)
if err != nil {
return
}
if _, err := i.rc.Exec(cmd); err == nil {
why := b.Reason
if why == "" {
why = "banned"
}
i.event("kick", "megalodon", fmt.Sprintf("Removed banned player %s (%s)", v.Name, why))
}
}
// recordSample must be called with i.mu held.
func (i *Instance) recordSample(now time.Time, n int, up bool) {
if now.Sub(i.lastSample) < sampleEvery {
return
}
i.lastSample = now
s := Sample{T: now.Unix(), N: n, Up: up}
i.samples = append(i.samples, s)
if len(i.samples) > sampleKeep {
i.samples = i.samples[len(i.samples)-sampleKeep:]
}
if f, err := os.OpenFile(i.samplePath(), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600); err == nil {
b, _ := json.Marshal(s)
f.Write(append(b, '\n'))
f.Close()
}
}
func (i *Instance) samplePath() string {
return filepath.Join(i.m.dir, "samples-"+i.cfg.ID+".jsonl")
}
func (i *Instance) loadSamples() {
f, err := os.Open(i.samplePath())
if err != nil {
return
}
defer f.Close()
cutoff := time.Now().Add(-24 * time.Hour).Unix()
sc := bufio.NewScanner(f)
for sc.Scan() {
var s Sample
if json.Unmarshal(sc.Bytes(), &s) == nil && s.T >= cutoff {
i.samples = append(i.samples, s)
}
}
// Rewrite the file so it only ever holds about a day.
var sb strings.Builder
for _, s := range i.samples {
b, _ := json.Marshal(s)
sb.Write(b)
sb.WriteByte('\n')
}
os.WriteFile(i.samplePath(), []byte(sb.String()), 0o600)
}
// ---- chat ----
func (i *Instance) pollChat() {
i.mu.RLock()
online := i.st.Online
i.mu.RUnlock()
if !online {
return
}
out, err := i.rc.Exec(ark.GetChat)
if err != nil {
return
}
for _, cl := range ark.ParseChat(out) {
who := cl.Sender
if cl.Server && who == "" {
who = "SERVER"
}
i.m.hub.Publish(Event{Server: i.cfg.ID, Type: "chat", Actor: who, Message: cl.Message, Detail: cl.Character})
if !cl.Server {
i.filter(cl)
}
}
}
// filter applies the word filter to one line of player chat.
func (i *Instance) filter(cl ark.ChatLine) {
cf := i.m.cfg.Get().ChatFilter
if !cf.Enabled {
return
}
low := strings.ToLower(cl.Message)
hit := false
for _, w := range cf.Words {
if w = strings.ToLower(strings.TrimSpace(w)); w != "" && strings.Contains(low, w) {
hit = true
break
}
}
if !hit {
return
}
i.mu.Lock()
var who *PlayerView
for k := range i.st.Players {
if strings.EqualFold(i.st.Players[k].Name, cl.Sender) {
who = &i.st.Players[k]
break
}
}
if who == nil {
i.mu.Unlock()
return
}
p := *who
s := i.strikes[p.SteamID]
if time.Since(s.last) > time.Hour {
s.n = 0
}
s.n++
s.last = time.Now()
i.strikes[p.SteamID] = s
over := s.n >= cf.Strikes
if over {
delete(i.strikes, p.SteamID)
}
i.mu.Unlock()
switch {
case over && cf.Action == "kick":
i.run1("KickPlayer", p.SteamID)
i.event("filter", "megalodon", fmt.Sprintf("Kicked %s for language (%d strikes)", p.Name, cf.Strikes))
case over && cf.Action == "ban":
i.m.Ban("megalodon", p.SteamID, p.Name, "chat filter", time.Duration(cf.BanMinutes)*time.Minute, []string{i.cfg.ID})
i.event("filter", "megalodon", fmt.Sprintf("Banned %s for %d minutes for language", p.Name, cf.BanMinutes))
default:
msg := fmt.Sprintf("Please keep chat friendly (warning %d of %d).", s.n, cf.Strikes)
if over {
msg = "Please keep chat friendly."
}
if cmd, err := ark.ServerChatTo(p.SteamID, msg); err == nil {
i.rc.Exec(cmd)
}
i.event("filter", "megalodon", fmt.Sprintf("Warned %s (%d of %d)", p.Name, s.n, cf.Strikes))
}
}
func (i *Instance) run1(cmd, steamID string) {
if c, err := ark.ForPlayer(cmd, steamID); err == nil {
i.rc.Exec(c)
}
}
// ---- actions ----
// Perform runs one action on this server and returns the server's reply.
func (i *Instance) Perform(actor string, a Action) (string, error) {
var cmd string
var err error
switch a.Name {
case "save":
cmd = ark.SaveWorld
case "broadcast":
cmd, err = ark.Broadcast(a.Message)
case "say":
cmd, err = ark.ServerChat(a.Message)
case "whisper":
cmd, err = ark.ServerChatTo(a.SteamID, a.Message)
case "kick":
cmd, err = ark.ForPlayer("KickPlayer", a.SteamID)
case "allow":
cmd, err = ark.ForPlayer("AllowPlayerToJoinNoCheck", a.SteamID)
case "disallow":
cmd, err = ark.ForPlayer("DisallowPlayerToJoinNoCheck", a.SteamID)
case "destroy_wild_dinos":
cmd = ark.DestroyWildDinos
case "set_time":
cmd, err = ark.SetTimeOfDay(a.Time)
case "motd":
cmd, err = ark.SetMOTD(a.Message)
case "exec":
if cmd = strings.TrimSpace(a.Command); cmd == "" {
err = errors.New("no command given")
}
case "restart", "stop":
err = i.ScheduleRestart(actor, time.Duration(a.Minutes*float64(time.Minute)), a.Reason, a.Name == "stop")
if err == nil {
return "scheduled", nil
}
case "cancel_restart":
err = i.CancelRestart(actor)
if err == nil {
return "cancelled", nil
}
case "start":
return i.Start(actor)
default:
err = fmt.Errorf("unknown action %q", a.Name)
}
if err != nil {
return "", err
}
out, err := i.rc.Exec(cmd)
if err != nil {
return "", err
}
i.audit(actor, a)
if ark.IsEmptyReply(out) {
return "", nil
}
return strings.TrimSpace(out), nil
}
func (i *Instance) audit(actor string, a Action) {
detail := a.Name
switch a.Name {
case "exec":
detail = "console: " + a.Command
case "broadcast", "say", "motd":
detail = a.Name + ": " + a.Message
case "whisper":
detail = "whisper to " + a.SteamID + ": " + a.Message
case "kick", "allow", "disallow":
detail = a.Name + " " + a.SteamID
case "set_time":
detail = "set time of day to " + a.Time
}
i.event("audit", actor, detail)
}
// ScheduleRestart announces a restart (or, with stop, a shutdown) and carries it out after delay.
// Players are warned along the way; the world is saved first.
func (i *Instance) ScheduleRestart(by string, delay time.Duration, reason string, stop bool) error {
if delay < 0 || delay > 24*time.Hour {
return errors.New("the delay must be between 0 and 24 hours")
}
i.mu.Lock()
if i.st.Restart != nil {
i.mu.Unlock()
return errors.New("a restart is already scheduled; cancel it first")
}
at := time.Now().Add(delay)
plan := &RestartPlan{At: at, By: by, Reason: ark.Clean(reason), Stop: stop}
ctx, cancel := context.WithCancel(i.ctx)
i.st.Restart, i.planCancel = plan, cancel
i.mu.Unlock()
verb := "restart"
if stop {
verb = "shutdown"
}
i.event("restart", by, fmt.Sprintf("Scheduled %s in %s%s", verb, ark.Duration(delay), withReason(plan.Reason)))
go i.runPlan(ctx, plan)
return nil
}
func withReason(r string) string {
if r == "" {
return ""
}
return " (" + r + ")"
}
// warnings are the moments before a restart at which players are told.
var warnings = []time.Duration{30 * time.Minute, 15 * time.Minute, 10 * time.Minute, 5 * time.Minute, 2 * time.Minute, time.Minute, 30 * time.Second, 10 * time.Second}
func (i *Instance) runPlan(ctx context.Context, plan *RestartPlan) {
finish := func() {
i.mu.Lock()
if i.st.Restart == plan {
i.st.Restart, i.planCancel = nil, nil
}
i.mu.Unlock()
}
defer finish()
verb, ing := "restarting", "restart"
if plan.Stop {
verb, ing = "shutting down", "shutdown"
}
say := func(until time.Duration) {
msg := fmt.Sprintf("Server %s in %s.", verb, ark.Duration(until))
if plan.Reason != "" {
msg += " " + plan.Reason
}
if cmd, err := ark.Broadcast(msg); err == nil {
i.rc.Exec(cmd)
}
}
wait := func(until time.Time) bool {
t := time.NewTimer(time.Until(until))
defer t.Stop()
select {
case <-t.C:
return true
case <-ctx.Done():
return false
}
}
total := time.Until(plan.At)
if total >= 15*time.Second {
say(total) // tell everyone straight away, not at the next fixed warning
}
for _, w := range warnings {
if w >= total-5*time.Second { // already announced above, or too late to matter
continue
}
if !wait(plan.At.Add(-w)) {
return
}
say(w)
}
if !wait(plan.At) {
return
}
i.event("restart", plan.By, "Saving and "+ing+"ing now")
i.rc.Exec(ark.SaveWorld)
time.Sleep(2 * time.Second)
i.rc.Exec(ark.DoExit) // the connection usually drops before a reply
if plan.Stop {
i.mu.Lock()
i.st.Held = true
i.mu.Unlock()
finish()
return
}
if i.cfg.StartCommand == "" {
return // a supervisor (docker, systemd) is expected to bring it back
}
// Wait for the server to be seen going down, then start it.
deadline := time.Now().Add(3 * time.Minute)
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return
case <-time.After(time.Second):
}
i.mu.RLock()
online := i.st.Online
i.mu.RUnlock()
if !online {
break
}
}
select {
case <-ctx.Done():
case <-time.After(5 * time.Second):
i.Start(plan.By)
}
}
// CancelRestart stops a countdown.
func (i *Instance) CancelRestart(by string) error {
i.mu.Lock()
defer i.mu.Unlock()
if i.st.Restart == nil {
return errors.New("no restart is scheduled")
}
i.planCancel()
i.st.Restart, i.planCancel = nil, nil
go func() {
if cmd, err := ark.Broadcast("The scheduled restart was cancelled."); err == nil {
i.rc.Exec(cmd)
}
}()
i.m.hub.Publish(Event{Server: i.cfg.ID, Type: "restart", Actor: by, Message: "Scheduled restart cancelled"})
return nil
}
// Start runs the server's start command.
func (i *Instance) Start(by string) (string, error) {
if i.cfg.StartCommand == "" {
return "", errors.New("this server has no start command configured")
}
i.mu.Lock()
i.st.Held = false
i.autoStarted = time.Now()
i.mu.Unlock()
out, err := runShell(i.cfg.StartCommand)
if err != nil {
i.event("error", by, "Start command failed: "+err.Error()+" "+out)
return out, err
}
i.event("start", by, "Ran the start command")
return out, nil
}
func (i *Instance) maybeAutoStart() {
if !i.cfg.AutoStart || i.cfg.StartCommand == "" {
return
}
after := time.Duration(i.cfg.AutoStartSec) * time.Second
if after <= 0 {
after = 2 * time.Minute
}
i.mu.RLock()
due := !i.st.Online && !i.st.Held && i.st.OfflineSince != nil && time.Since(*i.st.OfflineSince) >= after && time.Since(i.autoStarted) >= 5*time.Minute
i.mu.RUnlock()
if due {
i.Start("auto-start")
}
}
func runShell(cmd string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
var c *exec.Cmd
if runtime.GOOS == "windows" {
c = exec.CommandContext(ctx, "cmd", "/C", cmd)
} else {
c = exec.CommandContext(ctx, "sh", "-c", cmd)
}
out, err := c.CombinedOutput()
s := strings.TrimSpace(string(out))
if len(s) > 500 {
s = s[:500]
}
return s, err
}
+479
View File
@@ -0,0 +1,479 @@
// Package manager runs Megalodon's side of things: it keeps a connection to every server, tracks
// who is online, records events, enforces bans, runs scheduled jobs and sends notifications.
package manager
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
"git.veillax.com/Veillax/megalodon/internal/ark"
"git.veillax.com/Veillax/megalodon/internal/config"
)
// Action is something a person or a schedule asks to be done.
type Action struct {
Name string `json:"action"`
Servers []string `json:"servers,omitempty"` // empty or ["*"]: every server
SteamID string `json:"steam_id,omitempty"`
Player string `json:"player,omitempty"` // name, for the record
Message string `json:"message,omitempty"`
Command string `json:"command,omitempty"`
Time string `json:"time,omitempty"`
Reason string `json:"reason,omitempty"`
Minutes float64 `json:"minutes,omitempty"` // restart countdown, or ban length (0 = permanent)
}
// Result is how an action went on one server.
type Result struct {
Server string `json:"server"`
OK bool `json:"ok"`
Output string `json:"output,omitempty"`
Error string `json:"error,omitempty"`
}
// ActionRole is the lowest role allowed to run an action.
func ActionRole(name string) config.Role {
switch name {
case "broadcast", "say", "whisper", "kick", "ban", "unban", "allow", "disallow", "save":
return config.Moderator
}
return config.Admin
}
type Manager struct {
cfg *config.Holder
dir string
hub *Hub
players *PlayerDB
bans *BanDB
mu sync.RWMutex
inst map[string]*Instance
ctx context.Context
wg sync.WaitGroup
jobMu sync.Mutex
lastRun map[string]time.Time
lastFire map[string]string
client httpDoer
}
// New opens the data directory. Call Start to begin watching servers.
func New(h *config.Holder) (*Manager, error) {
dir := h.Get().DataDir
hub, err := NewHub(dir)
if err != nil {
return nil, err
}
players, err := openPlayers(dir)
if err != nil {
return nil, err
}
bans, err := openBans(dir)
if err != nil {
return nil, err
}
return &Manager{
cfg: h, dir: dir, hub: hub, players: players, bans: bans, inst: map[string]*Instance{},
lastRun: map[string]time.Time{}, lastFire: map[string]string{}, client: defaultClient(),
}, nil
}
func (m *Manager) Hub() *Hub { return m.hub }
// Start begins watching every configured server and running background work until ctx ends.
func (m *Manager) Start(ctx context.Context) {
m.ctx = ctx
m.Reload()
m.go_(m.scheduler)
m.go_(m.notifier)
m.go_(m.housekeeping)
}
func (m *Manager) go_(f func(context.Context)) {
m.wg.Add(1)
go func() { defer m.wg.Done(); f(m.ctx) }()
}
// Wait blocks until background work has finished (after the context given to Start is cancelled)
// and saves what is still in memory.
func (m *Manager) Wait() {
m.wg.Wait()
m.mu.Lock()
for id, i := range m.inst {
i.stop()
delete(m.inst, id)
}
m.mu.Unlock()
m.players.Flush()
m.hub.Close()
}
// Reload makes the running servers match the configuration: new ones start, removed ones stop,
// changed ones are restarted.
func (m *Manager) Reload() {
servers := m.cfg.Get().Servers
want := map[string]config.Server{}
for _, s := range servers {
want[s.ID] = s
}
m.mu.Lock()
defer m.mu.Unlock()
for id, i := range m.inst {
if s, ok := want[id]; !ok || !sameServer(s, i.cfg) {
i.stop()
delete(m.inst, id)
}
}
for _, s := range servers {
if _, ok := m.inst[s.ID]; !ok {
i := newInstance(m, s)
m.inst[s.ID] = i
i.start(m.ctx)
}
}
}
func sameServer(a, b config.Server) bool {
x, _ := json.Marshal(a)
y, _ := json.Marshal(b)
return string(x) == string(y)
}
func (m *Manager) Instance(id string) (*Instance, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
i, ok := m.inst[id]
return i, ok
}
// Views returns every server in configuration order.
func (m *Manager) Views() []View {
var out []View
for _, s := range m.cfg.Get().Servers {
if i, ok := m.Instance(s.ID); ok {
out = append(out, i.View())
}
}
return out
}
func (m *Manager) Players() []PlayerRecord { return m.players.List() }
func (m *Manager) Annotate(id, notes string, watch bool) error {
if !ark.ValidSteamID(id) {
return errors.New("not a Steam ID")
}
m.players.Annotate(id, ark.Clean(notes), watch)
return nil
}
func (m *Manager) Bans() []Ban { return m.bans.List() }
// Audit records something done outside a server (a settings change, say).
func (m *Manager) Audit(actor, server, msg string) {
m.hub.Publish(Event{Server: server, Type: "audit", Actor: actor, Message: msg})
}
// targets resolves server ids; empty or "*" means all.
func (m *Manager) targets(ids []string) ([]*Instance, error) {
if len(ids) == 0 || (len(ids) == 1 && ids[0] == "*") {
var out []*Instance
for _, v := range m.Views() {
if i, ok := m.Instance(v.ID); ok {
out = append(out, i)
}
}
if len(out) == 0 {
return nil, errors.New("no servers are configured")
}
return out, nil
}
var out []*Instance
for _, id := range ids {
i, ok := m.Instance(id)
if !ok {
return nil, fmt.Errorf("unknown server %q", id)
}
out = append(out, i)
}
return out, nil
}
// Do carries out an action on its target servers, one after another, and reports each result.
// The caller has already checked that actor may do this.
func (m *Manager) Do(actor string, a Action) ([]Result, error) {
if a.Name == "ban" || a.Name == "unban" {
return m.banAction(actor, a)
}
targets, err := m.targets(a.Servers)
if err != nil {
return nil, err
}
results := make([]Result, len(targets))
var wg sync.WaitGroup
for k, i := range targets {
wg.Add(1)
go func() {
defer wg.Done()
out, err := i.Perform(actor, a)
r := Result{Server: i.cfg.ID, OK: err == nil, Output: out}
if err != nil {
r.Error = err.Error()
}
results[k] = r
}()
}
wg.Wait()
return results, nil
}
func (m *Manager) banAction(actor string, a Action) ([]Result, error) {
if a.Name == "unban" {
return m.Unban(actor, a.SteamID)
}
var servers []string
if !(len(a.Servers) == 0 || (len(a.Servers) == 1 && a.Servers[0] == "*")) {
servers = a.Servers
}
return m.banOn(actor, a.SteamID, a.Player, a.Reason, time.Duration(a.Minutes*float64(time.Minute)), servers)
}
// Ban records a ban and applies it to the servers it covers (all if servers is empty). dur 0 is
// permanent. The ban is also enforced later: anyone banned who turns up on a covered server is
// removed, even if that server was offline when the ban was made.
func (m *Manager) Ban(actor, steamID, name, reason string, dur time.Duration, servers []string) error {
_, err := m.banOn(actor, steamID, name, reason, dur, servers)
return err
}
func (m *Manager) banOn(actor, steamID, name, reason string, dur time.Duration, servers []string) ([]Result, error) {
if !ark.ValidSteamID(steamID) {
return nil, errors.New("not a Steam ID")
}
targets, err := m.targets(servers)
if err != nil {
return nil, err
}
if name == "" {
if r, ok := m.players.Get(steamID); ok {
name = r.Name
}
}
b := Ban{SteamID: steamID, Name: ark.Clean(name), Reason: ark.Clean(reason), By: actor, At: time.Now(), Servers: servers}
desc := "permanently"
if dur > 0 {
exp := b.At.Add(dur)
b.Expires = &exp
desc = "for " + ark.Duration(dur)
}
m.bans.Add(b)
who := steamID
if b.Name != "" {
who = b.Name + " (" + steamID + ")"
}
m.hub.Publish(Event{Type: "ban", Actor: actor, SteamID: steamID, Message: fmt.Sprintf("Banned %s %s%s", who, desc, withReason(b.Reason))})
cmd, _ := ark.ForPlayer("BanPlayer", steamID)
var results []Result
for _, i := range targets {
out, err := i.rc.Exec(cmd)
r := Result{Server: i.cfg.ID, OK: err == nil, Output: strings.TrimSpace(out)}
if err != nil {
r.Error = err.Error()
}
results = append(results, r)
}
return results, nil
}
// Unban lifts a ban everywhere.
func (m *Manager) Unban(actor, steamID string) ([]Result, error) {
if !ark.ValidSteamID(steamID) {
return nil, errors.New("not a Steam ID")
}
b, _ := m.bans.Remove(steamID)
who := steamID
if b.Name != "" {
who = b.Name + " (" + steamID + ")"
}
m.hub.Publish(Event{Type: "unban", Actor: actor, SteamID: steamID, Message: "Unbanned " + who})
cmd, _ := ark.ForPlayer("UnbanPlayer", steamID)
targets, _ := m.targets(nil)
var results []Result
for _, i := range targets {
out, err := i.rc.Exec(cmd)
r := Result{Server: i.cfg.ID, OK: err == nil, Output: strings.TrimSpace(out)}
if err != nil {
r.Error = err.Error()
}
results = append(results, r)
}
return results, nil
}
// RunMacro sends each command of a macro to the server and gathers the replies.
func (m *Manager) RunMacro(actor, server string, mc config.Macro) ([]Result, error) {
i, ok := m.Instance(server)
if !ok {
return nil, fmt.Errorf("unknown server %q", server)
}
m.Audit(actor, server, "ran macro "+mc.Name)
var out []Result
for _, c := range mc.Commands {
reply, err := i.Exec(c)
r := Result{Server: server, OK: err == nil, Output: strings.TrimSpace(reply)}
if err != nil {
r.Error = err.Error()
}
out = append(out, r)
if err != nil {
break
}
}
return out, nil
}
// ---- background work ----
// housekeeping flushes the player list and lifts expired bans.
func (m *Manager) housekeeping(ctx context.Context) {
t := time.NewTicker(30 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
m.players.Flush()
for _, b := range m.bans.Expired(time.Now()) {
m.Unban("megalodon", b.SteamID)
}
}
}
}
// scheduler runs the configured jobs.
func (m *Manager) scheduler(ctx context.Context) {
t := time.NewTicker(15 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-t.C:
for _, j := range m.cfg.Get().Jobs {
if j.Enabled && m.due(j, now) {
go m.RunJob(j, "job: "+j.Name)
}
}
}
}
}
var dayNames = [...]string{"sun", "mon", "tue", "wed", "thu", "fri", "sat"}
func (m *Manager) due(j config.Job, now time.Time) bool {
m.jobMu.Lock()
defer m.jobMu.Unlock()
if j.Every != "" {
d, err := time.ParseDuration(j.Every)
if err != nil {
return false
}
last, ok := m.lastRun[j.ID]
if !ok { // first sight: count from now rather than firing at once
m.lastRun[j.ID] = now
return false
}
if now.Sub(last) >= d {
m.lastRun[j.ID] = now
return true
}
return false
}
if len(j.Days) > 0 {
ok := false
for _, d := range j.Days {
ok = ok || d == dayNames[now.Weekday()]
}
if !ok {
return false
}
}
minute := now.Format("2006-01-02 15:04")
for _, at := range j.At {
if at == now.Format("15:04") && m.lastFire[j.ID] != minute {
m.lastFire[j.ID] = minute
return true
}
}
return false
}
// RunJob runs a job now.
func (m *Manager) RunJob(j config.Job, actor string) []Result {
a := Action{Name: j.Action, Servers: j.Servers, Message: j.Message, Reason: j.Reason}
switch j.Action {
case "command":
a.Name, a.Command = "exec", j.Command
case "restart":
d := 5 * time.Minute
if j.Countdown != "" {
d, _ = time.ParseDuration(j.Countdown)
}
a.Minutes = d.Minutes()
}
results, err := m.Do(actor, a)
if err != nil {
m.hub.Publish(Event{Type: "error", Actor: actor, Message: "Job " + j.Name + " failed: " + err.Error()})
return nil
}
failed := 0
for _, r := range results {
if !r.OK {
failed++
}
}
msg := fmt.Sprintf("Ran job %q on %d server(s)", j.Name, len(results))
typ := "schedule"
if failed > 0 {
msg += fmt.Sprintf(", %d failed", failed)
typ = "error"
}
m.hub.Publish(Event{Type: typ, Actor: actor, Message: msg})
return results
}
// Metrics renders the current state in Prometheus text format.
func (m *Manager) Metrics() string {
var sb strings.Builder
views := m.Views()
sort.Slice(views, func(a, b int) bool { return views[a].ID < views[b].ID })
sb.WriteString("# TYPE megalodon_server_up gauge\n# TYPE megalodon_players gauge\n# TYPE megalodon_players_max gauge\n# TYPE megalodon_rcon_rtt_ms gauge\n# TYPE megalodon_uptime_seconds gauge\n# TYPE megalodon_restart_pending gauge\n")
for _, v := range views {
l := fmt.Sprintf(`{server=%q}`, v.ID)
up := 0
if v.Online {
up = 1
}
fmt.Fprintf(&sb, "megalodon_server_up%s %d\nmegalodon_players%s %d\nmegalodon_rcon_rtt_ms%s %d\n", l, up, l, len(v.Players), l, v.RTTMS)
if v.Info != nil {
fmt.Fprintf(&sb, "megalodon_players_max%s %d\n", l, v.Info.MaxPlayers)
}
if v.Since != nil {
fmt.Fprintf(&sb, "megalodon_uptime_seconds%s %d\n", l, int(time.Since(*v.Since).Seconds()))
}
pending := 0
if v.Restart != nil {
pending = 1
}
fmt.Fprintf(&sb, "megalodon_restart_pending%s %d\n", l, pending)
}
return sb.String()
}
+285
View File
@@ -0,0 +1,285 @@
package manager
import (
"context"
"encoding/binary"
"fmt"
"io"
"net"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"git.veillax.com/Veillax/megalodon/internal/config"
)
// fakeArk is a minimal ARK RCON server whose player list the test controls.
type fakeArk struct {
ln net.Listener
mu sync.Mutex
players []string // "Name, steamid" lines
got []string
}
func startFake(t *testing.T) *fakeArk {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
f := &fakeArk{ln: ln}
t.Cleanup(func() { ln.Close() })
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go f.handle(c)
}
}()
return f
}
func rawPacket(w io.Writer, id, typ int32, body string) {
buf := make([]byte, 12+len(body)+2)
binary.LittleEndian.PutUint32(buf[0:], uint32(10+len(body)))
binary.LittleEndian.PutUint32(buf[4:], uint32(id))
binary.LittleEndian.PutUint32(buf[8:], uint32(typ))
copy(buf[12:], body)
w.Write(buf)
}
func rawRead(r io.Reader) (id, typ int32, body string, err error) {
var h [4]byte
if _, err = io.ReadFull(r, h[:]); err != nil {
return
}
buf := make([]byte, binary.LittleEndian.Uint32(h[:]))
if _, err = io.ReadFull(r, buf); err != nil {
return
}
return int32(binary.LittleEndian.Uint32(buf)), int32(binary.LittleEndian.Uint32(buf[4:])), string(buf[8 : len(buf)-2]), nil
}
func (f *fakeArk) handle(c net.Conn) {
defer c.Close()
id, _, _, err := rawRead(c)
if err != nil {
return
}
rawPacket(c, id, 0, "")
rawPacket(c, id, 2, "")
for {
id, _, cmd, err := rawRead(c)
if err != nil {
return
}
f.mu.Lock()
f.got = append(f.got, cmd)
reply := "ok"
switch cmd {
case "ListPlayers":
reply = "No Players Connected"
if len(f.players) > 0 {
var lines []string
for n, p := range f.players {
lines = append(lines, fmt.Sprintf("%d. %s", n, p))
}
reply = strings.Join(lines, "\n") + "\n"
}
case "GetChat":
reply = "Server received, But no response!!"
}
f.mu.Unlock()
rawPacket(c, id, 0, reply)
}
}
func (f *fakeArk) setPlayers(p ...string) { f.mu.Lock(); f.players = p; f.mu.Unlock() }
func (f *fakeArk) received(cmd string) int {
f.mu.Lock()
defer f.mu.Unlock()
n := 0
for _, c := range f.got {
if c == cmd {
n++
}
}
return n
}
func (f *fakeArk) port() int { return f.ln.Addr().(*net.TCPAddr).Port }
func newTestManager(t *testing.T, f *fakeArk) *Manager {
t.Helper()
h, err := config.Load(filepath.Join(t.TempDir(), "megalodon.json"))
if err != nil {
t.Fatal(err)
}
err = h.Update(func(c *config.Config) error {
c.DataDir = t.TempDir()
c.PollSeconds = 1
c.Servers = []config.Server{{ID: "island", Name: "The Island", Host: "127.0.0.1", RCONPort: f.port(), Password: "pw"}}
return nil
})
if err != nil {
t.Fatal(err)
}
m, err := New(h)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
m.Start(ctx)
t.Cleanup(func() { cancel(); m.Wait() })
return m
}
func waitFor(t *testing.T, what string, ok func() bool) {
t.Helper()
deadline := time.Now().Add(8 * time.Second)
for time.Now().Before(deadline) {
if ok() {
return
}
time.Sleep(50 * time.Millisecond)
}
t.Fatalf("timed out waiting for %s", what)
}
func hasEvent(m *Manager, typ, contains string) bool {
for _, e := range m.hub.Recent(Filter{Types: []string{typ}}) {
if strings.Contains(e.Message, contains) {
return true
}
}
return false
}
const (
alex = "76561198000000001"
bo = "76561198000000002"
)
func TestJoinLeaveAndBanEnforcement(t *testing.T) {
f := startFake(t)
f.setPlayers("Alex, " + alex)
m := newTestManager(t, f)
waitFor(t, "server online", func() bool { return m.Views()[0].Online })
if hasEvent(m, "join", "Alex") {
t.Fatal("someone already online at startup should not raise a join event")
}
f.setPlayers("Alex, "+alex, "Bo, "+bo)
waitFor(t, "join event", func() bool { return hasEvent(m, "join", "Bo joined") })
if v := m.Views()[0]; len(v.Players) != 2 {
t.Fatalf("expected two players, got %+v", v.Players)
}
f.setPlayers("Bo, " + bo)
waitFor(t, "leave event", func() bool { return hasEvent(m, "leave", "Alex left") })
// A ban is sent to the server, and enforced again when the player shows up.
if _, err := m.Do("tester", Action{Name: "ban", SteamID: alex, Reason: "griefing", Minutes: 60}); err != nil {
t.Fatal(err)
}
if f.received("BanPlayer "+alex) != 1 {
t.Fatal("the ban was not sent")
}
f.setPlayers("Alex, "+alex, "Bo, "+bo)
waitFor(t, "ban enforcement", func() bool { return f.received("BanPlayer "+alex) >= 2 })
waitFor(t, "kick event", func() bool { return hasEvent(m, "kick", "Removed banned player Alex") })
if _, err := m.Do("tester", Action{Name: "unban", SteamID: alex}); err != nil {
t.Fatal(err)
}
if f.received("UnbanPlayer "+alex) != 1 || len(m.Bans()) != 0 {
t.Fatal("the unban did not go through")
}
}
func TestActionsBuildSafeCommands(t *testing.T) {
f := startFake(t)
m := newTestManager(t, f)
waitFor(t, "server online", func() bool { return m.Views()[0].Online })
res, err := m.Do("tester", Action{Name: "broadcast", Message: "hello\nDoExit"})
if err != nil || len(res) != 1 || !res[0].OK {
t.Fatalf("broadcast: %+v, %v", res, err)
}
if f.received("Broadcast hello DoExit") != 1 || f.received("DoExit") != 0 {
t.Fatalf("commands received: %v", f.got)
}
res, _ = m.Do("tester", Action{Name: "kick", SteamID: "1; DoExit"})
if res[0].OK || f.received("DoExit") != 0 {
t.Fatal("an invalid Steam ID got through")
}
if _, err := m.Do("tester", Action{Name: "save", Servers: []string{"nope"}}); err == nil {
t.Fatal("unknown server accepted")
}
if !hasEvent(m, "audit", "broadcast") {
t.Fatal("the action was not audited")
}
}
func TestRestartCountdownCanBeCancelled(t *testing.T) {
f := startFake(t)
m := newTestManager(t, f)
waitFor(t, "server online", func() bool { return m.Views()[0].Online })
if _, err := m.Do("tester", Action{Name: "restart", Minutes: 10, Reason: "updates"}); err != nil {
t.Fatal(err)
}
if m.Views()[0].Restart == nil {
t.Fatal("no restart shown")
}
waitFor(t, "first warning", func() bool { return f.received("Broadcast Server restarting in 10 minutes. updates") == 1 })
if res, _ := m.Do("tester", Action{Name: "restart", Minutes: 5}); res[0].OK {
t.Fatal("a second restart was accepted")
}
m.Do("tester", Action{Name: "cancel_restart"})
if m.Views()[0].Restart != nil {
t.Fatal("restart still shown after cancelling")
}
if f.received("DoExit") != 0 {
t.Fatal("the server was stopped despite the cancel")
}
}
func TestSchedule(t *testing.T) {
m := &Manager{lastRun: map[string]time.Time{}, lastFire: map[string]string{}}
now := time.Date(2026, 3, 2, 4, 0, 10, 0, time.Local) // a Monday
every := config.Job{ID: "e", Every: "30m"}
if m.due(every, now) {
t.Fatal("an interval job must not fire the moment it is first seen")
}
if m.due(every, now.Add(29*time.Minute)) || !m.due(every, now.Add(31*time.Minute)) {
t.Fatal("interval job timing is wrong")
}
daily := config.Job{ID: "d", At: []string{"04:00"}, Days: []string{"mon"}}
if !m.due(daily, now) || m.due(daily, now.Add(5*time.Second)) {
t.Fatal("a timed job should fire once in its minute")
}
if m.due(config.Job{ID: "t", At: []string{"04:00"}, Days: []string{"tue"}}, now) {
t.Fatal("job fired on the wrong day")
}
}
func TestNotificationFilter(t *testing.T) {
join := Event{Type: "join", Server: "island"}
chat := Event{Type: "chat", Server: "island"}
if !wants(config.Notify{}, join) || wants(config.Notify{}, chat) {
t.Fatal("default event set is wrong")
}
if !wants(config.Notify{Events: []string{"chat"}}, chat) || wants(config.Notify{Events: []string{"chat"}}, join) {
t.Fatal("explicit event list ignored")
}
if wants(config.Notify{Servers: []string{"other"}}, join) || !wants(config.Notify{Servers: []string{"island"}}, join) {
t.Fatal("server filter is wrong")
}
}
+151
View File
@@ -0,0 +1,151 @@
package manager
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"git.veillax.com/Veillax/megalodon/internal/config"
)
type httpDoer interface {
Do(*http.Request) (*http.Response, error)
}
func defaultClient() httpDoer {
return &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
}
// defaultEvents is what a notification gets when it does not choose.
var defaultEvents = map[string]bool{
"join": true, "leave": true, "online": true, "offline": true, "restart": true, "start": true,
"ban": true, "unban": true, "kick": true, "filter": true, "watch": true, "error": true,
}
// notifier forwards events to the configured webhooks.
func (m *Manager) notifier(ctx context.Context) {
ch, cancel := m.hub.Subscribe()
defer cancel()
for {
select {
case <-ctx.Done():
return
case e := <-ch:
for _, n := range m.cfg.Get().Notify {
if wants(n, e) {
go func() {
if err := m.send(ctx, n, e); err != nil {
// Not published as an event: a broken webhook must not trigger itself.
fmt.Printf("notify %s: %v\n", n.Name, err)
}
}()
}
}
}
}
}
func wants(n config.Notify, e Event) bool {
if len(n.Servers) > 0 && e.Server != "" {
ok := false
for _, s := range n.Servers {
ok = ok || s == e.Server
}
if !ok {
return false
}
}
if len(n.Events) == 0 {
return defaultEvents[e.Type]
}
for _, t := range n.Events {
if t == e.Type || t == "*" {
return true
}
}
return false
}
// TestNotify sends a sample message through the named notification.
func (m *Manager) TestNotify(name string) error {
for _, n := range m.cfg.Get().Notify {
if n.Name == name {
return m.send(m.ctx, n, Event{Time: time.Now(), Type: "audit", Message: "This is a test message from Megalodon."})
}
}
return errors.New("no such notification")
}
func (m *Manager) send(ctx context.Context, n config.Notify, e Event) error {
server := e.Server
for _, s := range m.cfg.Get().Servers {
if s.ID == e.Server {
server = s.Name
}
}
text := e.Message
if e.Type == "chat" {
text = e.Actor + ": " + e.Message
}
var body []byte
ct := "application/json"
hdr := map[string]string{}
switch n.Kind {
case "discord":
prefix := ""
if server != "" {
prefix = "**" + server + "** · "
}
body, _ = json.Marshal(map[string]any{"username": "Megalodon", "content": truncate(prefix+text, 1900), "allowed_mentions": map[string]any{"parse": []string{}}})
case "ntfy":
body, ct = []byte(text), "text/plain"
if server != "" {
hdr["Title"] = server
}
hdr["Tags"] = e.Type
default:
body, _ = json.Marshal(e)
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "POST", n.URL, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", ct)
req.Header.Set("User-Agent", "megalodon")
for k, v := range hdr {
req.Header.Set(k, v)
}
resp, err := m.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))
if resp.StatusCode >= 300 {
return fmt.Errorf("%s answered %s", hostOf(n.URL), resp.Status)
}
return nil
}
func hostOf(u string) string {
u = strings.TrimPrefix(strings.TrimPrefix(u, "https://"), "http://")
if i := strings.IndexAny(u, "/?"); i >= 0 {
u = u[:i]
}
return u
}
func truncate(s string, n int) string {
if r := []rune(s); len(r) > n {
return string(r[:n])
}
return s
}
+242
View File
@@ -0,0 +1,242 @@
package manager
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"sort"
"sync"
"time"
)
func saveJSON(path string, v any) error {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
func loadJSON(path string, v any) error {
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
return json.Unmarshal(b, v)
}
// PlayerRecord is what Megalodon remembers about someone who has been on any server.
type PlayerRecord struct {
SteamID string `json:"steam_id"`
Name string `json:"name"`
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
Seconds int64 `json:"seconds"` // time spent online, across all servers
Joins int `json:"joins"`
LastOn string `json:"last_server,omitempty"`
Notes string `json:"notes,omitempty"`
Watch bool `json:"watch,omitempty"` // raise an event when they join
}
// PlayerDB is the list of everyone seen, saved to a file.
type PlayerDB struct {
mu sync.Mutex
path string
recs map[string]*PlayerRecord
dirty bool
}
func openPlayers(dir string) (*PlayerDB, error) {
db := &PlayerDB{path: filepath.Join(dir, "players.json"), recs: map[string]*PlayerRecord{}}
var list []*PlayerRecord
if err := loadJSON(db.path, &list); err != nil {
return nil, err
}
for _, r := range list {
db.recs[r.SteamID] = r
}
return db, nil
}
// Seen notes that a player is online on server. online is the time since the previous poll to
// credit them with; joined is true the moment they appear.
func (db *PlayerDB) Seen(id, name, server string, online time.Duration, joined bool) PlayerRecord {
db.mu.Lock()
defer db.mu.Unlock()
now := time.Now()
r := db.recs[id]
if r == nil {
r = &PlayerRecord{SteamID: id, FirstSeen: now}
db.recs[id] = r
}
r.Name, r.LastSeen, r.LastOn = name, now, server
r.Seconds += int64(online / time.Second)
if joined {
r.Joins++
}
db.dirty = true
return *r
}
func (db *PlayerDB) Get(id string) (PlayerRecord, bool) {
db.mu.Lock()
defer db.mu.Unlock()
if r := db.recs[id]; r != nil {
return *r, true
}
return PlayerRecord{}, false
}
// List returns everyone, most recently seen first.
func (db *PlayerDB) List() []PlayerRecord {
db.mu.Lock()
defer db.mu.Unlock()
out := make([]PlayerRecord, 0, len(db.recs))
for _, r := range db.recs {
out = append(out, *r)
}
sort.Slice(out, func(i, j int) bool { return out[i].LastSeen.After(out[j].LastSeen) })
return out
}
// Annotate sets the notes and watch flag for a player, creating the record if needed.
func (db *PlayerDB) Annotate(id, notes string, watch bool) {
db.mu.Lock()
defer db.mu.Unlock()
r := db.recs[id]
if r == nil {
r = &PlayerRecord{SteamID: id, FirstSeen: time.Now(), LastSeen: time.Now()}
db.recs[id] = r
}
r.Notes, r.Watch = notes, watch
db.dirty = true
}
func (db *PlayerDB) Flush() {
db.mu.Lock()
defer db.mu.Unlock()
if !db.dirty {
return
}
list := make([]*PlayerRecord, 0, len(db.recs))
for _, r := range db.recs {
list = append(list, r)
}
if saveJSON(db.path, list) == nil {
db.dirty = false
}
}
// Ban is a ban Megalodon enforces on top of the game's own list: it can expire, carries a
// reason, and follows the player across a whole cluster.
type Ban struct {
SteamID string `json:"steam_id"`
Name string `json:"name,omitempty"`
Reason string `json:"reason,omitempty"`
By string `json:"by"`
At time.Time `json:"at"`
Expires *time.Time `json:"expires,omitempty"`
Servers []string `json:"servers,omitempty"` // empty means every server
}
func (b Ban) Active(now time.Time) bool { return b.Expires == nil || now.Before(*b.Expires) }
func (b Ban) Covers(server string) bool {
if len(b.Servers) == 0 {
return true
}
for _, s := range b.Servers {
if s == server {
return true
}
}
return false
}
type BanDB struct {
mu sync.Mutex
path string
bans map[string]Ban
}
func openBans(dir string) (*BanDB, error) {
db := &BanDB{path: filepath.Join(dir, "bans.json"), bans: map[string]Ban{}}
var list []Ban
if err := loadJSON(db.path, &list); err != nil {
return nil, err
}
for _, b := range list {
db.bans[b.SteamID] = b
}
return db, nil
}
func (db *BanDB) saveLocked() {
list := make([]Ban, 0, len(db.bans))
for _, b := range db.bans {
list = append(list, b)
}
sort.Slice(list, func(i, j int) bool { return list[i].At.After(list[j].At) })
saveJSON(db.path, list)
}
func (db *BanDB) Add(b Ban) {
db.mu.Lock()
defer db.mu.Unlock()
db.bans[b.SteamID] = b
db.saveLocked()
}
func (db *BanDB) Remove(id string) (Ban, bool) {
db.mu.Lock()
defer db.mu.Unlock()
b, ok := db.bans[id]
if ok {
delete(db.bans, id)
db.saveLocked()
}
return b, ok
}
// Active returns the ban that applies to id on server right now, if any.
func (db *BanDB) Active(id, server string) (Ban, bool) {
db.mu.Lock()
defer db.mu.Unlock()
b, ok := db.bans[id]
if !ok || !b.Active(time.Now()) || !b.Covers(server) {
return Ban{}, false
}
return b, true
}
func (db *BanDB) List() []Ban {
db.mu.Lock()
defer db.mu.Unlock()
out := make([]Ban, 0, len(db.bans))
for _, b := range db.bans {
out = append(out, b)
}
sort.Slice(out, func(i, j int) bool { return out[i].At.After(out[j].At) })
return out
}
// Expired returns (without removing) the bans whose time is up.
func (db *BanDB) Expired(now time.Time) []Ban {
db.mu.Lock()
defer db.mu.Unlock()
var out []Ban
for _, b := range db.bans {
if !b.Active(now) {
out = append(out, b)
}
}
return out
}
+217
View File
@@ -0,0 +1,217 @@
// Package rcon is a client for the Source RCON protocol as ARK: Survival Evolved speaks it. One
// Client holds one TCP connection, signs in on first use, reconnects when the server has dropped
// it, and runs one command at a time.
package rcon
import (
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"strings"
"sync"
"time"
)
const (
typeResponse int32 = 0
typeExec int32 = 2 // a command going out, and the sign-in answer coming back
typeAuth int32 = 3
// A response body this long may continue in the next packet.
fullBody = 4000
// ARK never sends anything near this; it only guards against a garbled length.
maxPacket = 1 << 20
// How long to wait for a continuation packet before deciding the response is complete.
continuation = 250 * time.Millisecond
)
// ErrAuth is returned when the server rejects the password.
var ErrAuth = errors.New("rcon: wrong password")
// Client talks to one server. It is safe for concurrent use; commands are sent one at a time.
type Client struct {
Addr string // host:port
Password string //
Timeout time.Duration // per dial and per command, default 5s
mu sync.Mutex
conn net.Conn
id int32
}
// New returns a client that has not connected yet.
func New(addr, password string) *Client {
return &Client{Addr: addr, Password: password, Timeout: 5 * time.Second}
}
// Close drops the connection. The client can be used again afterwards.
func (c *Client) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
return c.closeLocked()
}
func (c *Client) closeLocked() error {
if c.conn == nil {
return nil
}
err := c.conn.Close()
c.conn = nil
return err
}
// Exec runs one command and returns the server's reply. A connection that went stale while idle
// (the server restarted, a NAT forgot it) is replaced once, transparently.
func (c *Client) Exec(cmd string) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
fresh := false
if c.conn == nil {
if err := c.connect(); err != nil {
return "", err
}
fresh = true
}
out, got, err := c.exec(cmd)
if err == nil {
return out, nil
}
c.closeLocked()
if fresh || got {
return "", err
}
// Nothing came back at all, so this was most likely a dead connection: try once on a new one.
if err := c.connect(); err != nil {
return "", err
}
out, _, err = c.exec(cmd)
if err != nil {
c.closeLocked()
return "", err
}
return out, nil
}
func (c *Client) timeout() time.Duration {
if c.Timeout > 0 {
return c.Timeout
}
return 5 * time.Second
}
func (c *Client) connect() error {
conn, err := net.DialTimeout("tcp", c.Addr, c.timeout())
if err != nil {
return err
}
conn.SetDeadline(time.Now().Add(c.timeout()))
if err := writePacket(conn, 1, typeAuth, c.Password); err != nil {
conn.Close()
return err
}
// Servers may send an empty response before the sign-in answer.
for i := 0; i < 4; i++ {
p, err := readPacket(conn)
if err != nil {
conn.Close()
return fmt.Errorf("rcon: sign-in: %w", err)
}
if p.typ != typeExec {
continue
}
if p.id == -1 {
conn.Close()
return ErrAuth
}
c.conn = conn
return nil
}
conn.Close()
return errors.New("rcon: the server never answered the sign-in")
}
// exec reports whether any packet arrived, so Exec knows if a retry is safe.
func (c *Client) exec(cmd string) (body string, got bool, err error) {
c.id++
if c.id < 2 {
c.id = 2
}
id := c.id
c.conn.SetDeadline(time.Now().Add(c.timeout()))
if err := writePacket(c.conn, id, typeExec, cmd); err != nil {
return "", false, err
}
var sb strings.Builder
for {
p, err := readPacket(c.conn)
if err != nil {
return "", got, err
}
got = true
if p.id != id || p.typ != typeResponse {
continue // left over from an earlier command
}
sb.WriteString(p.body)
if len(p.body) < fullBody {
return sb.String(), true, nil
}
break
}
// A full-size packet: the reply may go on. There is no end marker, so wait briefly for more.
for {
c.conn.SetReadDeadline(time.Now().Add(continuation))
p, err := readPacket(c.conn)
if err != nil {
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
break
}
return "", true, err
}
if p.id != id || p.typ != typeResponse {
continue
}
sb.WriteString(p.body)
if len(p.body) < fullBody {
break
}
}
return sb.String(), true, nil
}
type packet struct {
id, typ int32
body string
}
func writePacket(w io.Writer, id, typ int32, body string) error {
buf := make([]byte, 4+4+4+len(body)+2)
binary.LittleEndian.PutUint32(buf[0:], uint32(4+4+len(body)+2))
binary.LittleEndian.PutUint32(buf[4:], uint32(id))
binary.LittleEndian.PutUint32(buf[8:], uint32(typ))
copy(buf[12:], body)
_, err := w.Write(buf)
return err
}
func readPacket(r io.Reader) (packet, error) {
var hdr [4]byte
if _, err := io.ReadFull(r, hdr[:]); err != nil {
return packet{}, err
}
size := int(int32(binary.LittleEndian.Uint32(hdr[:])))
if size < 10 || size > maxPacket {
return packet{}, fmt.Errorf("rcon: bad packet length %d", size)
}
buf := make([]byte, size)
if _, err := io.ReadFull(r, buf); err != nil {
return packet{}, err
}
return packet{
id: int32(binary.LittleEndian.Uint32(buf[0:])),
typ: int32(binary.LittleEndian.Uint32(buf[4:])),
body: strings.ToValidUTF8(string(buf[8:size-2]), "�"),
}, nil
}
+137
View File
@@ -0,0 +1,137 @@
package rcon
import (
"errors"
"net"
"strings"
"sync/atomic"
"testing"
)
// fakeServer speaks enough of the protocol to test the client: sign-in, then one reply per
// command, split into 4000-byte packets the way real servers do.
type fakeServer struct {
ln net.Listener
password string
handler func(cmd string) string
conns atomic.Int32
dropAt int32 // close each connection after this many commands (0 = never)
}
func serve(t *testing.T, password string, h func(string) string) *fakeServer {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
f := &fakeServer{ln: ln, password: password, handler: h}
t.Cleanup(func() { ln.Close() })
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
f.conns.Add(1)
go f.handle(c)
}
}()
return f
}
func (f *fakeServer) handle(c net.Conn) {
defer c.Close()
p, err := readPacket(c)
if err != nil || p.typ != typeAuth {
return
}
writePacket(c, p.id, typeResponse, "")
if p.body != f.password {
writePacket(c, -1, typeExec, "")
return
}
writePacket(c, p.id, typeExec, "")
var n int32
for {
p, err := readPacket(c)
if err != nil {
return
}
reply := f.handler(p.body)
for {
chunk := reply
if len(chunk) > fullBody {
chunk = reply[:fullBody]
}
writePacket(c, p.id, typeResponse, chunk)
reply = reply[len(chunk):]
if reply == "" {
break
}
}
n++
if f.dropAt > 0 && n >= f.dropAt {
return
}
}
}
func (f *fakeServer) addr() string { return f.ln.Addr().String() }
func TestExec(t *testing.T) {
f := serve(t, "pw", func(cmd string) string { return "you said " + cmd })
c := New(f.addr(), "pw")
defer c.Close()
for _, cmd := range []string{"ListPlayers", "GetChat"} {
out, err := c.Exec(cmd)
if err != nil || out != "you said "+cmd {
t.Fatalf("%s: got %q, %v", cmd, out, err)
}
}
if f.conns.Load() != 1 {
t.Fatalf("the connection should be reused, saw %d", f.conns.Load())
}
}
func TestWrongPassword(t *testing.T) {
f := serve(t, "pw", func(string) string { return "" })
c := New(f.addr(), "nope")
if _, err := c.Exec("ListPlayers"); !errors.Is(err, ErrAuth) {
t.Fatalf("want ErrAuth, got %v", err)
}
}
func TestLongReplyArrivesWhole(t *testing.T) {
long := strings.Repeat("0123456789", 1050) // 10500 bytes: three packets
f := serve(t, "pw", func(string) string { return long })
c := New(f.addr(), "pw")
defer c.Close()
out, err := c.Exec("GetChat")
if err != nil || out != long {
t.Fatalf("got %d bytes, %v", len(out), err)
}
}
func TestReconnectsAfterDrop(t *testing.T) {
f := serve(t, "pw", func(string) string { return "ok" })
f.dropAt = 1
c := New(f.addr(), "pw")
defer c.Close()
for i := 0; i < 3; i++ {
if out, err := c.Exec("ListPlayers"); err != nil || out != "ok" {
t.Fatalf("run %d: got %q, %v", i, out, err)
}
}
if f.conns.Load() < 3 {
t.Fatalf("expected a new connection per command, saw %d", f.conns.Load())
}
}
func TestUnreachable(t *testing.T) {
ln, _ := net.Listen("tcp", "127.0.0.1:0")
addr := ln.Addr().String()
ln.Close()
if _, err := New(addr, "pw").Exec("x"); err == nil {
t.Fatal("expected a connection error")
}
}
+500
View File
@@ -0,0 +1,500 @@
package web
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"git.veillax.com/Veillax/megalodon/internal/ark"
"git.veillax.com/Veillax/megalodon/internal/config"
"git.veillax.com/Veillax/megalodon/internal/manager"
)
func (s *Server) me(w http.ResponseWriter, r *http.Request, p principal) {
type macro struct {
Name string `json:"name"`
}
var macros []macro
for _, m := range s.cfg.Get().Macros {
if p.Role.Allows(m.Role) {
macros = append(macros, macro{m.Name})
}
}
writeJSON(w, map[string]any{"name": p.Name, "role": p.Role, "version": s.version, "macros": macros, "reference": ark.Reference})
}
// ---- servers ----
func (s *Server) listServers(w http.ResponseWriter, r *http.Request, p principal) {
writeJSON(w, s.m.Views())
}
var nonID = regexp.MustCompile(`[^a-z0-9]+`)
func (s *Server) addServer(w http.ResponseWriter, r *http.Request, p principal) {
var in config.Server
if !decode(w, r, &in) {
return
}
if in.ID == "" {
in.ID = strings.Trim(nonID.ReplaceAllString(strings.ToLower(in.Name), "-"), "-")
}
err := s.cfg.Update(func(c *config.Config) error {
c.Servers = append(c.Servers, in)
return nil
})
if err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
s.m.Reload()
s.m.Audit(p.Name, in.ID, "added server "+in.Name)
writeJSON(w, map[string]string{"id": in.ID})
}
func (s *Server) editServer(w http.ResponseWriter, r *http.Request, p principal) {
id := r.PathValue("id")
var in config.Server
if !decode(w, r, &in) {
return
}
in.ID = id
found := false
err := s.cfg.Update(func(c *config.Config) error {
for k := range c.Servers {
if c.Servers[k].ID == id {
if in.Password == "" { // blank means keep the current one
in.Password = c.Servers[k].Password
}
c.Servers[k], found = in, true
}
}
return nil
})
switch {
case err != nil:
fail(w, http.StatusBadRequest, err.Error())
case !found:
fail(w, http.StatusNotFound, "no such server")
default:
s.m.Reload()
s.m.Audit(p.Name, id, "edited server settings")
writeJSON(w, map[string]bool{"ok": true})
}
}
func (s *Server) deleteServer(w http.ResponseWriter, r *http.Request, p principal) {
id := r.PathValue("id")
err := s.cfg.Update(func(c *config.Config) error {
var keep []config.Server
for _, sv := range c.Servers {
if sv.ID != id {
keep = append(keep, sv)
}
}
c.Servers = keep
for k := range c.Jobs { // a job aimed only at this server would now hit every server
var left []string
for _, sid := range c.Jobs[k].Servers {
if sid != id {
left = append(left, sid)
}
}
if len(c.Jobs[k].Servers) > 0 && len(left) == 0 {
c.Jobs[k].Enabled = false
}
c.Jobs[k].Servers = left
}
return nil
})
if err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
s.m.Reload()
s.m.Audit(p.Name, "", "removed server "+id)
writeJSON(w, map[string]bool{"ok": true})
}
func (s *Server) samples(w http.ResponseWriter, r *http.Request, p principal) {
i, ok := s.m.Instance(r.PathValue("id"))
if !ok {
fail(w, http.StatusNotFound, "no such server")
return
}
writeJSON(w, i.Samples())
}
// ---- actions ----
func (s *Server) serverAction(w http.ResponseWriter, r *http.Request, p principal) {
var a manager.Action
if !decode(w, r, &a) {
return
}
a.Servers = []string{r.PathValue("id")}
s.runAction(w, p, a)
}
func (s *Server) clusterAction(w http.ResponseWriter, r *http.Request, p principal) {
var a manager.Action
if !decode(w, r, &a) {
return
}
s.runAction(w, p, a)
}
func (s *Server) runAction(w http.ResponseWriter, p principal, a manager.Action) {
if !p.Role.Allows(manager.ActionRole(a.Name)) {
fail(w, http.StatusForbidden, "your role cannot do this")
return
}
if a.Minutes < 0 || a.Minutes > 24*60*365 {
fail(w, http.StatusBadRequest, "minutes out of range")
return
}
results, err := s.m.Do(p.Name, a)
if err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, results)
}
func (s *Server) runMacro(w http.ResponseWriter, r *http.Request, p principal) {
var in struct{ Name string }
if !decode(w, r, &in) {
return
}
for _, m := range s.cfg.Get().Macros {
if m.Name == in.Name {
if !p.Role.Allows(m.Role) {
fail(w, http.StatusForbidden, "your role cannot run this macro")
return
}
res, err := s.m.RunMacro(p.Name, r.PathValue("id"), m)
if err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, res)
return
}
}
fail(w, http.StatusNotFound, "no such macro")
}
func (s *Server) runJob(w http.ResponseWriter, r *http.Request, p principal) {
for _, j := range s.cfg.Get().Jobs {
if j.ID == r.PathValue("id") {
writeJSON(w, s.m.RunJob(j, p.Name+" (run now)"))
return
}
}
fail(w, http.StatusNotFound, "no such job")
}
func (s *Server) testNotify(w http.ResponseWriter, r *http.Request, p principal) {
var in struct{ Name string }
if !decode(w, r, &in) {
return
}
if err := s.m.TestNotify(in.Name); err != nil {
fail(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, map[string]bool{"ok": true})
}
// ---- events, players, bans ----
func (s *Server) events(w http.ResponseWriter, r *http.Request, p principal) {
q := r.URL.Query()
f := manager.Filter{Server: q.Get("server"), Query: q.Get("q")}
if t := q.Get("type"); t != "" {
f.Types = strings.Split(t, ",")
}
f.Before, _ = strconv.ParseInt(q.Get("before"), 10, 64)
f.Limit, _ = strconv.Atoi(q.Get("limit"))
// Console audit lines can hold commands with sensitive values; only moderators and up see them.
events := s.m.Hub().Recent(f)
if !p.Role.Allows(config.Moderator) {
kept := events[:0]
for _, e := range events {
if e.Type != "audit" && e.Type != "error" {
kept = append(kept, e)
}
}
events = kept
}
writeJSON(w, events)
}
func (s *Server) stream(w http.ResponseWriter, r *http.Request, p principal) {
fl, ok := w.(http.Flusher)
if !ok {
fail(w, http.StatusInternalServerError, "streaming not supported")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("X-Accel-Buffering", "no")
ch, cancel := s.m.Hub().Subscribe()
defer cancel()
fmt.Fprint(w, ": connected\n\n")
fl.Flush()
ping := time.NewTicker(20 * time.Second)
defer ping.Stop()
for {
select {
case <-r.Context().Done():
return
case <-ping.C:
fmt.Fprint(w, ": ping\n\n")
fl.Flush()
case e := <-ch:
if !p.Role.Allows(config.Moderator) && (e.Type == "audit" || e.Type == "error") {
continue
}
b, _ := jsonBytes(e)
fmt.Fprintf(w, "data: %s\n\n", b)
fl.Flush()
}
}
}
func (s *Server) players(w http.ResponseWriter, r *http.Request, p principal) {
recs := s.m.Players()
if !p.Role.Allows(config.Moderator) {
for k := range recs {
recs[k].Notes = "" // moderator notes stay between moderators
}
}
writeJSON(w, recs)
}
func (s *Server) annotate(w http.ResponseWriter, r *http.Request, p principal) {
var in struct {
Notes string `json:"notes"`
Watch bool `json:"watch"`
}
if !decode(w, r, &in) {
return
}
if err := s.m.Annotate(r.PathValue("id"), in.Notes, in.Watch); err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, map[string]bool{"ok": true})
}
func (s *Server) bans(w http.ResponseWriter, r *http.Request, p principal) {
writeJSON(w, s.m.Bans())
}
// ---- settings ----
type settings struct {
Jobs []config.Job `json:"jobs"`
Macros []config.Macro `json:"macros"`
Notify []config.Notify `json:"notify"`
Welcome config.Welcome `json:"welcome"`
ChatFilter config.ChatFilter `json:"chat_filter"`
}
func (s *Server) getSettings(w http.ResponseWriter, r *http.Request, p principal) {
c := s.cfg.Get()
writeJSON(w, settings{c.Jobs, c.Macros, c.Notify, c.Welcome, c.ChatFilter})
}
func (s *Server) putSettings(w http.ResponseWriter, r *http.Request, p principal) {
var in settings
if !decode(w, r, &in) {
return
}
for k := range in.Jobs {
if in.Jobs[k].ID == "" {
b := make([]byte, 4)
rand.Read(b)
in.Jobs[k].ID = hex.EncodeToString(b)
}
}
err := s.cfg.Update(func(c *config.Config) error {
c.Jobs, c.Macros, c.Notify, c.Welcome, c.ChatFilter = in.Jobs, in.Macros, in.Notify, in.Welcome, in.ChatFilter
return nil
})
if err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
s.m.Audit(p.Name, "", "changed schedules, macros, notifications or filters")
c := s.cfg.Get()
writeJSON(w, settings{c.Jobs, c.Macros, c.Notify, c.Welcome, c.ChatFilter})
}
// ---- users and tokens ----
func (s *Server) listUsers(w http.ResponseWriter, r *http.Request, p principal) {
type user struct {
Name string `json:"name"`
Role config.Role `json:"role"`
}
out := []user{}
for _, u := range s.cfg.Get().Users {
out = append(out, user{u.Name, u.Role})
}
writeJSON(w, out)
}
func (s *Server) saveUser(w http.ResponseWriter, r *http.Request, p principal) {
var in struct {
Name string `json:"name"`
Role config.Role `json:"role"`
Password string `json:"password"`
}
if !decode(w, r, &in) {
return
}
in.Name = strings.TrimSpace(in.Name)
err := s.cfg.Update(func(c *config.Config) error {
for k := range c.Users {
if c.Users[k].Name == in.Name {
if c.Users[k].Role == config.Admin && in.Role != config.Admin && countAdmins(c) == 1 {
return fmt.Errorf("there must be at least one admin")
}
c.Users[k].Role = in.Role
if in.Password != "" {
h, err := newHash(in.Password)
if err != nil {
return err
}
c.Users[k].Hash = h
}
return nil
}
}
h, err := newHash(in.Password)
if err != nil {
return err
}
c.Users = append(c.Users, config.User{Name: in.Name, Role: in.Role, Hash: h})
return nil
})
if err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
s.m.Audit(p.Name, "", "saved user "+in.Name+" ("+string(in.Role)+")")
writeJSON(w, map[string]bool{"ok": true})
}
func newHash(password string) (string, error) {
if len(password) < 8 {
return "", fmt.Errorf("the password must be at least 8 characters")
}
return config.HashPassword(password)
}
func countAdmins(c *config.Config) int {
n := 0
for _, u := range c.Users {
if u.Role == config.Admin {
n++
}
}
return n
}
func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request, p principal) {
name := r.PathValue("name")
err := s.cfg.Update(func(c *config.Config) error {
var keep []config.User
for _, u := range c.Users {
if u.Name != name {
keep = append(keep, u)
}
}
c.Users = keep
if countAdmins(c) == 0 {
return fmt.Errorf("there must be at least one admin")
}
return nil
})
if err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
s.mu.Lock()
for k, sess := range s.sessions {
if sess.user == name {
delete(s.sessions, k)
}
}
s.mu.Unlock()
s.m.Audit(p.Name, "", "deleted user "+name)
writeJSON(w, map[string]bool{"ok": true})
}
func (s *Server) listTokens(w http.ResponseWriter, r *http.Request, p principal) {
type token struct {
Name string `json:"name"`
Role config.Role `json:"role"`
}
out := []token{}
for _, t := range s.cfg.Get().Tokens {
out = append(out, token{t.Name, t.Role})
}
writeJSON(w, out)
}
func (s *Server) newToken(w http.ResponseWriter, r *http.Request, p principal) {
var in struct {
Name string `json:"name"`
Role config.Role `json:"role"`
}
if !decode(w, r, &in) {
return
}
token, hash, err := config.NewToken()
if err == nil {
err = s.cfg.Update(func(c *config.Config) error {
for _, t := range c.Tokens {
if t.Name == in.Name {
return fmt.Errorf("a token named %q already exists", in.Name)
}
}
c.Tokens = append(c.Tokens, config.Token{Name: in.Name, Role: in.Role, Hash: hash})
return nil
})
}
if err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
s.m.Audit(p.Name, "", "created API token "+in.Name+" ("+string(in.Role)+")")
writeJSON(w, map[string]string{"token": token}) // the only time it is shown
}
func (s *Server) deleteToken(w http.ResponseWriter, r *http.Request, p principal) {
name := r.PathValue("name")
err := s.cfg.Update(func(c *config.Config) error {
var keep []config.Token
for _, t := range c.Tokens {
if t.Name != name {
keep = append(keep, t)
}
}
c.Tokens = keep
return nil
})
if err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
s.m.Audit(p.Name, "", "deleted API token "+name)
writeJSON(w, map[string]bool{"ok": true})
}
+314
View File
@@ -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) }
+656
View File
@@ -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();
+15
View File
@@ -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>
+93
View File
@@ -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; }
+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)
}