diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..8ba7387 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,20 @@ +name: Go test and build + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: "1.20" + + - name: Test + run: go test -v ./... + + - name: Build + run: go build -v ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5597dcd --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +# GoLand +.idea/ + +build/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5e8100a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Sch8ill + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..da70a7e --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +bin_name=mcli +target=cmd/cli/main.go + +all: build + +run: + go run $(target) + +build: + go build -o build/$(bin_name) $(target) + +multi-arch: + scripts/build-multi-arch.sh $(target) build/$(bin_name) + +clean: + rm -rf build \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..dccb943 --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +# mclib + +[![Release](https://img.shields.io/github/release/sch8ill/mclib.svg?style=flat-square)](https://github.com/sch8ill/mclib/releases) +[![doc](https://img.shields.io/badge/go.dev-doc-007d9c?style=flat-square&logo=read-the-docs)](https://pkg.go.dev/github.com/sch8ill/mclib) +[![Go Report Card](https://goreportcard.com/badge/github.com/sch8ill/mclib)](https://goreportcard.com/report/github.com/sch8ill/mclib) +![MIT license](https://img.shields.io/badge/license-MIT-green) + +--- + +The `mclib` package provides utilities for interacting with Minecraft servers using the Server List Ping (SLP) protocol. +It includes functionality to query Minecraft servers for status and latency information. + +## Installation + +To use this package in your Go project, simply install it: + +```bash +go get github.com/sch8ill/mclib +``` + +## Usage + +### MCServer + +`MCServer` represents a Minecraft server with its address and client. It provides methods to retrieve server status and +perform a status ping. + +#### Creating an MCServer Instance + +```go +package main + +import ( + "github.com/sch8ill/mclib/server" +) + +func main() { + srv, err := server.New("example.com:25565") + if err != nil { + // handle error + } +} +``` + +#### StatusPing + +```go +res, err := srv.StatusPing() +if err != nil { +// handle error +} + +fmt.Printf("version: %s\n", res.Version.Name) +fmt.Printf("protocol: %d\n", res.Version.Protocol) +fmt.Printf("online players: %d\n", res.Players.Online) +fmt.Printf("max players: %d\n", res.Players.Max) +fmt.Printf("sample players: %+q\n", res.Players.Sample) +fmt.Printf("description: %s\n", res.Description.String()) +fmt.Printf("latency: %dms\n", res.Latency) +// ... +``` + +#### Ping + +```go +latency, err := srv.ping() +if err != nil { +// handle error +} + +fmt.Printf("latency: %dms\n", latency) +``` + +### Cli + +#### Build + +requires: + +``` +make +go >= 1.20 +``` + +build: + +```bash +make build && mv build/mcli mcli +``` + +#### Usage + +`mclib` also provides a simple command line interface: + +``` + -addr string + the server address (default "localhost") + -srv + whether a srv lookup should be made (default true) + -timeout duration + the connection timeout (default 5s) +``` + +For example: + +```bash +mcli --addr hypixel.net --timeout 10s +``` + +## License + +This package is licensed under the [MIT License](LICENSE). + +--- diff --git a/address/address.go b/address/address.go new file mode 100644 index 0000000..cf4948a --- /dev/null +++ b/address/address.go @@ -0,0 +1,92 @@ +// Package address provides utilities for working with Minecraft server addresses. +package address + +import ( + "fmt" + "net" + "strconv" + "strings" +) + +const DefaultPort uint16 = 25565 + +// Address represents a Minecraft server address with a host, port and srv record. +type Address struct { + Host string + Port uint16 + SRVHost string + SRVPort uint16 + SRV bool +} + +// New creates a new Address from a given address string, +// which can include the host and port separated by a colon (e.g., "example.com:25565"). +// If no port is specified, it uses the default Minecraft port. +func New(addr string) (*Address, error) { + if !strings.Contains(addr, ":") { + return &Address{ + Host: addr, + Port: DefaultPort, + }, nil + } + + splitAddr := strings.Split(addr, ":") + if len(splitAddr) != 2 { + return nil, fmt.Errorf("invalid address: %s", addr) + } + + port, err := strconv.Atoi(splitAddr[1]) + if err != nil { + return nil, fmt.Errorf("invalid port: %s", splitAddr[1]) + } + + return &Address{ + Host: splitAddr[0], + Port: uint16(port), + }, nil +} + +// ResolveSRV resolves the SRV record for the Address's host and updates its SRV fields. +func (a *Address) ResolveSRV() error { + if a.IsIP() { + return nil + } + + _, records, err := net.LookupSRV("minecraft", "tcp", a.Host) + if err != nil { + return fmt.Errorf("failed to resolve SRV record: %w", err) + } + + if len(records) > 0 { + srvRecord := records[0] + a.SRVPort = srvRecord.Port + a.SRVHost = srvRecord.Target + a.SRV = true + } + + return nil +} + +// IsIP checks if the host in the Address is an IP address. +func (a *Address) IsIP() bool { + return net.ParseIP(a.Host) != nil +} + +// Addr returns the address string based on whether SRV record resolution is enabled. +// If SRV resolution is enabled, it returns the SRV address; otherwise, the original address. +func (a *Address) Addr() string { + if a.SRV { + return a.SRVAddr() + } + return a.OGAddr() +} + +// SRVAddr returns the address string in the format "hostname:port" based on SRV record values. +func (a *Address) SRVAddr() string { + return fmt.Sprintf("%s:%d", a.SRVHost, a.SRVPort) +} + +// OGAddr returns the address string in the format "hostname:port". +func (a *Address) OGAddr() string { + return fmt.Sprintf("%s:%d", a.Host, a.Port) +} diff --git a/cmd/cli/main.go b/cmd/cli/main.go new file mode 100644 index 0000000..636ba7b --- /dev/null +++ b/cmd/cli/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "flag" + "fmt" + + "github.com/sch8ill/mclib/server" + "github.com/sch8ill/mclib/slp" +) + +func main() { + addr := flag.String("addr", "localhost", "the server address") + timeout := flag.Duration("timeout", slp.DefaultTimeout, "the connection timeout") + srv := flag.Bool("srv", true, "whether a srv lookup should be made") + flag.Parse() + + opts := []server.MCServerOption{server.WithTimeout(*timeout)} + if !*srv { + opts = append(opts, server.WithoutSRV()) + } + + mcs, err := server.New(*addr, opts...) + if err != nil { + panic(err) + } + + res, err := mcs.StatusPing() + if err != nil { + panic(err) + } + + fmt.Printf("version: %s\n", res.Version.Name) + fmt.Printf("protocol: %d\n", res.Version.Protocol) + fmt.Printf("description: %s\n", res.Description.String()) + fmt.Printf("online players: %d\n", res.Players.Online) + fmt.Printf("max players: %d\n", res.Players.Max) + fmt.Printf("sample players: %+q\n", res.Players.Sample) + fmt.Printf("latency: %dms\n", res.Latency) + fmt.Printf("favicon: %t\n", res.Favicon != "") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..246ce09 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/sch8ill/mclib + +go 1.20 diff --git a/mcli b/mcli new file mode 100644 index 0000000..35eecd5 Binary files /dev/null and b/mcli differ diff --git a/scripts/build-multi-arch.sh b/scripts/build-multi-arch.sh new file mode 100644 index 0000000..02b5382 --- /dev/null +++ b/scripts/build-multi-arch.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +target="$1" +output_prefix="$2" + +platforms=("windows/amd64" "windows/arm" "linux/amd64" "linux/arm64" "linux/arm") + +# Ensure that the target and output_prefix are provided +if [ -z "$target" ] || [ -z "$output_prefix" ]; then + echo "Usage: $0 " + exit 1 +fi + +for platform in "${!platforms[@]}"; do + output_name="$output_prefix-${platforms[$platform]}" + + if [[ "$platform" == "windows"* ]]; then + output_name+='.exe' + fi + + if env GOOS="${platforms[$platform]%%/*}" GOARCH="${platforms[$platform]##*/}" go build -o "$output_name" "$target"; then + echo "Built $output_name" + else + echo "Error building $output_name" + exit 1 + fi +done \ No newline at end of file diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..1247810 --- /dev/null +++ b/server/server.go @@ -0,0 +1,86 @@ +// Package server provides utilities for working with Minecraft servers, including +// querying server status and sending Server List Ping (SLP) requests. +package server + +import ( + "fmt" + "time" + + "github.com/sch8ill/mclib/address" + "github.com/sch8ill/mclib/slp" +) + +// MCServer represents a Minecraft server with its address and client. +type MCServer struct { + addr *address.Address + timeout time.Duration + srv bool +} + +// MCServerOption represents a functional option for configuring an MCServer instance. +type MCServerOption func(*MCServer) + +// WithTimeout sets a custom timeout for communication with the server. +func WithTimeout(timeout time.Duration) MCServerOption { + return func(s *MCServer) { + s.timeout = timeout + } +} + +// WithoutSRV disables SRV record resolution when creating an MCServer instance. +func WithoutSRV() MCServerOption { + return func(s *MCServer) { + s.srv = false + } +} + +// New creates a new MCServer instance with the provided raw address. +func New(rawAddr string, opts ...MCServerOption) (*MCServer, error) { + addr, err := address.New(rawAddr) + if err != nil { + return nil, fmt.Errorf("failed to parse address: %w", err) + } + + s := &MCServer{ + addr: addr, + timeout: slp.DefaultTimeout, + srv: true, + } + for _, opt := range opts { + opt(s) + } + + if s.srv { + _ = addr.ResolveSRV() + } + + return s, nil +} + +// StatusPing sends an SLP status ping to the Minecraft server and returns the response. +func (s *MCServer) StatusPing(opts ...slp.ClientOption) (*slp.Response, error) { + client, err := s.createSLPClient(opts...) + if err != nil { + return nil, err + } + + res, err := client.StatusPing() + if err != nil { + return nil, fmt.Errorf("status ping failed: %w", err) + } + + return res, nil +} + +// createSLPClient creates a new SLPClient instance with the provided options. +func (s *MCServer) createSLPClient(opts ...slp.ClientOption) (*slp.Client, error) { + // the option is prepended so that it can be overwritten + opts = append([]slp.ClientOption{slp.WithTimeout(s.timeout)}, opts...) + + client, err := slp.NewClient(s.addr, opts...) + if err != nil { + return nil, fmt.Errorf("failed to intialize SLP client: %w", err) + } + + return client, nil +} diff --git a/slp/client.go b/slp/client.go new file mode 100644 index 0000000..626b2e4 --- /dev/null +++ b/slp/client.go @@ -0,0 +1,300 @@ +// Package slp provides an SLP client for querying information about Minecraft servers +// using the Server List Ping (SLP) protocol. +package slp + +import ( + "errors" + "fmt" + "net" + "time" + + "github.com/sch8ill/mclib/address" + "github.com/sch8ill/mclib/slp/packet" +) + +const ( + DefaultTimeout = 5 * time.Second + DefaultProtocolVersion int32 = 47 + + handshakePacketID int32 = 0 + statusPacketID int32 = 0 + pingPacketID int32 = 1 + pongPacketId int32 = 1 + disconnectPacketID int32 = 27 + // the disconnect packet id has changed to 27 in 1.20.2 + legacyDisconnectPacketID int32 = 26 + + statusState int32 = 1 +) + +// ConnState represents the connection state of the SLPClient. +type ConnState int64 + +const ( + Idle ConnState = iota + Connected + Handshaked +) + +// Client represents an SLP client for interacting with Minecraft servers through the SLP protocol. +type Client struct { + addr *address.Address + timeout time.Duration + protocolVersion int32 + state ConnState + conn net.Conn +} + +// ClientOption represents a functional option for configuring an SLPClient instance. +type ClientOption func(*Client) + +// WithTimeout sets a custom timeout for communication with the server. +func WithTimeout(timeout time.Duration) ClientOption { + return func(c *Client) { + c.timeout = timeout + } +} + +// WithProtocolVersion sets a custom Minecraft protocol version. +func WithProtocolVersion(version int32) ClientOption { + return func(c *Client) { + c.protocolVersion = version + } +} + +// NewClient creates a new Client for pinging a Minecraft server at the specified address. +func NewClient(addr *address.Address, opts ...ClientOption) (*Client, error) { + client := &Client{ + addr: addr, + timeout: DefaultTimeout, + protocolVersion: DefaultProtocolVersion, + } + + for _, opt := range opts { + opt(client) + } + + return client, nil +} + +// StatusPing performs both a status query and a ping to the Minecraft server and returns the combined result. +func (c *Client) StatusPing() (*Response, error) { + res, err := c.Status() + if err != nil { + return nil, fmt.Errorf("failed to get server status: %w", err) + } + + latency, err := c.Ping() + if err != nil { + return nil, fmt.Errorf("failed to determine latency: %w", err) + } + res.Latency = latency + + return res, nil +} + +// Status performs a status query to the Minecraft server and retrieves server information. +func (c *Client) Status() (*Response, error) { + if err := c.connectAndHandshake(); err != nil { + return nil, err + } + + if err := c.sendStatusRequest(); err != nil { + return nil, err + } + + rawRes, err := c.recvResponse() + if err != nil { + return nil, fmt.Errorf("failed to receive status reponse: %w", err) + } + + res, err := NewResponse(rawRes) + if err != nil { + return nil, fmt.Errorf("failed to parse json response: %w", err) + } + + return res, nil +} + +// Ping performs a ping operation to the Minecraft server and returns the latency in milliseconds. +func (c *Client) Ping() (int, error) { + if err := c.connectAndHandshake(); err != nil { + return 0, err + } + + timestamp := time.Now() + + if err := c.sendPing(timestamp.Unix()); err != nil { + return 0, err + } + + id, err := c.recvPong() + if err != nil { + return 0, fmt.Errorf("failed to receive pong: %w", err) + } + + latency := int(time.Since(timestamp).Milliseconds()) + + if id != timestamp.Unix() { + return latency, fmt.Errorf("server responded with wrong pong id") + } + + // the server closes the connection after the pong packet + c.state = Idle + return latency, nil +} + +// sendHandshake sends a handshake packet to the Minecraft server during the connection setup. +func (c *Client) sendHandshake() error { + // handshake packet: + // packet id (VarInt) (0) + // protocol version (VarInt) (-1 = not set) + // length of hostname (uint8) + // hostname (string) + // port (uint16) + // next state (VarInt) (1 for status) + // + // https://wiki.vg/Server_List_Ping#Handshake + + handshake := packet.NewOutboundPacket(handshakePacketID) + + handshake.WriteVarInt(c.protocolVersion) + if err := handshake.WriteString(c.addr.Host); err != nil { + return fmt.Errorf("failed to write host: %w", err) + } + handshake.WriteShort(int16(c.addr.Port)) + handshake.WriteVarInt(statusState) + if err := handshake.Write(c.conn); err != nil { + return fmt.Errorf("failed to send handshake: %w", err) + } + + c.state = Handshaked + + return nil +} + +// sendStatusRequest sends a status request packet to the Minecraft server. +func (c *Client) sendStatusRequest() error { + // status request: + // packet id (VarInt) (0) + // + // https://wiki.vg/Protocol#Status_Request + + statusRequest := packet.NewOutboundPacket(statusPacketID) + if err := statusRequest.Write(c.conn); err != nil { + return fmt.Errorf("failed to send status request: %w", err) + } + + return nil +} + +// recvResponse receives the status response from the Minecraft server. +func (c *Client) recvResponse() (string, error) { + // status response: + // packet id (VarInt) (0) + // length of json response (uint8) + // json response (string) + // + // https://wiki.vg/Server_List_Ping#Status_Response + + res, err := packet.NewInboundPacket(c.conn) + if err != nil { + return "", fmt.Errorf("failed to read status response: %w", err) + } + + id := res.ID() + + if id == disconnectPacketID || id == legacyDisconnectPacketID { + msg, err := res.ReadString() + if err != nil { + return "", fmt.Errorf("failed to read disconnect reason: %w", err) + } + + return "", fmt.Errorf("received disconnect packet from server: %s", msg) + } + + if id != statusPacketID { + return "", fmt.Errorf("response packet contains bad packet id: %d", res.ID()) + } + + resBody, err := res.ReadString() + if err != nil { + return "", fmt.Errorf("failed to read status response body: %w", err) + } + + return resBody, nil +} + +// sendPing sends a ping packet to the Minecraft server to measure latency. +func (c *Client) sendPing(timestamp int64) error { + // ping packet: + // packet id (VarInt) (1) + // timestamp (Int64) + // + // https://wiki.vg/Server_List_Ping#Ping_Request + + ping := packet.NewOutboundPacket(pingPacketID) + ping.WriteLong(timestamp) + if err := ping.Write(c.conn); err != nil { + return fmt.Errorf("failed to send ping: %w", err) + } + + return nil +} + +// recvPong receives the pong packet from the Minecraft server. +func (c *Client) recvPong() (int64, error) { + // pong packet: + // packet id (VarInt) (1) + // payload (Int64) + // + // https://wiki.vg/Server_List_Ping#Pong_Response + + pong, err := packet.NewInboundPacket(c.conn) + if err != nil { + return 0, fmt.Errorf("failed to read pong: %w", err) + } + + if pong.ID() != pongPacketId { + return 0, fmt.Errorf("response packet contains bad packet id: %d", pong.ID()) + } + + id, err := pong.ReadLong() + if err != nil { + return 0, fmt.Errorf("failed to read pong id: %w", err) + } + + return id, nil +} + +// connectAndHandshake handles the connection setup and handshake with the Minecraft server. +func (c *Client) connectAndHandshake() error { + if c.state < Connected { + if err := c.connect(); err != nil { + return err + } + } + + if c.state < Handshaked { + if err := c.sendHandshake(); err != nil { + return err + } + } + return nil +} + +// connect establishes a connection to the Minecraft server. +func (c *Client) connect() error { + if c.state > Idle { + return errors.New("client is already connected") + } + + conn, err := net.DialTimeout("tcp", c.addr.Addr(), c.timeout) + if err != nil { + return fmt.Errorf("failed to connect: %w", err) + } + c.conn = conn + c.state = Connected + + return nil +} diff --git a/slp/packet/inbound.go b/slp/packet/inbound.go new file mode 100644 index 0000000..e09541b --- /dev/null +++ b/slp/packet/inbound.go @@ -0,0 +1,179 @@ +// Package packet provides utilities for sending and receiving Minecraft network packets. +package packet + +import ( + "bufio" + "bytes" + "encoding/binary" + "fmt" + "io" + "net" +) + +// InboundPacket represents a packet received from a connection. +type InboundPacket struct { + id int32 + body []byte + reader *bufio.Reader +} + +// NewInboundPacket creates a new InboundPacket from a network connection. +func NewInboundPacket(conn net.Conn) (*InboundPacket, error) { + p := &InboundPacket{} + connReader := bufio.NewReader(conn) + + uLength, err := binary.ReadUvarint(connReader) + if err != nil { + return nil, fmt.Errorf("failed to read packet length: %w", err) + } + length := int(uLength) + + if length > MaxPacketLength { + return nil, fmt.Errorf("received packet is too long: %d", length) + } + + p.body, err = readBytes(connReader, length) + if err != nil { + return nil, fmt.Errorf("failed to receive packet body: %w", err) + } + + p.reader = bufio.NewReader(bytes.NewReader(p.body)) + + packetID, err := binary.ReadUvarint(p.reader) + if err != nil { + return nil, fmt.Errorf("failed to read packet id: %w", err) + } + p.id = int32(packetID) + + return p, nil +} + +// ReadInt reads a 32-bit integer from the packet. +func (p *InboundPacket) ReadInt() (int32, error) { + buf := make([]byte, 4) + + _, err := io.ReadFull(p.reader, buf) + if err != nil { + return 0, fmt.Errorf("failed to read int: %w", err) + } + n := int32(binary.BigEndian.Uint32(buf)) + + return n, nil +} + +// ReadShort reads a 16-bit integer from the packet. +func (p *InboundPacket) ReadShort() (int16, error) { + buf := make([]byte, 8) + + _, err := io.ReadFull(p.reader, buf) + if err != nil { + return 0, fmt.Errorf("failed to read short: %w", err) + } + n := int16(binary.BigEndian.Uint16(buf)) + + return n, nil +} + +// ReadLong reads a 64-bit integer from the packet. +func (p *InboundPacket) ReadLong() (int64, error) { + buf := make([]byte, 8) + + _, err := io.ReadFull(p.reader, buf) + if err != nil { + return 0, fmt.Errorf("failed to read long: %w", err) + } + n := int64(binary.BigEndian.Uint64(buf)) + + return n, nil +} + +// ReadVarInt reads a variable-length 32-bit integer from the packet. +func (p *InboundPacket) ReadVarInt() (int32, error) { + n, err := binary.ReadUvarint(p.reader) + if err != nil { + return 0, err + } + + return int32(n), nil +} + +// ReadVarLong reads a variable-length 64-bit integer from the packet. +func (p *InboundPacket) ReadVarLong() (int64, error) { + n, err := p.ReadVarInt() + if err != nil { + return 0, err + } + + return int64(n), err +} + +// ReadBool reads a boolean value from the packet. +func (p *InboundPacket) ReadBool() (bool, error) { + value, err := p.ReadByte() + if err != nil { + return false, fmt.Errorf("failed to read bool: %w", err) + } + + return value != 0, nil +} + +// ReadString reads a string from the packet. +func (p *InboundPacket) ReadString() (string, error) { + uLength, err := p.ReadVarInt() + if err != nil { + return "", fmt.Errorf("failed to read string length: %w", err) + } + length := int(uLength) + + if length > MaxStringLength { + return "", fmt.Errorf("received string exceeds the max string length: %d", length) + } + + raw, err := p.ReadBytes(length) + if err != nil { + return "", fmt.Errorf("failed to read string: %w", err) + } + + return string(raw), nil +} + +// ReadByte reads a single byte from the packet. +func (p *InboundPacket) ReadByte() (byte, error) { + buf, err := p.ReadBytes(1) + if err != nil { + return 0, fmt.Errorf("failed to read byte: %w", err) + } + + return buf[0], nil +} + +// ReadBytes reads a specified number of bytes from the packet. +func (p *InboundPacket) ReadBytes(length int) ([]byte, error) { + b, err := readBytes(p.reader, length) + if err != nil { + return nil, fmt.Errorf("failed to read bytes: %w", err) + } + + return b, nil +} + +// ID returns the id of the packet. +func (p *InboundPacket) ID() int32 { + return p.id +} + +// readBytes reads a specified number of bytes from a buffered reader. +func readBytes(reader *bufio.Reader, length int) ([]byte, error) { + data := make([]byte, length) + var received int + for received < length { + segmentLength, err := reader.Read(data[received:]) + if err != nil { + return nil, fmt.Errorf("failed to read packet segment: %w", err) + } + + received += segmentLength + } + + return data, nil +} diff --git a/slp/packet/outbound.go b/slp/packet/outbound.go new file mode 100644 index 0000000..390a678 --- /dev/null +++ b/slp/packet/outbound.go @@ -0,0 +1,117 @@ +package packet + +import ( + "encoding/binary" + "fmt" + "net" +) + +const ( + MaxPacketLength int = 2097151 + MaxStringLength int = 32767 +) + +// OutboundPacket represents a packet to be sent over a network connection. +type OutboundPacket struct { + id int32 + body []byte +} + +// NewOutboundPacket creates a new OutboundPacket with a given id. +func NewOutboundPacket(id int32) *OutboundPacket { + return &OutboundPacket{id: id} +} + +// WriteInt writes a 32-bit integer to the packet. +func (p *OutboundPacket) WriteInt(n int32) { + buf := make([]byte, 4) + binary.BigEndian.PutUint32(buf, uint32(n)) + p.WriteBytes(buf) +} + +// WriteShort writes a 16-bit integer to the packet. +func (p *OutboundPacket) WriteShort(n int16) { + buf := make([]byte, 2) + binary.BigEndian.PutUint16(buf, uint16(n)) + p.WriteBytes(buf) +} + +// WriteLong writes a 64-bit integer to the packet. +func (p *OutboundPacket) WriteLong(n int64) { + buf := make([]byte, 8) + binary.BigEndian.PutUint64(buf, uint64(n)) + p.WriteBytes(buf) +} + +// WriteVarInt writes a variable-length 32-bit integer to the packet. +func (p *OutboundPacket) WriteVarInt(n int32) { + buf := make([]byte, binary.MaxVarintLen32) + size := binary.PutUvarint(buf, uint64(n)) + p.WriteBytes(buf[:size]) +} + +// WriteVarLong writes a variable-length 64-bit integer to the packet. +func (p *OutboundPacket) WriteVarLong(n int64) { + buf := make([]byte, binary.MaxVarintLen64) + size := binary.PutUvarint(buf, uint64(n)) + p.WriteBytes(buf[:size]) +} + +// WriteBool writes a boolean value to the packet. +func (p *OutboundPacket) WriteBool(value bool) { + if value { + p.WriteByte(1) + } else { + p.WriteByte(0) + } +} + +// WriteString writes a string to the packet. +func (p *OutboundPacket) WriteString(str string) error { + length := len(str) + if length > MaxStringLength { + return fmt.Errorf("string is longer than %d", MaxStringLength) + } + + p.WriteVarInt(int32(length)) + p.WriteBytes([]byte(str)) + + return nil +} + +// WriteByte writes a single byte to the packet. +func (p *OutboundPacket) WriteByte(b byte) { + p.body = append(p.body, b) +} + +// WriteBytes writes a byte slice to the packet. +func (p *OutboundPacket) WriteBytes(b []byte) { + p.body = append(p.body, b...) +} + +// Write sends the packet over the given network connection. +func (p *OutboundPacket) Write(conn net.Conn) error { + payload := append(encodeVarInt(p.id), p.body...) + length := len(payload) + + if length > MaxPacketLength { + return fmt.Errorf("packet exceeds max packet length of %d by %d bytes", MaxPacketLength, length-MaxPacketLength) + } + + if _, err := conn.Write(encodeVarInt(int32(length))); err != nil { + return fmt.Errorf("failed to write packet length: %w", err) + } + + if _, err := conn.Write(payload); err != nil { + return fmt.Errorf("failed to write packet payload: %w", err) + } + + return nil +} + +// encodeVarInt encodes an integer into a variable-length byte slice. +func encodeVarInt(value int32) []byte { + buf := make([]byte, binary.MaxVarintLen32) + size := binary.PutUvarint(buf, uint64(value)) + return buf[:size] +} diff --git a/slp/response.go b/slp/response.go new file mode 100644 index 0000000..41108b3 --- /dev/null +++ b/slp/response.go @@ -0,0 +1,195 @@ +package slp + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" +) + +// Response represents the Server List Ping (SLP) response. +type Response struct { + // Documentation links for reference: + // https://wiki.vg/Server_List_Ping + // https://wiki.vg/Minecraft_Forge_Handshake#Changes_to_Server_List_Ping + + Version Version `json:"version"` + Players Players `json:"players"` + Favicon string `json:"favicon,omitempty"` + Description Description `json:"description"` + EnforcesSecureChat bool `json:"enforcesSecureChat,omitempty"` + PreviewsChat bool `json:"previewsChat,omitempty"` + + // Forge related data + ForgeModInfo *LegacyForgeModInfo `json:"modinfo,omitempty"` + ForgeData *ForgeData `json:"forgeData,omitempty"` + + // Latency measured by the client + Latency int `json:"latency,omitempty"` +} + +// Version represents the version information in the SLP response. +type Version struct { + Name string `json:"name"` + Protocol int `json:"protocol"` +} + +// Players represents player information in the SLP response. +type Players struct { + Max int `json:"max"` + Online int `json:"online"` + Sample []Player `json:"sample,omitempty"` +} + +// Player represents an individual player's information in the SLP response. +type Player struct { + Name string `json:"name"` + Id string `json:"id"` +} + +// ForgeData represents Forge mod data in the SLP response. +type ForgeData struct { + Channels []ForgeChannel `json:"channels"` + Mods []ForgeMod `json:"mods"` + FMLNetworkVersion int `json:"fmlNetworkVersion"` +} + +// ForgeChannel represents a Forge mod channel in ForgeData. +type ForgeChannel struct { + Res string `json:"res"` + Version string `json:"version"` + Required bool `json:"required"` +} + +// ForgeMod represents a Forge mod in ForgeData. +type ForgeMod struct { + ModID string `json:"modId"` + ModMarker string `json:"modmarker"` +} + +// LegacyForgeModInfo represents legacy Forge mod information in the SLP response. +type LegacyForgeModInfo struct { + Type string `json:"type"` + ModList []LegacyForgeMod `json:"modList"` +} + +// LegacyForgeMod represents a legacy Forge mod in LegacyForgeModInfo. +type LegacyForgeMod struct { + ModID string `json:"modid"` + Version string `json:"version"` +} + +// Description represents a Description in the SLP response. +// Description wraps a ChatComponent due to encoding limitations with dynamic JSON in go. +type Description struct { + Description ChatComponent +} + +// String converts the Description into a string. +func (d *Description) String() string { + return d.Description.String() +} + +// UnmarshalJSON unmarshalls a description into a ChatComponent. +// The description can be represented as a ChatComponent or string. +func (d *Description) UnmarshalJSON(b []byte) error { + // ToDo: translate color/formatting codes to JSON + // https://wiki.vg/Chat + // https://github.com/Sch8ill/rcon/blob/master/color/color.go + if b[0] == '"' { + var text string + if err := json.Unmarshal(b, &text); err != nil { + return err + } + d.Description.Text = text + + return nil + } + + if err := json.Unmarshal(b, &d.Description); err != nil { + return err + } + + return nil +} + +// MarshalJSON marshals a Description by returning a marshalled ChatComponent. +func (d *Description) MarshalJSON() ([]byte, error) { + return json.Marshal(d.Description) +} + +// ChatComponent represents a Minecraft chat type used in the SLP response description. +type ChatComponent struct { + Text string `json:"text"` + Bold bool `json:"bold,omitempty"` + Italic bool `json:"italic,omitempty"` + Underlined bool `json:"underlined,omitempty"` + Strikethrough bool `json:"strikethrough,omitempty"` + Obfuscated bool `json:"obfuscated,omitempty"` + Font string `json:"font,omitempty"` + Color string `json:"color,omitempty"` + Insertion string `json:"insertion,omitempty"` + ClickEvent *ClickEvent `json:"clickEvent,omitempty"` + HoverEvent *HoverEvent `json:"hoverEvent,omitempty"` + Extra []ChatComponent `json:"extra,omitempty"` +} + +// String converts the ChatComponent into a string. +func (c *ChatComponent) String() string { + text := c.Text + for _, extra := range c.Extra { + text += extra.Text + } + + return text +} + +// ClickEvent represents click event inside a chat component. +type ClickEvent struct { + Action string `json:"action"` + Value string `json:"value"` +} + +// HoverEvent represents a hover event inside a chat component. +type HoverEvent struct { + Action string `json:"action"` + Contents string `json:"contents"` +} + +// NewResponse parses a raw SLP response string into a Response struct. +func NewResponse[T []byte | string](rawRes T) (*Response, error) { + res := new(Response) + + if err := json.Unmarshal([]byte(rawRes), &res); err != nil { + return nil, err + } + + return res, nil +} + +// String converts the response to a JSON string. +func (r *Response) String() (string, error) { + res, err := json.Marshal(r) + if err != nil { + return "", fmt.Errorf("failed to convert to JSON: %w", err) + } + + return string(res), nil +} + +// Icon decodes the favicon string into byte data. +func (r *Response) Icon() ([]byte, error) { + if r.Favicon == "" { + return nil, errors.New("status response does not contain a favicon") + } + + base64Icon := strings.TrimPrefix(r.Favicon, "data:image/png;base64,") + + iconBytes, err := base64.StdEncoding.DecodeString(base64Icon) + if err != nil { + return nil, fmt.Errorf("failed to convert base64 image to bytes: %w", err) + } + + return iconBytes, nil +}