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

+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
}