diff --git a/internal/a2s/client.go b/internal/a2s/client.go new file mode 100644 index 0000000..30cbf3d --- /dev/null +++ b/internal/a2s/client.go @@ -0,0 +1,202 @@ +package a2s + +import ( + "encoding/binary" + "errors" + "fmt" + "net" + "time" +) + +// Errors +var ( + ErrPlayerRead = errors.New("failed to read player data") + ErrMultiPacketInvalid = errors.New("invalid multi-packet ID mismatch") + ErrMultiPacketMismatch = errors.New("multi-packet assembly failed") + errBzip2 = errors.New("bzip2 compressed response not supported") +) + +// Flag type for request/response types +type Flag byte + +const ( + PlayerRequest Flag = 0x55 + ChallengeResponse Flag = 0x41 + + DefaultBufferSize = 1400 + DefaultDeadlineTimeout = 5 + singlePacket uint32 = 0xFFFFFFFF +) + +// Client handles connection and options +type Client struct { + Conn *net.UDPConn + Address *net.UDPAddr + Timeout time.Duration + BufferSize uint16 +} + +// New creates a new Client and dials the connection +func New(ip string, port int) (*Client, error) { + return NewWithAddr(&net.UDPAddr{IP: net.ParseIP(ip), Port: port}) +} + +func NewWithString(addr string) (*Client, error) { + udpAddr, err := net.ResolveUDPAddr("udp", addr) + if err != nil { + return nil, err + } + return NewWithAddr(udpAddr) +} + +func NewWithAddr(addr *net.UDPAddr) (*Client, error) { + client := &Client{ + Address: addr, + Timeout: DefaultDeadlineTimeout * time.Second, + BufferSize: DefaultBufferSize, + } + return client, client.Dial() +} + +func (c *Client) Dial() error { + conn, err := net.DialUDP("udp", nil, c.Address) + if err != nil { + return err + } + c.Conn = conn + return nil +} + +func (c *Client) Close() error { + return c.Conn.Close() +} + +func (c *Client) SetBufferSize(size uint16) { + c.BufferSize = size +} + +func (c *Client) SetDeadlineTimeout(seconds int) { + c.Timeout = time.Duration(seconds) * time.Second +} + +func (c *Client) Get(requestType Flag) ([]byte, Flag, time.Duration, error) { + resp, duration, err := c.request(requestType, singlePacket) + if err != nil { + return nil, 0, 0, err + } + flag := Flag(resp[4]) + + if flag == ChallengeResponse { + challenge := binary.BigEndian.Uint32(resp[5:9]) + resp, _, err = c.request(requestType, challenge) + if err != nil { + return nil, 0, 0, err + } + flag = Flag(resp[4]) + } + + if err := validateResponseType(requestType, flag); err != nil { + return resp[5:], flag, duration, err + } + + return resp[5:], flag, duration, nil +} + +func (c *Client) request(requestType Flag, challenge uint32) ([]byte, time.Duration, error) { + req, err := createHeader(requestType, challenge) + if err != nil { + return nil, 0, err + } + + start := time.Now() + + if _, err := c.Conn.Write(req); err != nil { + return nil, 0, err + } + if err := c.Conn.SetReadDeadline(time.Now().Add(c.Timeout)); err != nil { + return nil, 0, err + } + + resp := make([]byte, c.BufferSize) + n, err := c.Conn.Read(resp) + if err != nil { + return nil, 0, err + } + + duration := time.Since(start) + + multi, err := isMultiPacket(resp) + if err != nil { + return resp, 0, err + } + + if !multi { + return resp[:n], duration, nil + } + + packetID := binary.LittleEndian.Uint32(resp[4:8]) + packetCount := int(resp[8] & 0x0F) + currentPacket := int(resp[9] & 0x0F) + + if (packetID & 0x80000000) != 0 { + return nil, 0, errBzip2 + } + + packets := make(map[int][]byte) + packets[currentPacket] = resp[12:n] + + for len(packets) < packetCount { + buf := make([]byte, c.BufferSize) + n, err := c.Conn.Read(buf) + if err != nil { + return nil, 0, err + } + if binary.LittleEndian.Uint32(buf[4:8]) != packetID { + return nil, 0, ErrMultiPacketInvalid + } + + currentPacket = int(buf[9] & 0x0F) + if _, exists := packets[currentPacket]; !exists { + packets[currentPacket] = buf[12:n] + } + } + + var assembledResp []byte + for i := 0; i < packetCount; i++ { + data, exists := packets[i] + if !exists { + return nil, 0, ErrMultiPacketMismatch + } + assembledResp = append(assembledResp, data...) + } + + return assembledResp, duration, nil +} + +// Helpers + +func createHeader(requestType Flag, challenge uint32) ([]byte, error) { + header := []byte{0xFF, 0xFF, 0xFF, 0xFF, byte(requestType)} + if challenge != singlePacket { + challengeBytes := make([]byte, 4) + binary.BigEndian.PutUint32(challengeBytes, challenge) + header = append(header, challengeBytes...) + } + return header, nil +} + +func validateResponseType(request, response Flag) error { + if request == PlayerRequest && response != 0x44 { + return fmt.Errorf("unexpected player response flag: 0x%X", response) + } + return nil +} + +func isMultiPacket(buf []byte) (bool, error) { + if len(buf) < 4 { + return false, errors.New("packet too short") + } + header := binary.LittleEndian.Uint32(buf[:4]) + return header == 0xFFFFFFFE, nil +} + diff --git a/scratch b/scratch new file mode 100644 index 0000000..f5546c2 --- /dev/null +++ b/scratch @@ -0,0 +1,16 @@ +client, err := a2s.New("127.0.0.1", 27016) +if err != nil { + panic(err) +} + +client.SetBufferSize(2048) +client.SetDeadlineTimeout(3) + +defer client.Close() + +players,err := client.GetPlayers() +if err != nil { + panic(err) +} else { + fmt.Printf("%+v\n", players) +}