commit 383b3fe543332dd8791dee702abe76154faa2ab0 Author: Andrewkydev Date: Sat Apr 4 20:01:57 2026 +0300 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..97b2c4d --- /dev/null +++ b/README.md @@ -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. diff --git a/bedrock/bedrock.go b/bedrock/bedrock.go new file mode 100644 index 0000000..4015cd1 --- /dev/null +++ b/bedrock/bedrock.go @@ -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 +} diff --git a/client.go b/client.go new file mode 100644 index 0000000..97f6719 --- /dev/null +++ b/client.go @@ -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}), + } +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..56a6faf --- /dev/null +++ b/doc.go @@ -0,0 +1 @@ +package mcstatus diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ebb5e6c --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/VexoraDevelopment/mcstatus + +go 1.25 diff --git a/info/types.go b/info/types.go new file mode 100644 index 0000000..458b7f4 --- /dev/null +++ b/info/types.go @@ -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 +} diff --git a/java/java.go b/java/java.go new file mode 100644 index 0000000..49c03d3 --- /dev/null +++ b/java/java.go @@ -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() +} diff --git a/java/query.go b/java/query.go new file mode 100644 index 0000000..faec61f --- /dev/null +++ b/java/query.go @@ -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 +} diff --git a/text/clean.go b/text/clean.go new file mode 100644 index 0000000..a667af6 --- /dev/null +++ b/text/clean.go @@ -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 +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..1f63afa --- /dev/null +++ b/types.go @@ -0,0 +1,12 @@ +package mcstatus + +import "github.com/VexoraDevelopment/mcstatus/info" + +type Edition = info.Edition + +const ( + EditionJava = info.EditionJava + EditionBedrock = info.EditionBedrock +) + +type ServerInfo = info.ServerInfo