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

+184
View File
@@ -0,0 +1,184 @@
// Command fakeark pretends to be an ARK server's RCON and query ports, so Megalodon can be tried
// (and its dashboard looked at) without a real server. Players come and go, and chat appears.
package main
import (
"encoding/binary"
"flag"
"fmt"
"io"
"log"
"math/rand"
"net"
"strings"
"sync"
"time"
)
type server struct {
mu sync.Mutex
name string
password string
online map[string]string // steam id -> name
chat []string
pool [][2]string
}
func main() {
rconAddr := flag.String("rcon", "127.0.0.1:27020", "RCON listen address")
query := flag.String("query", "127.0.0.1:27015", "query (UDP) listen address, empty to disable")
password := flag.String("password", "secret", "RCON password")
name := flag.String("name", "Fake Island", "server name")
flag.Parse()
s := &server{name: *name, password: *password, online: map[string]string{}}
for i, n := range []string{"Alex", "Bo", "Casey", "Dana", "Eli", "Fern", "Gus", "Hana"} {
s.pool = append(s.pool, [2]string{fmt.Sprintf("7656119800000%04d", i+1), n})
}
go s.simulate()
if *query != "" {
go s.serveQuery(*query)
}
ln, err := net.Listen("tcp", *rconAddr)
if err != nil {
log.Fatal(err)
}
log.Printf("fake ARK server %q: RCON %s (password %q), query %s", *name, *rconAddr, *password, *query)
for {
c, err := ln.Accept()
if err != nil {
log.Fatal(err)
}
go s.handle(c)
}
}
func (s *server) simulate() {
lines := []string{"hello everyone", "anyone want to breed rexes?", "my base got raided", "gg", "brb food"}
for range time.Tick(6 * time.Second) {
s.mu.Lock()
p := s.pool[rand.Intn(len(s.pool))]
if _, on := s.online[p[0]]; on {
if rand.Intn(2) == 0 {
delete(s.online, p[0])
} else {
s.chat = append(s.chat, fmt.Sprintf("%s (%s the Tamer): %s", p[1], p[1], lines[rand.Intn(len(lines))]))
}
} else {
s.online[p[0]] = p[1]
}
s.mu.Unlock()
}
}
func (s *server) run(cmd string) string {
s.mu.Lock()
defer s.mu.Unlock()
verb, rest, _ := strings.Cut(cmd, " ")
switch strings.ToLower(verb) {
case "listplayers":
if len(s.online) == 0 {
return "No Players Connected \n"
}
var sb strings.Builder
n := 0
for id, name := range s.online {
fmt.Fprintf(&sb, "%d. %s, %s\n", n, name, id)
n++
}
return sb.String()
case "getchat":
if len(s.chat) == 0 {
return "Server received, But no response!!\n"
}
out := strings.Join(s.chat, "\n") + "\n"
s.chat = nil
return out
case "saveworld":
return "World Saved\n"
case "broadcast", "serverchat":
if strings.ToLower(verb) == "serverchat" {
s.chat = append(s.chat, "SERVER: "+rest)
}
return "Server received, But no response!!\n"
case "kickplayer", "banplayer":
delete(s.online, strings.TrimSpace(rest))
return fmt.Sprintf("%s %s\n", verb, rest)
}
return "Server received, But no response!!\n"
}
func (s *server) handle(c net.Conn) {
defer c.Close()
id, typ, body, err := read(c)
if err != nil || typ != 3 {
return
}
write(c, id, 0, "")
if body != s.password {
write(c, -1, 2, "")
return
}
write(c, id, 2, "")
for {
id, _, cmd, err := read(c)
if err != nil {
return
}
write(c, id, 0, s.run(cmd))
}
}
func write(w io.Writer, id, typ int32, body string) {
buf := make([]byte, 12+len(body)+2)
binary.LittleEndian.PutUint32(buf, 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 read(r io.Reader) (id, typ int32, body string, err error) {
var h [4]byte
if _, err = io.ReadFull(r, h[:]); err != nil {
return
}
n := binary.LittleEndian.Uint32(h[:])
if n < 10 || n > 1<<16 {
return 0, 0, "", io.ErrUnexpectedEOF
}
buf := make([]byte, n)
if _, err = io.ReadFull(r, buf); err != nil {
return
}
return int32(binary.LittleEndian.Uint32(buf)), int32(binary.LittleEndian.Uint32(buf[4:])), string(buf[8 : n-2]), nil
}
// serveQuery answers A2S_INFO with a challenge first, like a real server.
func (s *server) serveQuery(addr string) {
pc, err := net.ListenPacket("udp", addr)
if err != nil {
log.Print("query: ", err)
return
}
buf := make([]byte, 100)
for {
n, from, err := pc.ReadFrom(buf)
if err != nil {
return
}
if n <= 25 {
pc.WriteTo([]byte{0xff, 0xff, 0xff, 0xff, 0x41, 1, 2, 3, 4}, from)
continue
}
s.mu.Lock()
players := len(s.online)
s.mu.Unlock()
reply := []byte{0xff, 0xff, 0xff, 0xff, 0x49, 17}
for _, v := range []string{s.name + " - (v350.2)", "TheIsland", "ark_survival_evolved", "ARK: Survival Evolved"} {
reply = append(append(reply, v...), 0)
}
reply = append(reply, 0, 0, byte(players), 70, 0, 'd', 'w', 0, 1, '1', '.', '0', 0)
pc.WriteTo(reply, from)
}
}
+283
View File
@@ -0,0 +1,283 @@
// Command megalodon manages ARK: Survival Evolved servers over RCON, from a web dashboard.
package main
import (
"bufio"
"context"
"errors"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"git.veillax.com/Veillax/megalodon/internal/ark"
"git.veillax.com/Veillax/megalodon/internal/config"
"git.veillax.com/Veillax/megalodon/internal/manager"
"git.veillax.com/Veillax/megalodon/internal/rcon"
"git.veillax.com/Veillax/megalodon/internal/web"
)
var version = "dev"
const usage = `megalodon manages ARK: Survival Evolved servers over RCON.
megalodon serve [-config megalodon.json] run the dashboard and watch the servers
megalodon user [-config ...] NAME [ROLE] add a user or change a password (ROLE: viewer, moderator, admin)
megalodon token [-config ...] NAME [ROLE] create an API token (shown once)
megalodon rcon -addr HOST:PORT -password PW [COMMAND...]
run one console command, or open a prompt
megalodon version
The password for "user" is read from MEGALODON_PASSWORD or, failing that, from a line on stdin.
The configuration path can also be set with MEGALODON_CONFIG.
`
func main() {
log.SetFlags(log.LstdFlags)
if len(os.Args) < 2 {
fmt.Fprint(os.Stderr, usage)
os.Exit(2)
}
var err error
switch os.Args[1] {
case "serve":
err = serve(os.Args[2:])
case "user":
err = userCmd(os.Args[2:])
case "token":
err = tokenCmd(os.Args[2:])
case "rcon":
err = rconCmd(os.Args[2:])
case "version", "-v", "--version":
fmt.Println("megalodon", version)
case "help", "-h", "--help":
fmt.Print(usage)
default:
fmt.Fprintf(os.Stderr, "unknown command %q\n\n%s", os.Args[1], usage)
os.Exit(2)
}
if err != nil {
fmt.Fprintln(os.Stderr, "megalodon:", err)
os.Exit(1)
}
}
func configFlag(fs *flag.FlagSet) *string {
def := os.Getenv("MEGALODON_CONFIG")
if def == "" {
def = "megalodon.json"
}
return fs.String("config", def, "path of the configuration file")
}
func serve(args []string) error {
fs := flag.NewFlagSet("serve", flag.ExitOnError)
path := configFlag(fs)
listen := fs.String("listen", "", "override the listen address")
fs.Parse(args)
h, err := config.Load(*path)
if err != nil {
return err
}
// First run: make an admin so the dashboard can be signed in to, and show the password once.
if len(h.Get().Users) == 0 {
pw := config.RandomPassword()
hash, err := config.HashPassword(pw)
if err != nil {
return err
}
if err := h.Update(func(c *config.Config) error {
c.Users = append(c.Users, config.User{Name: "admin", Role: config.Admin, Hash: hash})
return nil
}); err != nil {
return err
}
log.Printf("first run: created user \"admin\" with password %s (change it in Settings → Users; written to %s)", pw, *path)
} else if !h.Exists() {
h.Update(func(*config.Config) error { return nil })
}
cfg := h.Get()
if *listen != "" {
cfg.Listen = *listen
}
m, err := manager.New(h)
if err != nil {
return err
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
m.Start(ctx)
srv := &http.Server{
Addr: cfg.Listen,
Handler: web.New(h, m, version).Handler(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
IdleTimeout: 2 * time.Minute,
// No write timeout: the live event stream stays open.
}
errc := make(chan error, 1)
go func() { errc <- srv.ListenAndServe() }()
log.Printf("megalodon %s watching %d server(s); dashboard on http://%s", version, len(cfg.Servers), cfg.Listen)
if !strings.HasPrefix(cfg.Listen, "127.") && !strings.HasPrefix(cfg.Listen, "localhost") && !strings.HasPrefix(cfg.Listen, "[::1]") {
log.Print("note: the dashboard is not on a loopback address; put it behind HTTPS (a reverse proxy or Hammerhead) and set cookie_secure")
}
select {
case err := <-errc:
stop()
m.Wait()
return err
case <-ctx.Done():
}
sctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
srv.Shutdown(sctx)
m.Wait()
return nil
}
func readPassword() (string, error) {
if pw := os.Getenv("MEGALODON_PASSWORD"); pw != "" {
return pw, nil
}
fmt.Fprint(os.Stderr, "password: ")
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil && line == "" {
return "", errors.New("no password given")
}
return strings.TrimRight(line, "\r\n"), nil
}
func nameAndRole(fs *flag.FlagSet, def config.Role) (string, config.Role, error) {
if fs.NArg() < 1 || fs.NArg() > 2 {
return "", "", errors.New("expected NAME [ROLE]")
}
role := def
if fs.NArg() == 2 {
role = config.Role(fs.Arg(1))
}
if role.Rank() == 0 {
return "", "", errors.New("ROLE must be viewer, moderator or admin")
}
return fs.Arg(0), role, nil
}
func userCmd(args []string) error {
fs := flag.NewFlagSet("user", flag.ExitOnError)
path := configFlag(fs)
fs.Parse(args)
name, role, err := nameAndRole(fs, "")
if err != nil && fs.NArg() == 1 {
role, err = "", nil // changing an existing user's password
}
if err != nil {
return err
}
pw, err := readPassword()
if err != nil {
return err
}
if len(pw) < 8 {
return errors.New("the password must be at least 8 characters")
}
hash, err := config.HashPassword(pw)
if err != nil {
return err
}
h, err := config.Load(*path)
if err != nil {
return err
}
err = h.Update(func(c *config.Config) error {
for k := range c.Users {
if c.Users[k].Name == name {
c.Users[k].Hash = hash
if role != "" {
c.Users[k].Role = role
}
return nil
}
}
if role == "" {
return fmt.Errorf("no user %q: give a ROLE to create one", name)
}
c.Users = append(c.Users, config.User{Name: name, Role: role, Hash: hash})
return nil
})
if err == nil {
fmt.Println("saved user", name)
}
return err
}
func tokenCmd(args []string) error {
fs := flag.NewFlagSet("token", flag.ExitOnError)
path := configFlag(fs)
fs.Parse(args)
name, role, err := nameAndRole(fs, config.Viewer)
if err != nil {
return err
}
token, hash, err := config.NewToken()
if err != nil {
return err
}
h, err := config.Load(*path)
if err != nil {
return err
}
err = h.Update(func(c *config.Config) error {
c.Tokens = append(c.Tokens, config.Token{Name: name, Role: role, Hash: hash})
return nil
})
if err == nil {
fmt.Println(token)
fmt.Fprintln(os.Stderr, "this is the only time the token is shown; send it as 'Authorization: Bearer <token>'")
}
return err
}
func rconCmd(args []string) error {
fs := flag.NewFlagSet("rcon", flag.ExitOnError)
addr := fs.String("addr", "127.0.0.1:27020", "server host:port")
pw := fs.String("password", os.Getenv("MEGALODON_RCON_PASSWORD"), "RCON password (or MEGALODON_RCON_PASSWORD)")
fs.Parse(args)
c := rcon.New(*addr, *pw)
defer c.Close()
run := func(cmd string) error {
out, err := c.Exec(cmd)
if err != nil {
return err
}
if !ark.IsEmptyReply(out) {
fmt.Println(strings.TrimRight(out, "\r\n\x00"))
}
return nil
}
if fs.NArg() > 0 {
return run(strings.Join(fs.Args(), " "))
}
fmt.Fprintln(os.Stderr, "connected to", *addr, "- type a command, or exit")
in := bufio.NewScanner(os.Stdin)
for fmt.Fprint(os.Stderr, "> "); in.Scan(); fmt.Fprint(os.Stderr, "> ") {
line := strings.TrimSpace(in.Text())
if line == "exit" || line == "quit" {
break
}
if line == "" {
continue
}
if err := run(line); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
}
}
return nil
}