Megalodon: manage ARK servers over RCON from a web dashboard
Watches any number of ARK:SE servers (RCON for players and chat, the query port for map, version and player cap), records joins, leaves, chat and an audit trail, and keeps a player database across servers. Control: save, broadcast, chat, whisper, kick, time of day, MOTD, wild dino wipe, a raw console with history and macros, restarts with timed warnings and a world save, optional start command and auto-start. Bans are kept by Megalodon: timed, cluster-wide and re-enforced when the player returns. Automation: schedules (every N or at times of day), Discord/ntfy/webhook notifications, welcome message, chat word filter. Access: viewer, moderator and admin roles, API tokens, Prometheus metrics. cmd/fakeark simulates a server for trying it out. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
0d2cb31abf
32 files changed
+6280
No files matched your search
@@ -0,0 +1,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
|
||||
}
|
||||
Reference in new issue
Block a user