├── .gitignore ├── LICENSE ├── README.md ├── api └── structs.go ├── gamequery.go ├── go.mod └── internal ├── network_helper.go ├── packet.go ├── protocol.go └── protocols ├── minecraft_tcp.go ├── minecraft_udp.go └── source_query.go /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE 2 | .idea 3 | 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Local testing files 21 | cmd -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Stepan Fedotov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gamequery [![GoDoc](https://godoc.org/wisp-gg/gamequery?status.svg)](https://godoc.org/github.com/wisp-gg/gamequery) 2 | A Golang package for querying game servers 3 | 4 | ## Supported protocols: 5 | Source Query 6 | Minecraft TCP & UDP 7 | 8 | ## Sample code: 9 | ```go 10 | package main 11 | 12 | import ( 13 | "fmt" 14 | "github.com/wisp-gg/gamequery" 15 | "github.com/wisp-gg/gamequery/api" 16 | ) 17 | 18 | func main() { 19 | res, protocol, err := gamequery.Detect(api.Request{ 20 | IP: "127.0.0.1", 21 | Port: 27015, 22 | }) 23 | if err != nil { 24 | fmt.Printf("failed to query: %s", err) 25 | return 26 | } 27 | 28 | fmt.Printf("Detected the protocol: %s\n", protocol) 29 | fmt.Printf("%+v\n", res) 30 | } 31 | ``` 32 | 33 | NOTE: Ideally, you'd only want to use `gamequery.Detect` only once (or until one successful response), and then use `gamequery.Query` with the protocol provided. 34 | Otherwise, each `gamequery.Detect` call will try to query the game server with _all_ possible protocols. -------------------------------------------------------------------------------- /api/structs.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import "time" 4 | 5 | // Representation of a query request for a specific game server. 6 | type Request struct { 7 | Game string // The game protocol to use, can be left out for the `Detect` function. 8 | IP string // The game server's query IP 9 | Port uint16 // The game server's query port 10 | Timeout *time.Duration // Timeout for a single send/receive operation in the game's protocol. 11 | } 12 | 13 | // Player information of the server 14 | type PlayersResponse struct { 15 | Current int // The amount of players currently on the server 16 | Max int // The amount of players the server can hold 17 | Names []string // List of player names on the server, could be partial (so that the length of Names =/= Current) 18 | } 19 | 20 | // Representation of a query result for a specific game server. All of the fields are guaranteed to be present (other than the contents of Raw). 21 | type Response struct { 22 | Name string // The server name 23 | Players PlayersResponse // Player information of the server 24 | 25 | Raw interface{} // Contains the original, raw response received from the game's protocol. 26 | } 27 | 28 | // Raw Minecraft UDP response 29 | type MinecraftUDPRaw struct { 30 | Hostname string 31 | GameType string 32 | GameID string 33 | Version string 34 | Plugins string 35 | Map string 36 | NumPlayers uint16 37 | MaxPlayers uint16 38 | HostPort uint16 39 | HostIP string 40 | Players []string 41 | } 42 | 43 | // Raw Minecraft TCP response 44 | type MinecraftTCPRaw struct { 45 | Version struct { 46 | Name string 47 | Protocol int 48 | } 49 | Players struct { 50 | Max int 51 | Online int 52 | Sample []struct { 53 | Name string 54 | ID string 55 | } 56 | } 57 | Description struct { 58 | Text string 59 | } 60 | Favicon string 61 | } 62 | 63 | // Optional extra data included in SourceQuery A2S info response 64 | type SourceQuery_ExtraData struct { 65 | Port uint16 66 | SteamID uint64 67 | SourceTVPort uint16 68 | SourceTVName string 69 | Keywords string 70 | GameID uint64 71 | } 72 | 73 | // Raw Source Query A2S info response 74 | type SourceQuery_A2SInfo struct { 75 | Protocol uint8 76 | Name string 77 | Map string 78 | Folder string 79 | Game string 80 | ID uint16 81 | Players uint8 82 | MaxPlayers uint8 83 | Bots uint8 84 | ServerType uint8 85 | Environment uint8 86 | Visibility uint8 87 | VAC uint8 88 | 89 | // The Ship 90 | 91 | Version string 92 | EDF uint8 93 | ExtraData SourceQuery_ExtraData 94 | } 95 | 96 | // Single A2S_PLAYER query's player response 97 | type SourceQuery_A2SPlayer struct { 98 | Index uint8 99 | Name string 100 | Score int32 101 | Duration float32 102 | } 103 | -------------------------------------------------------------------------------- /gamequery.go: -------------------------------------------------------------------------------- 1 | package gamequery 2 | 3 | import ( 4 | "errors" 5 | "github.com/wisp-gg/gamequery/api" 6 | "github.com/wisp-gg/gamequery/internal" 7 | "github.com/wisp-gg/gamequery/internal/protocols" 8 | "sort" 9 | "sync" 10 | "time" 11 | ) 12 | 13 | var queryProtocols = []internal.Protocol{ 14 | protocols.SourceQuery{}, 15 | protocols.MinecraftUDP{}, 16 | protocols.MinecraftTCP{}, 17 | } 18 | 19 | func findProtocols(name string) []internal.Protocol { 20 | found := make([]internal.Protocol, 0) 21 | for _, protocol := range queryProtocols { 22 | if protocol.Name() == name { 23 | found = append(found, protocol) 24 | } else { 25 | for _, protocolName := range protocol.Aliases() { 26 | if protocolName == name { 27 | found = append(found, protocol) 28 | } 29 | } 30 | } 31 | } 32 | 33 | return found 34 | } 35 | 36 | type queryResult struct { 37 | Name string 38 | Priority uint16 39 | Err error 40 | Response api.Response 41 | } 42 | 43 | // Query the game server by detecting the protocol (trying all available protocols). 44 | // This usually should be used as the initial query function and then use `Query` function 45 | // with the returned protocol if the query succeeds. Otherwise each function call will take always 46 | // duration even if the response was received earlier from one of the protocols. 47 | func Detect(req api.Request) (api.Response, string, error) { 48 | return query(req, queryProtocols) 49 | } 50 | 51 | // Query the game server using the protocol provided in req.Game. 52 | func Query(req api.Request) (api.Response, error) { 53 | chosenProtocols := findProtocols(req.Game) 54 | if len(chosenProtocols) < 1 { 55 | return api.Response{}, errors.New("could not find protocols for the game") 56 | } 57 | 58 | response, _, err := query(req, chosenProtocols) 59 | return response, err 60 | } 61 | 62 | func query(req api.Request, chosenProtocols []internal.Protocol) (api.Response, string, error) { 63 | var wg sync.WaitGroup 64 | wg.Add(len(chosenProtocols)) 65 | 66 | queryResults := make([]queryResult, len(chosenProtocols)) 67 | for index, queryProtocol := range chosenProtocols { 68 | go func(queryProtocol internal.Protocol, index int) { 69 | defer wg.Done() 70 | 71 | var port = queryProtocol.DefaultPort() 72 | if req.Port != 0 { 73 | port = req.Port 74 | } 75 | 76 | var timeout = 5 * time.Second 77 | if req.Timeout != nil { 78 | timeout = *req.Timeout 79 | } 80 | 81 | networkHelper := internal.NetworkHelper{} 82 | if err := networkHelper.Initialize(queryProtocol.Network(), req.IP, port, timeout); err != nil { 83 | queryResults[index] = queryResult{ 84 | Priority: queryProtocol.Priority(), 85 | Err: err, 86 | Response: api.Response{}, 87 | } 88 | return 89 | } 90 | defer networkHelper.Close() 91 | 92 | response, err := queryProtocol.Execute(networkHelper) 93 | if err != nil { 94 | queryResults[index] = queryResult{ 95 | Priority: queryProtocol.Priority(), 96 | Err: err, 97 | Response: api.Response{}, 98 | } 99 | return 100 | } 101 | 102 | queryResults[index] = queryResult{ 103 | Name: queryProtocol.Name(), 104 | Priority: queryProtocol.Priority(), 105 | Err: nil, 106 | Response: response, 107 | } 108 | }(queryProtocol, index) 109 | } 110 | 111 | wg.Wait() 112 | sort.Slice(queryResults, func(i, j int) bool { 113 | return queryResults[i].Priority > queryResults[j].Priority 114 | }) 115 | 116 | var firstError error 117 | for _, result := range queryResults { 118 | if result.Err != nil { 119 | if firstError == nil { 120 | firstError = result.Err 121 | } 122 | } else { 123 | return result.Response, result.Name, nil 124 | } 125 | } 126 | 127 | return api.Response{}, "", firstError 128 | } 129 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/wisp-gg/gamequery 2 | 3 | go 1.13 4 | -------------------------------------------------------------------------------- /internal/network_helper.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "net" 8 | "time" 9 | ) 10 | 11 | const ( 12 | readBufSize = 2048 13 | ) 14 | 15 | type NetworkHelper struct { 16 | ip string 17 | port uint16 18 | conn net.Conn 19 | timeout time.Duration 20 | } 21 | 22 | func (helper *NetworkHelper) Initialize(protocol string, ip string, port uint16, timeout time.Duration) error { 23 | conn, err := net.DialTimeout(protocol, fmt.Sprintf("%s:%d", ip, port), timeout) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | helper.ip = ip 29 | helper.port = port 30 | helper.conn = conn 31 | helper.timeout = timeout 32 | 33 | return nil 34 | } 35 | 36 | func (helper *NetworkHelper) getTimeout() time.Time { 37 | return time.Now().Add(helper.timeout) 38 | } 39 | 40 | func (helper *NetworkHelper) Send(data []byte) error { 41 | err := helper.conn.SetWriteDeadline(helper.getTimeout()) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | _, err = helper.conn.Write(data) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | return nil 52 | } 53 | 54 | func (helper *NetworkHelper) Receive() (Packet, error) { 55 | err := helper.conn.SetReadDeadline(helper.getTimeout()) 56 | if err != nil { 57 | return Packet{}, err 58 | } 59 | 60 | var res = &bytes.Buffer{} 61 | for { 62 | recvBuffer := make([]byte, readBufSize) 63 | recvSize, err := helper.conn.Read(recvBuffer) 64 | 65 | if recvSize > 0 { 66 | res.Write(recvBuffer[:recvSize]) 67 | } 68 | 69 | if err != nil { 70 | if err == io.EOF { 71 | break 72 | } 73 | 74 | return Packet{}, err 75 | } 76 | 77 | if recvSize < readBufSize { 78 | break 79 | } 80 | } 81 | 82 | packet := Packet{} 83 | packet.SetBuffer(res.Bytes()) 84 | 85 | return packet, nil 86 | } 87 | 88 | func (helper *NetworkHelper) Close() error { 89 | return helper.conn.Close() 90 | } 91 | 92 | func (helper *NetworkHelper) GetIP() string { 93 | return helper.ip 94 | } 95 | 96 | func (helper *NetworkHelper) GetPort() uint16 { 97 | return helper.port 98 | } 99 | -------------------------------------------------------------------------------- /internal/packet.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "encoding/binary" 5 | "math" 6 | ) 7 | 8 | type Packet struct { 9 | buffer []byte 10 | pos int 11 | invalid bool 12 | 13 | order binary.ByteOrder 14 | } 15 | 16 | func (p *Packet) WriteRaw(bytes ...byte) { 17 | for _, b := range bytes { 18 | p.buffer = append(p.buffer, b) 19 | } 20 | } 21 | 22 | func (p *Packet) WriteInt32(int int32) { 23 | buf := make([]byte, 4) 24 | p.order.PutUint32(buf, uint32(int)) 25 | 26 | p.WriteRaw(buf...) 27 | } 28 | 29 | func (p *Packet) WriteUint8(int uint8) { 30 | p.WriteRaw(int) 31 | } 32 | 33 | func (p *Packet) WriteUint16(int uint16) { 34 | buf := make([]byte, 2) 35 | p.order.PutUint16(buf, uint16(int)) 36 | 37 | p.WriteRaw(buf...) 38 | } 39 | 40 | func (p *Packet) WriteVarint(num int) { 41 | res := make([]byte, 0) 42 | for { 43 | b := num & 0x7F 44 | num >>= 7 45 | 46 | if num != 0 { 47 | b |= 0x80 48 | } 49 | 50 | res = append(res, byte(b)) 51 | 52 | if num == 0 { 53 | break 54 | } 55 | } 56 | 57 | p.WriteRaw(res...) 58 | } 59 | 60 | func (p *Packet) WriteString(str string) { 61 | p.WriteRaw([]byte(str)...) 62 | } 63 | 64 | func (p *Packet) ReadFloat32() float32 { 65 | if !p.CanRead(4) { 66 | p.invalid = true 67 | return 0 68 | } 69 | 70 | r := p.ReadUint32() 71 | return math.Float32frombits(r) 72 | } 73 | 74 | func (p *Packet) ReadUint8() uint8 { 75 | if !p.CanRead(1) { 76 | p.invalid = true 77 | return 0 78 | } 79 | 80 | r := p.buffer[p.pos] 81 | p.pos++ 82 | 83 | return r 84 | } 85 | 86 | func (p *Packet) ReadUint16() uint16 { 87 | if !p.CanRead(2) { 88 | p.invalid = true 89 | return 0 90 | } 91 | 92 | r := p.order.Uint16(p.buffer[p.pos : p.pos+2]) 93 | p.pos += 2 94 | 95 | return r 96 | } 97 | 98 | func (p *Packet) ReadUint32() uint32 { 99 | if !p.CanRead(4) { 100 | p.invalid = true 101 | return 0 102 | } 103 | 104 | r := p.order.Uint32(p.buffer[p.pos : p.pos+4]) 105 | p.pos += 4 106 | 107 | return r 108 | } 109 | 110 | func (p *Packet) ReadUint64() uint64 { 111 | if !p.CanRead(8) { 112 | p.invalid = true 113 | return 0 114 | } 115 | 116 | r := p.order.Uint64(p.buffer[p.pos : p.pos+8]) 117 | p.pos += 8 118 | 119 | return r 120 | } 121 | 122 | func (p *Packet) ReadInt8() int8 { 123 | return int8(p.ReadUint8()) 124 | } 125 | 126 | func (p *Packet) ReadInt32() int32 { 127 | return int32(p.ReadUint32()) 128 | } 129 | 130 | func (p *Packet) ReadVarint() int { 131 | var varint = 0 132 | for i := 0; i <= 5; i++ { 133 | nextByte := p.ReadUint8() 134 | varint |= (int(nextByte) & 0x7F) << (7 * i) 135 | 136 | if (nextByte & 0x80) == 0 { 137 | break 138 | } 139 | } 140 | 141 | return varint 142 | } 143 | 144 | func (p *Packet) ReadString() string { 145 | if p.ReachedEnd() { 146 | p.invalid = true 147 | return "" 148 | } 149 | 150 | start := p.pos 151 | for { 152 | if p.ReachedEnd() || p.buffer[p.pos] == 0x00 { 153 | break 154 | } 155 | 156 | p.pos++ 157 | } 158 | 159 | str := p.buffer[start:p.pos] 160 | p.pos++ 161 | 162 | return string(str) 163 | } 164 | 165 | func (p *Packet) ReadRest() []byte { 166 | if p.ReachedEnd() { 167 | p.invalid = true 168 | return []byte{} 169 | } 170 | 171 | res := p.buffer[p.pos:p.Length()] 172 | p.pos = p.Length() 173 | 174 | return res 175 | } 176 | 177 | func (p *Packet) CanRead(bytes int) bool { 178 | return p.pos+bytes <= p.Length() 179 | } 180 | 181 | func (p *Packet) ReachedEnd() bool { 182 | return !p.CanRead(1) 183 | } 184 | 185 | func (p *Packet) SetOrder(order binary.ByteOrder) { 186 | p.order = order 187 | } 188 | 189 | func (p *Packet) SetBuffer(buffer []byte) { 190 | p.buffer = buffer 191 | } 192 | 193 | func (p *Packet) GetBuffer() []byte { 194 | return p.buffer 195 | } 196 | 197 | func (p *Packet) Forward(count int) { 198 | p.pos += count 199 | 200 | if p.pos < 0 { 201 | p.pos = 0 202 | } 203 | } 204 | 205 | func (p *Packet) Clear() { 206 | p.pos = 0 207 | p.buffer = make([]byte, 0) 208 | } 209 | 210 | func (p *Packet) Length() int { 211 | return len(p.buffer) 212 | } 213 | 214 | func (p *Packet) IsInvalid() bool { 215 | return p.invalid 216 | } 217 | 218 | func (p *Packet) AsString() string { 219 | return string(p.GetBuffer()) 220 | } 221 | -------------------------------------------------------------------------------- /internal/protocol.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "github.com/wisp-gg/gamequery/api" 5 | ) 6 | 7 | type Protocol interface { 8 | Name() string 9 | Aliases() []string 10 | DefaultPort() uint16 11 | Priority() uint16 12 | Network() string 13 | 14 | Execute(helper NetworkHelper) (api.Response, error) 15 | } 16 | -------------------------------------------------------------------------------- /internal/protocols/minecraft_tcp.go: -------------------------------------------------------------------------------- 1 | package protocols 2 | 3 | import ( 4 | "encoding/binary" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "github.com/wisp-gg/gamequery/api" 9 | "github.com/wisp-gg/gamequery/internal" 10 | ) 11 | 12 | type MinecraftTCP struct{} 13 | 14 | func (mc MinecraftTCP) Name() string { 15 | return "minecraft_tcp" 16 | } 17 | 18 | func (mc MinecraftTCP) Aliases() []string { 19 | return []string{ 20 | "minecraft", 21 | } 22 | } 23 | 24 | func (mc MinecraftTCP) DefaultPort() uint16 { 25 | return 25565 26 | } 27 | 28 | func (mc MinecraftTCP) Priority() uint16 { 29 | return 1 30 | } 31 | 32 | func (mc MinecraftTCP) Network() string { 33 | return "tcp" 34 | } 35 | 36 | func buildMCPacket(bulkData ...interface{}) *internal.Packet { 37 | packet := internal.Packet{} 38 | packet.SetOrder(binary.BigEndian) 39 | 40 | tmpPacket := internal.Packet{} 41 | tmpPacket.SetOrder(binary.BigEndian) 42 | for _, data := range bulkData { 43 | switch val := data.(type) { 44 | case string: 45 | tmpPacket.WriteVarint(len(val)) 46 | tmpPacket.WriteString(val) 47 | case int: 48 | tmpPacket.WriteVarint(val) 49 | case uint16: 50 | tmpPacket.WriteUint16(val) 51 | case []byte: 52 | tmpPacket.WriteRaw(val...) 53 | default: 54 | fmt.Printf("gamequery: unhandled type %s for Minecraft TCP packet, ignoring...\n", val) 55 | } 56 | } 57 | 58 | packet.WriteVarint(tmpPacket.Length()) 59 | packet.WriteRaw(tmpPacket.GetBuffer()...) 60 | 61 | return &packet 62 | } 63 | 64 | func (mc MinecraftTCP) Execute(helper internal.NetworkHelper) (api.Response, error) { 65 | err := helper.Send(buildMCPacket([]byte{0x00, 0x00}, helper.GetIP(), helper.GetPort(), 0x01).GetBuffer()) 66 | if err != nil { 67 | return api.Response{}, err 68 | } 69 | 70 | err = helper.Send(buildMCPacket(0x00).GetBuffer()) 71 | if err != nil { 72 | return api.Response{}, err 73 | } 74 | 75 | responsePacket, err := helper.Receive() 76 | if err != nil { 77 | return api.Response{}, err 78 | } 79 | 80 | packetLength := responsePacket.ReadVarint() 81 | packetId := responsePacket.ReadVarint() 82 | if packetId != 0 { 83 | return api.Response{}, errors.New("received something else than a status response") 84 | } 85 | 86 | if packetId > packetLength { 87 | responsePacket.ReadVarint() // No idea what this is 88 | } 89 | 90 | responsePacket.ReadVarint() // Actual JSON strings' length (unneeded with ReadString) 91 | jsonBody := responsePacket.ReadString() 92 | 93 | if responsePacket.IsInvalid() { 94 | return api.Response{}, errors.New("received packet is invalid") 95 | } 96 | 97 | raw := api.MinecraftTCPRaw{} 98 | err = json.Unmarshal([]byte(jsonBody), &raw) 99 | if err != nil { 100 | return api.Response{}, err 101 | } 102 | 103 | var playerList []string 104 | for _, player := range raw.Players.Sample { 105 | playerList = append(playerList, player.Name) 106 | } 107 | 108 | return api.Response{ 109 | Name: raw.Version.Name, 110 | Players: api.PlayersResponse{ 111 | Current: raw.Players.Online, 112 | Max: raw.Players.Max, 113 | Names: playerList, 114 | }, 115 | 116 | Raw: raw, 117 | }, nil 118 | } 119 | -------------------------------------------------------------------------------- /internal/protocols/minecraft_udp.go: -------------------------------------------------------------------------------- 1 | package protocols 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | "errors" 7 | "github.com/wisp-gg/gamequery/api" 8 | "github.com/wisp-gg/gamequery/internal" 9 | "math/rand" 10 | "strconv" 11 | "time" 12 | ) 13 | 14 | type MinecraftUDP struct{} 15 | 16 | func (mc MinecraftUDP) Name() string { 17 | return "minecraft_udp" 18 | } 19 | 20 | func (mc MinecraftUDP) Aliases() []string { 21 | return []string{ 22 | "minecraft", 23 | } 24 | } 25 | 26 | func (mc MinecraftUDP) DefaultPort() uint16 { 27 | return 25565 28 | } 29 | 30 | func (mc MinecraftUDP) Priority() uint16 { 31 | return 10 32 | } 33 | 34 | func (mc MinecraftUDP) Network() string { 35 | return "udp" 36 | } 37 | 38 | func generateSessionID() int32 { 39 | rand.Seed(time.Now().UTC().UnixNano()) 40 | 41 | return rand.Int31() & 0x0F0F0F0F 42 | } 43 | 44 | func parseChallengeToken(challengeToken string) ([]byte, error) { 45 | parsedInt, err := strconv.ParseInt(challengeToken, 10, 32) 46 | if err != nil { 47 | return []byte{}, err 48 | } 49 | 50 | buf := &bytes.Buffer{} 51 | err = binary.Write(buf, binary.BigEndian, parsedInt) 52 | if err != nil { 53 | return []byte{}, err 54 | } 55 | 56 | return buf.Bytes()[buf.Len()-4:], nil 57 | } 58 | 59 | func (mc MinecraftUDP) Execute(helper internal.NetworkHelper) (api.Response, error) { 60 | sessionId := generateSessionID() 61 | 62 | packet := internal.Packet{} 63 | packet.SetOrder(binary.BigEndian) 64 | packet.WriteRaw(0xFE, 0xFD, 0x09) 65 | packet.WriteInt32(sessionId) 66 | 67 | err := helper.Send(packet.GetBuffer()) 68 | if err != nil { 69 | return api.Response{}, err 70 | } 71 | 72 | handshakePacket, err := helper.Receive() 73 | if err != nil { 74 | return api.Response{}, err 75 | } 76 | 77 | handshakePacket.SetOrder(binary.BigEndian) 78 | if handshakePacket.ReadUint8() != 0x09 { 79 | return api.Response{}, errors.New("sent a handshake, but didn't receive handshake response back") 80 | } 81 | 82 | if handshakePacket.ReadInt32() != sessionId { 83 | return api.Response{}, errors.New("received handshake for wrong session id") 84 | } 85 | 86 | challengeToken, err := parseChallengeToken(handshakePacket.ReadString()) 87 | if err != nil { 88 | return api.Response{}, err 89 | } 90 | 91 | packet.Clear() 92 | packet.WriteRaw(0xFE, 0xFD, 0x00) 93 | packet.WriteInt32(sessionId) 94 | packet.WriteRaw(challengeToken...) 95 | packet.WriteRaw(0x00, 0x00, 0x00, 0x00) 96 | 97 | err = helper.Send(packet.GetBuffer()) 98 | if err != nil { 99 | return api.Response{}, err 100 | } 101 | 102 | responsePacket, err := helper.Receive() 103 | if err != nil { 104 | return api.Response{}, err 105 | } 106 | 107 | responsePacket.SetOrder(binary.BigEndian) 108 | if responsePacket.ReadUint8() != 0x00 { 109 | return api.Response{}, errors.New("sent a full stat request, but didn't receive stat response back") 110 | } 111 | 112 | if responsePacket.ReadInt32() != sessionId { 113 | return api.Response{}, errors.New("received handshake for wrong session id") 114 | } 115 | 116 | responsePacket.Forward(11) 117 | 118 | raw := api.MinecraftUDPRaw{} 119 | for { 120 | key := responsePacket.ReadString() 121 | if key == "" { 122 | break 123 | } 124 | 125 | val := responsePacket.ReadString() 126 | 127 | switch key { 128 | case "hostname": 129 | raw.Hostname = val 130 | case "gametype": 131 | raw.GameType = val 132 | case "game_id": 133 | raw.GameID = val 134 | case "version": 135 | raw.Version = val 136 | case "plugins": 137 | raw.Plugins = val 138 | case "map": 139 | raw.Map = val 140 | case "numplayers": 141 | tmp, _ := strconv.ParseInt(val, 10, 16) 142 | raw.NumPlayers = uint16(tmp) 143 | case "maxplayers": 144 | tmp, _ := strconv.ParseInt(val, 10, 16) 145 | raw.MaxPlayers = uint16(tmp) 146 | case "hostport": 147 | tmp, _ := strconv.ParseInt(val, 10, 16) 148 | raw.HostPort = uint16(tmp) 149 | case "hostip": 150 | raw.HostIP = val 151 | } 152 | } 153 | 154 | responsePacket.Forward(10) 155 | 156 | for { 157 | playerName := responsePacket.ReadString() 158 | if playerName == "" { 159 | break 160 | } 161 | 162 | raw.Players = append(raw.Players, playerName) 163 | } 164 | 165 | if responsePacket.IsInvalid() { 166 | return api.Response{}, errors.New("received packet is invalid") 167 | } 168 | 169 | return api.Response{ 170 | Name: raw.Hostname, 171 | Players: api.PlayersResponse{ 172 | Current: int(raw.NumPlayers), 173 | Max: int(raw.MaxPlayers), 174 | Names: raw.Players, 175 | }, 176 | 177 | Raw: raw, 178 | }, nil 179 | } 180 | -------------------------------------------------------------------------------- /internal/protocols/source_query.go: -------------------------------------------------------------------------------- 1 | package protocols 2 | 3 | import ( 4 | "encoding/binary" 5 | "errors" 6 | "fmt" 7 | "github.com/wisp-gg/gamequery/api" 8 | "github.com/wisp-gg/gamequery/internal" 9 | "sort" 10 | ) 11 | 12 | type SourceQuery struct{} 13 | 14 | func (sq SourceQuery) Name() string { 15 | return "source" 16 | } 17 | 18 | func (sq SourceQuery) Aliases() []string { 19 | return []string{} 20 | } 21 | 22 | func (sq SourceQuery) DefaultPort() uint16 { 23 | return 27015 24 | } 25 | 26 | func (sq SourceQuery) Priority() uint16 { 27 | return 1 28 | } 29 | 30 | func (sq SourceQuery) Network() string { 31 | return "udp" 32 | } 33 | 34 | type partialPacket struct { 35 | ID int32 36 | Number int8 37 | Size uint16 38 | Data []byte 39 | } 40 | 41 | func (sq SourceQuery) handleMultiplePackets(helper internal.NetworkHelper, initialPacket internal.Packet) (internal.Packet, error) { 42 | var initial = true 43 | var curPacket = initialPacket 44 | var packets []partialPacket 45 | var compressed = false 46 | var decompressedSize, crc32 int32 = 0, 0 47 | for { 48 | if !initial { 49 | var err error 50 | curPacket, err = helper.Receive() 51 | if err != nil { 52 | return internal.Packet{}, err 53 | } 54 | 55 | curPacket.SetOrder(binary.LittleEndian) 56 | } 57 | 58 | if curPacket.ReadInt32() != -2 { 59 | return internal.Packet{}, errors.New("received packet isn't part of split response") 60 | } 61 | 62 | // For the sake of simplicity, we'll assume that the server is Source based instead of possibly Goldsource. 63 | id, total, number, size := curPacket.ReadInt32(), curPacket.ReadInt8(), curPacket.ReadInt8(), curPacket.ReadUint16() 64 | if initial { 65 | compressed = uint32(id)&0x80000000 != 0 66 | 67 | if compressed { 68 | decompressedSize, crc32 = curPacket.ReadInt32(), curPacket.ReadInt32() 69 | } 70 | 71 | initial = false 72 | } 73 | 74 | packets = append(packets, partialPacket{ 75 | ID: id, 76 | Number: number, 77 | Size: size, 78 | Data: curPacket.ReadRest(), 79 | }) 80 | 81 | if curPacket.IsInvalid() { 82 | return internal.Packet{}, errors.New("split packet response was malformed") 83 | } 84 | 85 | if len(packets) == int(total) { 86 | break 87 | } 88 | } 89 | 90 | sort.Slice(packets, func(i, j int) bool { 91 | return packets[i].Number < packets[j].Number 92 | }) 93 | 94 | packet := internal.Packet{} 95 | packet.SetOrder(binary.LittleEndian) 96 | for _, partial := range packets { 97 | packet.WriteRaw(partial.Data...) 98 | } 99 | 100 | if compressed { 101 | // TODO: Handle decompression (only engines from ~2006-era seem to implement this) 102 | 103 | return internal.Packet{}, errors.New("received packet that is bz2 compressed (" + string(decompressedSize) + ", " + string(crc32) + ")") 104 | } 105 | 106 | // The constructed packet will resemble the simple response format, so we need to get rid of 107 | // the FF FF FF FF prefix (as we'll return to logic after the initial header reading). 108 | packet.ReadInt32() 109 | 110 | return packet, nil 111 | } 112 | 113 | func (sq SourceQuery) handleReceivedPacket(helper internal.NetworkHelper, packet internal.Packet) (internal.Packet, error) { 114 | packetType := packet.ReadInt32() 115 | if packetType == -1 { 116 | return packet, nil 117 | } 118 | 119 | if packetType == -2 { 120 | packet.Forward(-4) // Seek back so we're able to reread the data in handleMultiplePackets 121 | 122 | return sq.handleMultiplePackets(helper, packet) 123 | } 124 | 125 | return internal.Packet{}, errors.New(fmt.Sprintf("unable to handle unknown packet type %d", packetType)) 126 | } 127 | 128 | func (sq SourceQuery) request(helper internal.NetworkHelper, requestPacket internal.Packet, wantedId uint8, allowChallengeRequest bool) (internal.Packet, error) { 129 | if err := helper.Send(requestPacket.GetBuffer()); err != nil { 130 | return internal.Packet{}, err 131 | } 132 | 133 | packet, err := helper.Receive() 134 | if err != nil { 135 | return internal.Packet{}, err 136 | } 137 | 138 | packet.SetOrder(binary.LittleEndian) 139 | packet, err = sq.handleReceivedPacket(helper, packet) 140 | if err != nil { 141 | return internal.Packet{}, err 142 | } 143 | 144 | responseType := packet.ReadUint8() 145 | if responseType == wantedId { 146 | return packet, nil 147 | } 148 | 149 | if responseType != 0x41 { 150 | return internal.Packet{}, errors.New(fmt.Sprintf("unable to handle unknown response type %d", responseType)) 151 | } 152 | 153 | // If a challenge response fails, the game may respond with another challenge. 154 | // To avoid a recursive loop, we explicitly disallow requesting new challenges after 155 | // a single challenge request has been done (initial request). 156 | if !allowChallengeRequest { 157 | return internal.Packet{}, errors.New("unable to handle response due to disallowing challenge requests") 158 | } 159 | 160 | challengedRequest := internal.Packet{} 161 | challengedRequest.SetOrder(binary.LittleEndian) 162 | challengedRequest.WriteInt32(requestPacket.ReadInt32()) 163 | challengedRequest.WriteUint8(requestPacket.ReadUint8()) 164 | if wantedId == 0x49 { 165 | challengedRequest.WriteString("Source Engine Query") 166 | challengedRequest.WriteRaw(0x00) 167 | } 168 | challengedRequest.WriteInt32(packet.ReadInt32()) 169 | 170 | return sq.request(helper, challengedRequest, wantedId, false) 171 | } 172 | 173 | func (sq SourceQuery) Execute(helper internal.NetworkHelper) (api.Response, error) { 174 | requestPacket := internal.Packet{} 175 | requestPacket.SetOrder(binary.LittleEndian) 176 | 177 | // A2S_INFO request 178 | requestPacket.WriteRaw(0xFF, 0xFF, 0xFF, 0xFF, 0x54) 179 | requestPacket.WriteString("Source Engine Query") 180 | requestPacket.WriteRaw(0x00) 181 | 182 | packet, err := sq.request(helper, requestPacket, 0x49, true) 183 | if err != nil { 184 | return api.Response{}, err 185 | } 186 | 187 | raw := api.SourceQuery_A2SInfo{ 188 | Protocol: packet.ReadUint8(), 189 | Name: packet.ReadString(), 190 | Map: packet.ReadString(), 191 | Folder: packet.ReadString(), 192 | Game: packet.ReadString(), 193 | ID: packet.ReadUint16(), 194 | Players: packet.ReadUint8(), 195 | MaxPlayers: packet.ReadUint8(), 196 | Bots: packet.ReadUint8(), 197 | ServerType: packet.ReadUint8(), 198 | Environment: packet.ReadUint8(), 199 | Visibility: packet.ReadUint8(), 200 | VAC: packet.ReadUint8(), 201 | } 202 | 203 | if raw.ID == 2420 { 204 | return api.Response{}, errors.New("detected The Ship response, unsupported") 205 | } 206 | 207 | raw.Version = packet.ReadString() 208 | 209 | if !packet.ReachedEnd() { 210 | raw.EDF = packet.ReadUint8() 211 | 212 | extraData := api.SourceQuery_ExtraData{} 213 | if (raw.EDF & 0x80) != 0 { 214 | extraData.Port = packet.ReadUint16() 215 | } 216 | 217 | if (raw.EDF & 0x10) != 0 { 218 | extraData.SteamID = packet.ReadUint64() 219 | } 220 | 221 | if (raw.EDF & 0x40) != 0 { 222 | extraData.SourceTVPort = packet.ReadUint16() 223 | extraData.SourceTVName = packet.ReadString() 224 | } 225 | 226 | if (raw.EDF & 0x20) != 0 { 227 | extraData.Keywords = packet.ReadString() 228 | } 229 | 230 | if (raw.EDF & 0x01) != 0 { 231 | extraData.GameID = packet.ReadUint64() 232 | } 233 | 234 | raw.ExtraData = extraData 235 | } 236 | 237 | if packet.IsInvalid() { 238 | return api.Response{}, errors.New("received packet is invalid") 239 | } 240 | 241 | // Attempt to additionally get info from A2S_PLAYER (as it contains player names) 242 | // Though if this fails, just fail silently as it's acceptable for that information to be missing 243 | // and it's better than having no info at all. 244 | // 245 | // Depending on the game type, it may also just stop responding to A2S_PLAYER due to too many players. 246 | requestPacket.Clear() 247 | requestPacket.WriteRaw(0xFF, 0xFF, 0xFF, 0xFF, 0x55, 0xFF, 0xFF, 0xFF, 0xFF) 248 | 249 | packet, err = sq.request(helper, requestPacket, 0x44, true) 250 | var playerList []string 251 | if err == nil { 252 | packet.ReadUint8() // Number of players we received information for 253 | 254 | for { 255 | player := api.SourceQuery_A2SPlayer{ 256 | Index: packet.ReadUint8(), 257 | Name: packet.ReadString(), 258 | Score: packet.ReadInt32(), 259 | Duration: packet.ReadFloat32(), 260 | } 261 | 262 | if packet.IsInvalid() { 263 | break 264 | } 265 | 266 | playerList = append(playerList, player.Name) 267 | 268 | if packet.ReachedEnd() { 269 | break 270 | } 271 | } 272 | } 273 | 274 | return api.Response{ 275 | Name: raw.Name, 276 | Players: api.PlayersResponse{ 277 | Current: int(raw.Players), 278 | Max: int(raw.MaxPlayers), 279 | Names: playerList, 280 | }, 281 | 282 | Raw: raw, 283 | }, nil 284 | } 285 | --------------------------------------------------------------------------------