first commit
This commit is contained in:
60
README.md
Normal file
60
README.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# mcstatus
|
||||||
|
|
||||||
|
Small Go library for querying Minecraft Java and Bedrock server status.
|
||||||
|
|
||||||
|
The package is extracted from `DuExpBot/backend/pkg/mcstatus`, but without any app-specific model/database layer.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Java status ping
|
||||||
|
- Java full query
|
||||||
|
- Bedrock RakNet ping
|
||||||
|
- Bedrock full query
|
||||||
|
- Optional SRV lookup
|
||||||
|
- Simple MOTD cleanup helper
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go get github.com/VexoraDevelopment/mcstatus
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/VexoraDevelopment/mcstatus"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
client := mcstatus.NewAll(3 * time.Second)
|
||||||
|
|
||||||
|
info, err := client.Java.Connect("hypixel.net", 25565)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(info.Edition)
|
||||||
|
fmt.Println(info.Address, info.Port)
|
||||||
|
fmt.Println(info.Version)
|
||||||
|
fmt.Println(info.Online, "/", info.Slots)
|
||||||
|
fmt.Println(info.MOTD)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Packages
|
||||||
|
|
||||||
|
- `github.com/VexoraDevelopment/mcstatus`
|
||||||
|
- `github.com/VexoraDevelopment/mcstatus/java`
|
||||||
|
- `github.com/VexoraDevelopment/mcstatus/bedrock`
|
||||||
|
- `github.com/VexoraDevelopment/mcstatus/text`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- No GORM or persistence layer is included.
|
||||||
|
- `ServerInfo` is a plain struct and can be stored however you want.
|
||||||
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
|
||||||
|
}
|
||||||
20
client.go
Normal file
20
client.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package mcstatus
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/VexoraDevelopment/mcstatus/bedrock"
|
||||||
|
"github.com/VexoraDevelopment/mcstatus/java"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
Java *java.Client
|
||||||
|
Bedrock *bedrock.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAll(timeout time.Duration) *Client {
|
||||||
|
return &Client{
|
||||||
|
Java: java.New(java.Options{Timeout: timeout, EnableSRV: true}),
|
||||||
|
Bedrock: bedrock.New(bedrock.Options{Timeout: timeout, EnableSRV: true}),
|
||||||
|
}
|
||||||
|
}
|
||||||
43
info/types.go
Normal file
43
info/types.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package info
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Edition string
|
||||||
|
|
||||||
|
const (
|
||||||
|
EditionJava Edition = "java"
|
||||||
|
EditionBedrock Edition = "bedrock"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServerInfo struct {
|
||||||
|
Edition Edition
|
||||||
|
|
||||||
|
Address string
|
||||||
|
IP string
|
||||||
|
Port int
|
||||||
|
|
||||||
|
MOTD string
|
||||||
|
Version string
|
||||||
|
|
||||||
|
Online int
|
||||||
|
Slots int
|
||||||
|
|
||||||
|
Software string
|
||||||
|
Plugins []string
|
||||||
|
Players []string
|
||||||
|
|
||||||
|
Game string
|
||||||
|
Map string
|
||||||
|
GameMode string
|
||||||
|
|
||||||
|
Query bool
|
||||||
|
|
||||||
|
Latency time.Duration
|
||||||
|
|
||||||
|
Raw json.RawMessage
|
||||||
|
|
||||||
|
RawQuery map[string]string
|
||||||
|
}
|
||||||
378
java/java.go
Normal file
378
java/java.go
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
package java
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/VexoraDevelopment/mcstatus/info"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
Timeout time.Duration
|
||||||
|
EnableSRV bool
|
||||||
|
Resolver *net.Resolver
|
||||||
|
|
||||||
|
UseTLS bool
|
||||||
|
TLSConfig *tls.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = 25565
|
||||||
|
}
|
||||||
|
|
||||||
|
host := address
|
||||||
|
p := port
|
||||||
|
|
||||||
|
if c.opt.EnableSRV && net.ParseIP(host) == nil {
|
||||||
|
if rh, rp, err := c.resolveSRV(host); err == nil && rh != "" {
|
||||||
|
host = rh
|
||||||
|
if rp != 0 {
|
||||||
|
p = rp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(host, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Status(address string, port int) (*info.ServerInfo, error) {
|
||||||
|
conn, err := c.dial(address, port)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(c.opt.Timeout))
|
||||||
|
|
||||||
|
const protocolVersion = 47
|
||||||
|
|
||||||
|
if err := writePacket(conn, func(w *bytes.Buffer) {
|
||||||
|
writeVarInt(w, 0x00)
|
||||||
|
writeVarInt(w, protocolVersion)
|
||||||
|
writeString(w, address)
|
||||||
|
writeU16(w, uint16(port))
|
||||||
|
writeVarInt(w, 1)
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writePacket(conn, func(w *bytes.Buffer) {
|
||||||
|
writeVarInt(w, 0x00)
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pkt, err := readPacket(conn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r := bytes.NewReader(pkt)
|
||||||
|
|
||||||
|
pid, err := readVarInt(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if pid != 0x00 {
|
||||||
|
return nil, fmt.Errorf("unexpected status response packet id: %d", pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
js, err := readString(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
latency, _ := measureLatency(conn)
|
||||||
|
raw := json.RawMessage(js)
|
||||||
|
parsed, err := parseJavaStatus(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ip := resolveIP(address)
|
||||||
|
srvInfo := &info.ServerInfo{
|
||||||
|
Edition: info.EditionJava,
|
||||||
|
Address: address,
|
||||||
|
IP: ip,
|
||||||
|
Port: port,
|
||||||
|
MOTD: parsed.MOTD,
|
||||||
|
Version: parsed.VersionName,
|
||||||
|
Software: parsed.VersionName,
|
||||||
|
Online: parsed.Online,
|
||||||
|
Slots: parsed.Max,
|
||||||
|
Latency: latency,
|
||||||
|
Raw: raw,
|
||||||
|
}
|
||||||
|
return srvInfo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) resolveSRV(domain string) (string, int, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), c.opt.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, addrs, err := c.opt.Resolver.LookupSRV(ctx, "minecraft", "tcp", domain)
|
||||||
|
if err != nil || len(addrs) == 0 {
|
||||||
|
return "", 0, errors.New("no _minecraft._tcp SRV record")
|
||||||
|
}
|
||||||
|
target := strings.TrimSuffix(addrs[0].Target, ".")
|
||||||
|
return target, int(addrs[0].Port), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) dial(host string, port int) (net.Conn, error) {
|
||||||
|
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||||
|
d := &net.Dialer{Timeout: c.opt.Timeout}
|
||||||
|
|
||||||
|
if !c.opt.UseTLS {
|
||||||
|
return d.Dial("tcp", addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := c.opt.TLSConfig
|
||||||
|
if cfg == nil {
|
||||||
|
cfg = &tls.Config{ServerName: host}
|
||||||
|
}
|
||||||
|
return tls.DialWithDialer(d, "tcp", addr, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePacket(w io.Writer, build func(*bytes.Buffer)) error {
|
||||||
|
var payload bytes.Buffer
|
||||||
|
build(&payload)
|
||||||
|
|
||||||
|
var framed bytes.Buffer
|
||||||
|
writeVarInt(&framed, payload.Len())
|
||||||
|
_, _ = framed.Write(payload.Bytes())
|
||||||
|
|
||||||
|
_, err := w.Write(framed.Bytes())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func readPacket(r io.Reader) ([]byte, error) {
|
||||||
|
length, err := readVarIntFromIO(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if length < 0 || length > 1<<20 {
|
||||||
|
return nil, fmt.Errorf("invalid packet length: %d", length)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := make([]byte, length)
|
||||||
|
if _, err := io.ReadFull(r, buf); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeVarInt(w *bytes.Buffer, v int) {
|
||||||
|
uv := uint32(v)
|
||||||
|
for {
|
||||||
|
if (uv & ^uint32(0x7F)) == 0 {
|
||||||
|
_ = w.WriteByte(byte(uv))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = w.WriteByte(byte(uv&0x7F | 0x80))
|
||||||
|
uv >>= 7
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readVarInt(r *bytes.Reader) (int, error) {
|
||||||
|
var numRead int
|
||||||
|
var result int
|
||||||
|
for {
|
||||||
|
if numRead > 5 {
|
||||||
|
return 0, errors.New("varint too big")
|
||||||
|
}
|
||||||
|
b, err := r.ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
value := int(b & 0x7F)
|
||||||
|
result |= value << (7 * numRead)
|
||||||
|
numRead++
|
||||||
|
if (b & 0x80) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readVarIntFromIO(r io.Reader) (int, error) {
|
||||||
|
var numRead int
|
||||||
|
var result int
|
||||||
|
var one [1]byte
|
||||||
|
|
||||||
|
for {
|
||||||
|
if numRead > 5 {
|
||||||
|
return 0, errors.New("varint too big")
|
||||||
|
}
|
||||||
|
if _, err := io.ReadFull(r, one[:]); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
b := one[0]
|
||||||
|
value := int(b & 0x7F)
|
||||||
|
result |= value << (7 * numRead)
|
||||||
|
numRead++
|
||||||
|
if (b & 0x80) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeString(w *bytes.Buffer, s string) {
|
||||||
|
writeVarInt(w, len(s))
|
||||||
|
_, _ = w.WriteString(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readString(r *bytes.Reader) (string, error) {
|
||||||
|
n, err := readVarInt(r)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if n < 0 || n > 1<<20 {
|
||||||
|
return "", fmt.Errorf("invalid string length: %d", n)
|
||||||
|
}
|
||||||
|
b := make([]byte, n)
|
||||||
|
if _, err := io.ReadFull(r, b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeU16(w *bytes.Buffer, v uint16) {
|
||||||
|
var tmp [2]byte
|
||||||
|
binary.BigEndian.PutUint16(tmp[:], v)
|
||||||
|
_, _ = w.Write(tmp[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func measureLatency(conn net.Conn) (time.Duration, error) {
|
||||||
|
start := time.Now()
|
||||||
|
ts := time.Now().UnixMilli()
|
||||||
|
|
||||||
|
if err := writePacket(conn, func(w *bytes.Buffer) {
|
||||||
|
writeVarInt(w, 0x01)
|
||||||
|
var tmp [8]byte
|
||||||
|
binary.BigEndian.PutUint64(tmp[:], uint64(ts))
|
||||||
|
_, _ = w.Write(tmp[:])
|
||||||
|
}); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pkt, err := readPacket(conn)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
r := bytes.NewReader(pkt)
|
||||||
|
pid, err := readVarInt(r)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if pid != 0x01 {
|
||||||
|
return 0, fmt.Errorf("unexpected pong packet id: %d", pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Since(start), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type javaStatusParsed struct {
|
||||||
|
VersionName string
|
||||||
|
Online int
|
||||||
|
Max int
|
||||||
|
MOTD string
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseJavaStatus(raw json.RawMessage) (*javaStatusParsed, error) {
|
||||||
|
var doc map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := &javaStatusParsed{}
|
||||||
|
|
||||||
|
if v, ok := doc["version"].(map[string]any); ok {
|
||||||
|
if name, _ := v["name"].(string); name != "" {
|
||||||
|
out.VersionName = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if p, ok := doc["players"].(map[string]any); ok {
|
||||||
|
out.Online = asInt(p["online"])
|
||||||
|
out.Max = asInt(p["max"])
|
||||||
|
}
|
||||||
|
|
||||||
|
out.MOTD = parseDescription(doc["description"])
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDescription(v any) string {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case string:
|
||||||
|
return t
|
||||||
|
case map[string]any:
|
||||||
|
if s, _ := t["text"].(string); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if extra, ok := t["extra"].([]any); ok {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, it := range extra {
|
||||||
|
if m, ok := it.(map[string]any); ok {
|
||||||
|
if s, _ := m["text"].(string); s != "" {
|
||||||
|
b.WriteString(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b.Len() > 0 {
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func asInt(v any) int {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return int(t)
|
||||||
|
case int:
|
||||||
|
return t
|
||||||
|
case json.Number:
|
||||||
|
n, _ := t.Int64()
|
||||||
|
return int(n)
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
192
java/query.go
Normal file
192
java/query.go
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
package java
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/VexoraDevelopment/mcstatus/info"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Client) QueryFull(address string, port int) (*info.ServerInfo, error) {
|
||||||
|
if port == 0 {
|
||||||
|
port = 25565
|
||||||
|
}
|
||||||
|
|
||||||
|
host := address
|
||||||
|
p := port
|
||||||
|
if c.opt.EnableSRV && net.ParseIP(host) == nil {
|
||||||
|
if rh, rp, err := c.resolveSRV(host); err == nil && rh != "" {
|
||||||
|
host = rh
|
||||||
|
if rp != 0 {
|
||||||
|
p = rp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, _, err := c.dialUDP(host, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
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("java 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("java 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("java query 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
srvInfo := &info.ServerInfo{
|
||||||
|
Edition: info.EditionJava,
|
||||||
|
Address: address,
|
||||||
|
IP: resolveIP(address),
|
||||||
|
Port: intFromMap(raw, "hostport", port),
|
||||||
|
MOTD: raw["hostname"],
|
||||||
|
Version: raw["version"],
|
||||||
|
GameMode: raw["gametype"],
|
||||||
|
Map: raw["map"],
|
||||||
|
Game: raw["game_id"],
|
||||||
|
Online: intFromMap(raw, "numplayers", 0),
|
||||||
|
Slots: intFromMap(raw, "maxplayers", 0),
|
||||||
|
Players: players,
|
||||||
|
Query: true,
|
||||||
|
RawQuery: raw,
|
||||||
|
Latency: time.Since(start),
|
||||||
|
}
|
||||||
|
|
||||||
|
if pl := raw["plugins"]; pl != "" {
|
||||||
|
split := strings.SplitN(pl, ": ", 2)
|
||||||
|
srvInfo.Software = split[0]
|
||||||
|
if len(split) == 2 {
|
||||||
|
p2 := strings.Split(split[1], "; ")
|
||||||
|
clean := make([]string, 0, len(p2))
|
||||||
|
for _, x := range p2 {
|
||||||
|
x = strings.TrimSpace(x)
|
||||||
|
if x != "" {
|
||||||
|
clean = append(clean, x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(clean) > 0 {
|
||||||
|
srvInfo.Plugins = clean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if eng := raw["server_engine"]; eng != "" {
|
||||||
|
srvInfo.Software = eng
|
||||||
|
}
|
||||||
|
|
||||||
|
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, typ byte, appendData []byte) ([]byte, error) {
|
||||||
|
header := []byte{0xFE, 0xFD, typ, 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] != typ {
|
||||||
|
return nil, errors.New("java query response validation failed")
|
||||||
|
}
|
||||||
|
return resp[5:], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func intFromMap(m map[string]string, key string, def int) int {
|
||||||
|
v := strings.TrimSpace(m[key])
|
||||||
|
if v == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err != nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
109
text/clean.go
Normal file
109
text/clean.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
package text
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CleanMOTD(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||||
|
s = strings.ReplaceAll(s, "\r", "\n")
|
||||||
|
s = stripColorCodes(s)
|
||||||
|
s = stripControl(s)
|
||||||
|
s = normalizeSpacesKeepNewlines(s)
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripColorCodes(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(s))
|
||||||
|
|
||||||
|
for i := 0; i < len(s); {
|
||||||
|
r, size := utf8.DecodeRuneInString(s[i:])
|
||||||
|
if r == utf8.RuneError && size == 1 {
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if r == '§' || r == '&' {
|
||||||
|
if i+2 < len(s) && (s[i] == '§' || s[i] == '&') && (s[i+1] == 'x' || s[i+1] == 'X') {
|
||||||
|
j := i + 2
|
||||||
|
ok := true
|
||||||
|
for k := 0; k < 6; k++ {
|
||||||
|
if j+2 >= len(s) {
|
||||||
|
ok = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if s[j] != s[i] {
|
||||||
|
ok = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
c := s[j+1]
|
||||||
|
if !isHex(c) {
|
||||||
|
ok = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
j += 2
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
i = j
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
i += size
|
||||||
|
if i < len(s) {
|
||||||
|
_, nextSize := utf8.DecodeRuneInString(s[i:])
|
||||||
|
i += nextSize
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteRune(r)
|
||||||
|
i += size
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHex(c byte) bool {
|
||||||
|
return (c >= '0' && c <= '9') ||
|
||||||
|
(c >= 'a' && c <= 'f') ||
|
||||||
|
(c >= 'A' && c <= 'F')
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripControl(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(s))
|
||||||
|
|
||||||
|
for _, r := range s {
|
||||||
|
if r == '\n' || r == '\t' {
|
||||||
|
b.WriteRune(r)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r < 32 || r == 127 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSpacesKeepNewlines(s string) string {
|
||||||
|
lines := strings.Split(s, "\n")
|
||||||
|
for i := range lines {
|
||||||
|
lines[i] = strings.Join(strings.Fields(lines[i]), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
out := strings.Join(lines, "\n")
|
||||||
|
out = strings.Trim(out, "\n")
|
||||||
|
|
||||||
|
for strings.Contains(out, "\n\n\n") {
|
||||||
|
out = strings.ReplaceAll(out, "\n\n\n", "\n\n")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user