Files
Megalodon/internal/ark/query.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

93 lines
2.3 KiB
Go

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
}