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,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)
|
||||
}
|
||||
}
|
||||
@@ -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