package client import ( "bytes" "fmt" "net" "strconv" "time" "github.com/pkg/errors" ) const ( outOfBandPrefix = "\xff\xff\xff\xff" bufSize = 1024 * 1024 timeout = 5 * time.Second ) type Q3Client struct { address *net.UDPAddr conn *net.UDPConn } type Status struct { Cfg map[string]string Players []Player } type Player struct { Name string Ping int Score int } func New(host string, port int) (*Q3Client, error) { // Resolve host in case it's a hostname not an IP addr, err := net.ResolveUDPAddr( "udp4", fmt.Sprintf("%s:%d", host, port), ) if err != nil { return nil, err } // Open UDP socket to server conn, err := net.DialUDP("udp4", nil, addr) if err != nil { return nil, err } // Build client object return &Q3Client{ address: addr, conn: conn, }, nil } func (c *Q3Client) Close() error { if err := c.conn.Close(); err != nil { return err } return nil } func (c *Q3Client) Ping() error { _, err := c.sendCommand("getinfo") return err } func (c *Q3Client) GetInfo() (map[string]string, error) { resp, err := c.sendCommand("getinfo") if err != nil { return nil, err } return parseAsMap(resp), nil } func (c *Q3Client) GetStatus() (*Status, error) { data, err := c.sendCommand("getstatus") if err != nil { return nil, err } // Try to split into three parts by newlines: // header \n status-string \n players data = bytes.TrimSuffix(data, []byte("\n")) parts := bytes.SplitN(data, []byte("\n"), 3) switch len(parts) { case 2: // only header and status-string -> no players return &Status{ Cfg: parseAsMap(parts[1]), Players: make([]Player, 0), }, nil case 3: // header + status-string + players players, err := parsePlayers(parts[2]) if err != nil { return nil, err } return &Status{ Cfg: parseAsMap(parts[1]), Players: players, }, nil default: return nil, errors.Errorf( "cannot parse response: %q", data, ) } } func (c *Q3Client) sendCommand(command string) ([]byte, error) { // Reset socket timeout if err := c.conn.SetDeadline(time.Now().Add(timeout)); err != nil { return nil, err } // Send our message if _, err := c.conn.Write( fmt.Appendf(nil, "%s%s", outOfBandPrefix, command), ); err != nil { return nil, err } // Read the reply buf := make([]byte, bufSize) n, _, err := c.conn.ReadFrom(buf) if err != nil { return nil, err } return buf[:n], nil } func parseAsMap(data []byte) map[string]string { // Strip header if any if i := bytes.Index(data, []byte("\n")); i >= 0 { data = data[i+1:] } // Trim ends data = bytes.TrimPrefix(data, []byte("\\")) data = bytes.TrimSuffix(data, []byte("\n")) // Split up by backslashes and mapify parts := bytes.Split(data, []byte("\\")) m := make(map[string]string) for i := 0; i < len(parts)-1; i += 2 { m[string(parts[i])] = string(parts[i+1]) } return m } func parsePlayers(data []byte) ([]Player, error) { players := make([]Player, 0) for p := range bytes.SplitSeq(data, []byte("\n")) { parts := bytes.SplitN(p, []byte(" "), 3) if len(parts) != 3 { continue } score, err := strconv.Atoi(string(parts[0])) if err != nil { return nil, err } ping, err := strconv.Atoi(string(parts[1])) if err != nil { return nil, err } name, err := strconv.Unquote(string(parts[2])) if err != nil { return nil, err } players = append( players, Player{Name: name, Score: score, Ping: ping}, ) } return players, nil }