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,92 @@
|
||||
package ark
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"net"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Info is what a server tells the Steam server browser (A2S_INFO).
|
||||
type Info struct {
|
||||
Name string `json:"name"`
|
||||
Map string `json:"map"`
|
||||
Game string `json:"game"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Players int `json:"players"`
|
||||
MaxPlayers int `json:"max_players"`
|
||||
PingMS int64 `json:"ping_ms"`
|
||||
}
|
||||
|
||||
var a2sRequest = []byte("\xff\xff\xff\xffTSource Engine Query\x00")
|
||||
|
||||
// ARK puts its version in the server name: "My Server - (v349.7)".
|
||||
var nameVersion = regexp.MustCompile(`\(v(\d+(?:\.\d+)*)\)`)
|
||||
|
||||
// Query asks the game's query port (UDP, usually 27015) who is on the server, without needing the
|
||||
// RCON password.
|
||||
func Query(addr string, timeout time.Duration) (*Info, error) {
|
||||
conn, err := net.Dial("udp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
start := time.Now()
|
||||
conn.SetDeadline(start.Add(timeout))
|
||||
if _, err := conn.Write(a2sRequest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buf := make([]byte, 1400)
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n >= 9 && buf[4] == 0x41 { // challenge: ask again with the number it gave
|
||||
req := append(append([]byte{}, a2sRequest...), buf[5:9]...)
|
||||
if _, err := conn.Write(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n, err = conn.Read(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
info, err := parseInfo(buf[:n])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info.PingMS = time.Since(start).Milliseconds()
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func parseInfo(b []byte) (*Info, error) {
|
||||
if len(b) < 6 || !bytes.Equal(b[:4], []byte{0xff, 0xff, 0xff, 0xff}) || b[4] != 0x49 {
|
||||
return nil, errors.New("not an A2S_INFO reply")
|
||||
}
|
||||
r := b[6:] // skip the header and the protocol byte
|
||||
str := func() string {
|
||||
i := bytes.IndexByte(r, 0)
|
||||
if i < 0 {
|
||||
s := string(r)
|
||||
r = nil
|
||||
return s
|
||||
}
|
||||
s := string(r[:i])
|
||||
r = r[i+1:]
|
||||
return s
|
||||
}
|
||||
var in Info
|
||||
in.Name, in.Map = str(), str()
|
||||
str() // folder
|
||||
in.Game = str()
|
||||
if len(r) < 9 {
|
||||
return nil, errors.New("short A2S_INFO reply")
|
||||
}
|
||||
in.Players, in.MaxPlayers = int(r[2]), int(r[3])
|
||||
r = r[9:] // app id (2), players, max, bots, type, environment, visibility, VAC
|
||||
in.Version = str()
|
||||
if m := nameVersion.FindStringSubmatch(in.Name); m != nil {
|
||||
in.Version = m[1]
|
||||
}
|
||||
return &in, nil
|
||||
}
|
||||
Reference in new issue
Block a user