first commit
This commit is contained in:
341
bedrock/bedrock.go
Normal file
341
bedrock/bedrock.go
Normal file
@@ -0,0 +1,341 @@
|
||||
package bedrock
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/VexoraDevelopment/mcstatus/info"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Timeout time.Duration
|
||||
|
||||
EnableSRV bool
|
||||
|
||||
Resolver *net.Resolver
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
opt Options
|
||||
}
|
||||
|
||||
func New(opt Options) *Client {
|
||||
if opt.Timeout <= 0 {
|
||||
opt.Timeout = 2 * time.Second
|
||||
}
|
||||
if opt.Resolver == nil {
|
||||
opt.Resolver = net.DefaultResolver
|
||||
}
|
||||
return &Client{opt: opt}
|
||||
}
|
||||
|
||||
func (c *Client) Connect(address string, port int) (*info.ServerInfo, error) {
|
||||
if port == 0 {
|
||||
port = 19132
|
||||
}
|
||||
|
||||
host := address
|
||||
p := port
|
||||
|
||||
if c.opt.EnableSRV && net.ParseIP(host) == nil {
|
||||
if rh, rp, err := c.resolveMinecraftSRV(host); err == nil && rh != "" {
|
||||
host = rh
|
||||
if rp != 0 {
|
||||
p = rp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if info, err := c.QueryFull(host, p); err == nil {
|
||||
return info, nil
|
||||
}
|
||||
|
||||
return c.PingRaknet(host, p)
|
||||
}
|
||||
|
||||
func (c *Client) QueryFull(address string, port int) (*info.ServerInfo, error) {
|
||||
conn, _, err := c.dialUDP(address, port)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
chalPayload, err := c.writeQueryPacket(conn, 0x09, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
chalStr := strings.TrimRight(string(chalPayload), "\x00\r\n\t ")
|
||||
chalInt, err := strconv.Atoi(chalStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query challenge parse failed: %w (payload=%q)", err, chalStr)
|
||||
}
|
||||
|
||||
chal := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(chal, uint32(chalInt))
|
||||
|
||||
appendData := make([]byte, 0, 8)
|
||||
appendData = append(appendData, chal...)
|
||||
appendData = append(appendData, 0x00, 0x00, 0x00, 0x00)
|
||||
|
||||
data, err := c.writeQueryPacket(conn, 0x00, appendData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(data) < 11 {
|
||||
return nil, errors.New("query full stat response too short")
|
||||
}
|
||||
payload := data[11:]
|
||||
|
||||
marker := []byte{0x00, 0x00, 0x01, 'p', 'l', 'a', 'y', 'e', 'r', '_', 0x00, 0x00}
|
||||
parts := bytes.SplitN(payload, marker, 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, errors.New("query full stat parse failed: marker not found")
|
||||
}
|
||||
|
||||
kvPart := parts[0]
|
||||
playersPart := parts[1]
|
||||
|
||||
if len(playersPart) >= 2 {
|
||||
playersPart = playersPart[:len(playersPart)-2]
|
||||
}
|
||||
|
||||
raw := map[string]string{}
|
||||
kvs := bytes.Split(kvPart, []byte{0x00})
|
||||
|
||||
for i := 0; i+1 < len(kvs); i += 2 {
|
||||
k := string(kvs[i])
|
||||
v := string(kvs[i+1])
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
raw[k] = v
|
||||
}
|
||||
|
||||
var players []string
|
||||
if len(playersPart) > 0 {
|
||||
pp := bytes.Split(playersPart, []byte{0x00})
|
||||
for _, b := range pp {
|
||||
s := string(b)
|
||||
if s != "" {
|
||||
players = append(players, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ip := resolveIP(address)
|
||||
srvInfo := &info.ServerInfo{
|
||||
Edition: info.EditionBedrock,
|
||||
Address: address,
|
||||
IP: ip,
|
||||
Port: intFromMap(raw, "hostport", port),
|
||||
MOTD: raw["hostname"],
|
||||
Version: raw["version"],
|
||||
Game: raw["game_id"],
|
||||
Map: raw["map"],
|
||||
GameMode: raw["gametype"],
|
||||
Online: intFromMap(raw, "numplayers", 0),
|
||||
Slots: intFromMap(raw, "maxplayers", 0),
|
||||
Players: players,
|
||||
Query: true,
|
||||
RawQuery: raw,
|
||||
}
|
||||
|
||||
if pl := raw["plugins"]; pl != "" {
|
||||
parts := strings.SplitN(pl, ": ", 2)
|
||||
srvInfo.Software = parts[0]
|
||||
if len(parts) == 2 {
|
||||
p2 := strings.Split(parts[1], "; ")
|
||||
if len(p2) > 1 {
|
||||
srvInfo.Plugins = p2
|
||||
} else if len(p2) == 1 && p2[0] != "" {
|
||||
srvInfo.Plugins = []string{p2[0]}
|
||||
}
|
||||
}
|
||||
} else if eng := raw["server_engine"]; eng != "" {
|
||||
srvInfo.Software = eng
|
||||
}
|
||||
|
||||
return srvInfo, nil
|
||||
}
|
||||
|
||||
func (c *Client) PingRaknet(address string, port int) (*info.ServerInfo, error) {
|
||||
conn, _, err := c.dialUDP(address, port)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
req := buildUnconnectedPing()
|
||||
|
||||
if err := conn.SetDeadline(time.Now().Add(c.opt.Timeout)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := conn.Write(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
buf := make([]byte, 4096)
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := buf[:n]
|
||||
|
||||
if len(resp) == 0 || resp[0] != 0x1C {
|
||||
return nil, errors.New("raknet pong not received (unexpected response)")
|
||||
}
|
||||
if len(resp) < 35 {
|
||||
return nil, errors.New("raknet pong too short")
|
||||
}
|
||||
|
||||
s := string(resp[35:])
|
||||
s = strings.TrimRight(s, "\x00\r\n\t ")
|
||||
|
||||
parts := strings.Split(s, ";")
|
||||
motd := safeIndex(parts, 1)
|
||||
version := safeIndex(parts, 3)
|
||||
online := atoiSafe(safeIndex(parts, 4))
|
||||
slots := atoiSafe(safeIndex(parts, 5))
|
||||
|
||||
ip := resolveIP(address)
|
||||
srvInfo := &info.ServerInfo{
|
||||
Edition: info.EditionBedrock,
|
||||
Address: address,
|
||||
IP: ip,
|
||||
Port: port,
|
||||
MOTD: motd,
|
||||
Version: version,
|
||||
Online: online,
|
||||
Slots: slots,
|
||||
Query: false,
|
||||
}
|
||||
return srvInfo, nil
|
||||
}
|
||||
|
||||
func (c *Client) dialUDP(host string, port int) (*net.UDPConn, *net.UDPAddr, error) {
|
||||
raddr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", host, port))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
conn, err := net.DialUDP("udp", nil, raddr)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := conn.SetDeadline(time.Now().Add(c.opt.Timeout)); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
return conn, raddr, nil
|
||||
}
|
||||
|
||||
func (c *Client) writeQueryPacket(conn *net.UDPConn, command byte, appendData []byte) ([]byte, error) {
|
||||
header := []byte{0xFE, 0xFD, command, 0x01, 0x02, 0x03, 0x04}
|
||||
packet := make([]byte, 0, len(header)+len(appendData))
|
||||
packet = append(packet, header...)
|
||||
packet = append(packet, appendData...)
|
||||
|
||||
if err := conn.SetDeadline(time.Now().Add(c.opt.Timeout)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := conn.Write(packet); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
buf := make([]byte, 4096)
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := buf[:n]
|
||||
if len(resp) < 5 || resp[0] != command {
|
||||
return nil, errors.New("query response validation failed")
|
||||
}
|
||||
return resp[5:], nil
|
||||
}
|
||||
|
||||
func buildUnconnectedPing() []byte {
|
||||
req := make([]byte, 0, 1+8+16+8)
|
||||
req = append(req, 0x01)
|
||||
|
||||
pingID := rand.New(rand.NewSource(time.Now().UnixNano())).Uint64()
|
||||
tmp := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(tmp, pingID)
|
||||
req = append(req, tmp...)
|
||||
|
||||
magic := []byte{0x00, 0xFF, 0xFF, 0x00, 0xFE, 0xFE, 0xFE, 0xFE, 0xFD, 0xFD, 0xFD, 0xFD, 0x12, 0x34, 0x56, 0x78}
|
||||
req = append(req, magic...)
|
||||
|
||||
binary.BigEndian.PutUint64(tmp, 0)
|
||||
req = append(req, tmp...)
|
||||
return req
|
||||
}
|
||||
|
||||
func (c *Client) resolveMinecraftSRV(domain string) (string, int, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), c.opt.Timeout)
|
||||
defer cancel()
|
||||
|
||||
type srvQ struct{ service, proto string }
|
||||
for _, q := range []srvQ{
|
||||
{service: "minecraft", proto: "udp"},
|
||||
{service: "minecraft", proto: "tcp"},
|
||||
} {
|
||||
_, addrs, err := c.opt.Resolver.LookupSRV(ctx, q.service, q.proto, domain)
|
||||
if err != nil || len(addrs) == 0 {
|
||||
continue
|
||||
}
|
||||
target := strings.TrimSuffix(addrs[0].Target, ".")
|
||||
port := int(addrs[0].Port)
|
||||
if target != "" {
|
||||
return target, port, nil
|
||||
}
|
||||
}
|
||||
return "", 0, errors.New("no minecraft SRV record found")
|
||||
}
|
||||
|
||||
func resolveIP(host string) string {
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return ip.String()
|
||||
}
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil || len(ips) == 0 {
|
||||
return ""
|
||||
}
|
||||
return ips[0].String()
|
||||
}
|
||||
|
||||
func safeIndex(a []string, i int) string {
|
||||
if i < 0 || i >= len(a) {
|
||||
return ""
|
||||
}
|
||||
return a[i]
|
||||
}
|
||||
|
||||
func atoiSafe(s string) int {
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(s))
|
||||
return n
|
||||
}
|
||||
|
||||
func intFromMap(m map[string]string, key string, def int) int {
|
||||
if m == nil {
|
||||
return def
|
||||
}
|
||||
v := strings.TrimSpace(m[key])
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
Reference in New Issue
Block a user