diff --git a/minecraft/DOCS.md b/minecraft/DOCS.md new file mode 100644 index 0000000..445c1f0 --- /dev/null +++ b/minecraft/DOCS.md @@ -0,0 +1,117 @@ +# minecraft + +`minecraft` currently provides a small client for querying Minecraft Bedrock server status over UDP. + +## Quick Start + +Use the package directly: + +```go +package main + +import ( + "fmt" + "log" + "time" + + "github.com/barluscuda/dextools/minecraft" +) + +func main() { + client := &minecraft.Bedrock{ + Timeout: 5 * time.Second, + } + + status, err := client.ServerStatus("play.nethergames.org", 19132) + if err != nil { + log.Fatal(err) + } + + fmt.Println("MOTD:", status.MOTD) + fmt.Println("Version:", status.Version) + fmt.Printf("Players: %d/%d\n", status.PlayersOnline, status.PlayersMax) + fmt.Println("Ping:", status.Ping) +} +``` + +## Behavior + +- `Bedrock.ServerStatus` sends a UDP Bedrock status ping to the target host and port. +- `Bedrock.Timeout` is used for both dialing and the read deadline. +- The response parser expects a valid Bedrock unconnected pong payload with the `MCPE` edition marker. +- Invalid numeric fields in the server response return an error instead of silently becoming `0`. +- `Ping` is measured from request write to response read. + +## Available Types + +The package currently exposes: + +- `Bedrock` +- `BedrockServerStatusStruct` + +## Client + +### `Bedrock` + +`Bedrock` is the client used to query a Bedrock server. + +```go +type Bedrock struct { + Timeout time.Duration +} +``` + +## Status Response + +### `BedrockServerStatusStruct` + +`ServerStatus` returns a `*BedrockServerStatusStruct` with the parsed server response. + +```go +type BedrockServerStatusStruct struct { + Ping time.Duration + MOTD string + Protocol int + Version string + PlayersOnline int + PlayersMax int + ServerID string + LevelName string + GameMode string + GameModeNumeric int + IPv4Port int + IPv6Port int +} +``` + +Field meanings: + +- `Ping`: round-trip duration for the status request. +- `MOTD`: server message of the day. +- `Protocol`: Bedrock protocol version number. +- `Version`: Bedrock version string reported by the server. +- `PlayersOnline`: currently connected player count. +- `PlayersMax`: maximum configured player count. +- `ServerID`: server identifier from the Bedrock response. +- `LevelName`: world or level name. +- `GameMode`: game mode name. +- `GameModeNumeric`: numeric game mode identifier. +- `IPv4Port`: IPv4 listener port reported by the server. +- `IPv6Port`: IPv6 listener port reported by the server. + +## API + +### `func (b *Bedrock) ServerStatus(host string, port int) (*BedrockServerStatusStruct, error)` + +`ServerStatus` connects to `host:port`, sends a Bedrock status ping, parses the response, and returns the structured result. + +Common error cases: + +- UDP dial failures +- request timeout or read timeout +- malformed status packets +- unexpected response payloads + +## Testing + +Unit tests for the Bedrock status client live in [bedrock-server-status_test.go](./bedrock-server-status_test.go). diff --git a/minecraft/bedrock-server-status.go b/minecraft/bedrock-server-status.go new file mode 100644 index 0000000..c486a49 --- /dev/null +++ b/minecraft/bedrock-server-status.go @@ -0,0 +1,141 @@ +package minecraft + +import ( + "bytes" + "encoding/binary" + "fmt" + "net" + "strconv" + "time" +) + +type BedrockServerStatusStruct struct { + Ping time.Duration + MOTD string + Protocol int + Version string + PlayersOnline int + PlayersMax int + ServerID string + LevelName string + GameMode string + GameModeNumeric int + IPv4Port int + IPv6Port int +} + +func (b *Bedrock) ServerStatus(host string, port int) (*BedrockServerStatusStruct, error) { + addr := fmt.Sprintf("%s:%d", host, port) + + conn, err := net.DialTimeout("udp", addr, b.Timeout) + if err != nil { + return nil, err + } + defer conn.Close() + + _ = conn.SetDeadline(time.Now().Add(b.Timeout)) + + var packet bytes.Buffer + packet.WriteByte(0x01) + + timestamp := time.Now().UnixMilli() + _ = binary.Write(&packet, binary.BigEndian, timestamp) + + packet.Write([]byte{ + 0x00, 0xff, 0xff, 0x00, + 0xfe, 0xfe, 0xfe, 0xfe, + 0xfd, 0xfd, 0xfd, 0xfd, + 0x12, 0x34, 0x56, 0x78, + }) + + _ = binary.Write(&packet, binary.BigEndian, int64(1)) + + start := time.Now() + + if _, err := conn.Write(packet.Bytes()); err != nil { + return nil, err + } + + buf := make([]byte, 2048) + n, err := conn.Read(buf) + if err != nil { + return nil, err + } + + ping := time.Since(start) + + return parseBedrockStatusResponse(buf[:n], ping) +} + +func parseBedrockStatusResponse(packet []byte, ping time.Duration) (*BedrockServerStatusStruct, error) { + if len(packet) < 35 || packet[0] != 0x1c { + return nil, fmt.Errorf("invalid response") + } + + offset := 1 + 8 + 8 + 16 + if offset+2 > len(packet) { + return nil, fmt.Errorf("invalid packet") + } + + strLen := int(binary.BigEndian.Uint16(packet[offset : offset+2])) + offset += 2 + if offset+strLen > len(packet) { + return nil, fmt.Errorf("invalid string length") + } + + parts := bytes.Split(packet[offset:offset+strLen], []byte(";")) + if len(parts) < 12 { + return nil, fmt.Errorf("invalid bedrock response") + } + if string(parts[0]) != "MCPE" { + return nil, fmt.Errorf("invalid edition: %q", string(parts[0])) + } + + parseInt := func(field string, raw []byte) (int, error) { + value, err := strconv.Atoi(string(raw)) + if err != nil { + return 0, fmt.Errorf("invalid %s: %q", field, string(raw)) + } + return value, nil + } + + protocol, err := parseInt("protocol", parts[2]) + if err != nil { + return nil, err + } + playersOnline, err := parseInt("players online", parts[4]) + if err != nil { + return nil, err + } + playersMax, err := parseInt("players max", parts[5]) + if err != nil { + return nil, err + } + gameModeNumeric, err := parseInt("game mode numeric", parts[9]) + if err != nil { + return nil, err + } + ipv4Port, err := parseInt("IPv4 port", parts[10]) + if err != nil { + return nil, err + } + ipv6Port, err := parseInt("IPv6 port", parts[11]) + if err != nil { + return nil, err + } + + return &BedrockServerStatusStruct{ + Ping: ping, + MOTD: string(parts[1]), + Protocol: protocol, + Version: string(parts[3]), + PlayersOnline: playersOnline, + PlayersMax: playersMax, + ServerID: string(parts[6]), + LevelName: string(parts[7]), + GameMode: string(parts[8]), + GameModeNumeric: gameModeNumeric, + IPv4Port: ipv4Port, + IPv6Port: ipv6Port, + }, nil +} diff --git a/minecraft/bedrock-server-status_test.go b/minecraft/bedrock-server-status_test.go new file mode 100644 index 0000000..bd4eaab --- /dev/null +++ b/minecraft/bedrock-server-status_test.go @@ -0,0 +1,142 @@ +package minecraft + +import ( + "bytes" + "encoding/binary" + "strings" + "testing" + "time" +) + +func TestBedrockServerStatus(t *testing.T) { + b := &Bedrock{ + Timeout: 5 * time.Second, + } + + status, err := b.ServerStatus("play.nethergames.org", 19132) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if status == nil { + t.Fatal("status is nil") + } + + if status.Ping <= 0 { + t.Error("invalid ping") + } + + if status.MOTD == "" { + t.Error("empty MOTD") + } + + if status.Protocol <= 0 { + t.Error("invalid protocol") + } + + if status.Version == "" { + t.Error("empty version") + } + + if status.PlayersMax < 0 { + t.Error("invalid players max") + } + + if status.PlayersOnline < 0 { + t.Error("invalid players online") + } + + if status.ServerID == "" { + t.Error("empty server id") + } + + if status.LevelName == "" { + t.Error("empty level name") + } + + if status.GameMode == "" { + t.Error("empty game mode") + } + + if status.IPv4Port <= 0 { + t.Error("invalid IPv4 port") + } + + if status.IPv6Port < 0 { + t.Error("invalid IPv6 port") + } +} + +func TestParseBedrockStatusResponse(t *testing.T) { + packet := buildBedrockStatusPacket(t, "MCPE;NetherGames;594;1.20.81;12;500;123456789;Lobby;Survival;1;19132;19133;") + + status, err := parseBedrockStatusResponse(packet, 42*time.Millisecond) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if status.Ping != 42*time.Millisecond { + t.Fatalf("unexpected ping: %v", status.Ping) + } + if status.MOTD != "NetherGames" { + t.Fatalf("unexpected MOTD: %q", status.MOTD) + } + if status.Protocol != 594 { + t.Fatalf("unexpected protocol: %d", status.Protocol) + } + if status.Version != "1.20.81" { + t.Fatalf("unexpected version: %q", status.Version) + } + if status.PlayersOnline != 12 || status.PlayersMax != 500 { + t.Fatalf("unexpected player counts: %d/%d", status.PlayersOnline, status.PlayersMax) + } + if status.ServerID != "123456789" { + t.Fatalf("unexpected server id: %q", status.ServerID) + } + if status.LevelName != "Lobby" { + t.Fatalf("unexpected level name: %q", status.LevelName) + } + if status.GameMode != "Survival" || status.GameModeNumeric != 1 { + t.Fatalf("unexpected game mode: %q/%d", status.GameMode, status.GameModeNumeric) + } + if status.IPv4Port != 19132 || status.IPv6Port != 19133 { + t.Fatalf("unexpected ports: %d/%d", status.IPv4Port, status.IPv6Port) + } +} + +func TestParseBedrockStatusResponseRejectsInvalidNumericFields(t *testing.T) { + packet := buildBedrockStatusPacket(t, "MCPE;NetherGames;bad;1.20.81;12;500;123456789;Lobby;Survival;1;19132;19133;") + + _, err := parseBedrockStatusResponse(packet, 0) + if err == nil { + t.Fatal("expected an error") + } + if !strings.Contains(err.Error(), "invalid protocol") { + t.Fatalf("unexpected error: %v", err) + } +} + +func buildBedrockStatusPacket(t *testing.T, payload string) []byte { + t.Helper() + + var packet bytes.Buffer + packet.WriteByte(0x1c) + if err := binary.Write(&packet, binary.BigEndian, int64(123)); err != nil { + t.Fatalf("write timestamp: %v", err) + } + if err := binary.Write(&packet, binary.BigEndian, int64(456)); err != nil { + t.Fatalf("write server id: %v", err) + } + packet.Write([]byte{ + 0x00, 0xff, 0xff, 0x00, + 0xfe, 0xfe, 0xfe, 0xfe, + 0xfd, 0xfd, 0xfd, 0xfd, + 0x12, 0x34, 0x56, 0x78, + }) + if err := binary.Write(&packet, binary.BigEndian, uint16(len(payload))); err != nil { + t.Fatalf("write string length: %v", err) + } + packet.WriteString(payload) + + return packet.Bytes() +} diff --git a/minecraft/bedrock.go b/minecraft/bedrock.go new file mode 100644 index 0000000..73506a1 --- /dev/null +++ b/minecraft/bedrock.go @@ -0,0 +1,7 @@ +package minecraft + +import "time" + +type Bedrock struct { + Timeout time.Duration +}