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>
93 lines
2.3 KiB
Go
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
|
|
}
|