Files
Megalodon/cmd/fakeark/main.go
T
VeillaxandClaude Sonnet 5.5 0d2cb31abf
CI / test (push) Successful in 2m26s
Release / release (push) Successful in 2m36s
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>
2026-10-30 23:36:13 -06:00

185 lines
4.6 KiB
Go

// 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)
}
}