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,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
|
||||
}
|
||||
Reference in new issue
Block a user