Files
VeillaxandClaude Sonnet 5.5 0d2cb31abf
CI / test (push) Successful in 2m26s
Release / release (push) Successful in 2m36s
Megalodon: manage ARK servers over RCON from a web dashboard
Watches any number of ARK:SE servers (RCON for players and chat, the query
port for map, version and player cap), records joins, leaves, chat and an
audit trail, and keeps a player database across servers.

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

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

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 23:36:13 -06:00

152 lines
3.5 KiB
Go

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
}