From 2acfe1fa37077d64d039d20a0d02dbf12e3ec3ea Mon Sep 17 00:00:00 2001 From: lwaddicor Date: Fri, 25 Jun 2021 17:19:59 +0100 Subject: [PATCH 1/4] feat: Add sqp sample server This adds an SQP server sample app --- cmd/cli/main.go | 92 +++++++++++++-- go.sum | 3 - lib/svrsample/README.md | 8 ++ lib/svrsample/common/wire_encoder.go | 67 +++++++++++ lib/svrsample/protocol/interfaces.go | 7 ++ lib/svrsample/protocol/sqp/register.go | 7 ++ lib/svrsample/protocol/sqp/server_info.go | 38 ++++++ lib/svrsample/protocol/sqp/sqp.go | 136 ++++++++++++++++++++++ lib/svrsample/protocol/sqp/sqp_test.go | 55 +++++++++ lib/svrsample/query.go | 38 ++++++ 10 files changed, 438 insertions(+), 13 deletions(-) create mode 100644 lib/svrsample/README.md create mode 100644 lib/svrsample/common/wire_encoder.go create mode 100644 lib/svrsample/protocol/interfaces.go create mode 100644 lib/svrsample/protocol/sqp/register.go create mode 100644 lib/svrsample/protocol/sqp/server_info.go create mode 100644 lib/svrsample/protocol/sqp/sqp.go create mode 100644 lib/svrsample/protocol/sqp/sqp_test.go create mode 100644 lib/svrsample/query.go diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 9069025..cd9c1bd 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -5,31 +5,44 @@ import ( "flag" "fmt" "log" + "net" "os" + "time" "github.com/multiplay/go-svrquery/lib/svrquery" + "github.com/multiplay/go-svrquery/lib/svrsample" ) func main() { - address := flag.String("addr", "", "Address e.g. 127.0.0.1:12345") + clientAddr := flag.String("addr", "", "Address e.g. 127.0.0.1:12345") proto := flag.String("proto", "", "Protocol e.g. sqp, tf2e, tf2e-v7, tf2e-v8") + serverAddr := flag.String("server", "", "Address to start server e.g. 127.0.0.1:12121, :23232") flag.Parse() l := log.New(os.Stderr, "", 0) - if *address == "" { - l.Println("No address provided") - flag.PrintDefaults() - os.Exit(1) + if *serverAddr != "" && *clientAddr != "" { + bail(l, "Cannot run both a server and a client. Specify either -addr OR -server flags") } - if *proto == "" { - l.Println("No protocol provided") - flag.PrintDefaults() - os.Exit(1) + if *serverAddr != "" { + if *proto == "" { + bail(l, "No protocol provided") + } + serverMode(l, *proto, *serverAddr) + } else { + if *proto == "" { + bail(l, "Protocol required in server mode") + } + if *clientAddr == "" { + bail(l, "No address provided") + } + queryMode(l, *proto, *clientAddr) } +} - if err := query(*proto, *address); err != nil { +func queryMode(l *log.Logger, proto, address string) { + if err := query(proto, address); err != nil { l.Fatal(err) } } @@ -53,3 +66,62 @@ func query(proto, address string) error { fmt.Printf("%s\n", b) return nil } + +func serverMode(l *log.Logger, proto, serverAddr string) { + if err := server(l, proto, serverAddr); err != nil { + l.Fatal(err) + } +} + +func server(l *log.Logger, proto, address string) error { + l.Printf("Starting sample server using protocol %s on %s", proto, address) + responder, err := svrsample.GetResponder(proto, svrsample.QueryState{ + CurrentPlayers: 1, + MaxPlayers: 2, + ServerName: "Name", + GameType: "Game Type", + Map: "Map", + Port: 1000, + }) + + addr, err := net.ResolveUDPAddr("udp4", address) + if err != nil { + return err + } + + conn, err := net.ListenUDP("udp4", addr) + if err != nil { + return err + } + + for { + buf := make([]byte, 16) + _, to, err := conn.ReadFromUDP(buf) + if err != nil { + l.Println("read from udp", err) + continue + } + + resp, err := responder.Respond(to.String(), buf) + if err != nil { + l.Println("error responding to query", err) + continue + } + + if err = conn.SetWriteDeadline(time.Now().Add(1 * time.Second)); err != nil { + l.Println("error setting write deadline") + continue + } + + if _, err = conn.WriteTo(resp, to); err != nil { + l.Println("error writing response") + } + } + +} + +func bail(l *log.Logger, msg string) { + l.Println(msg) + flag.PrintDefaults() + os.Exit(1) +} diff --git a/go.sum b/go.sum index 50d2660..0b54d61 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,3 @@ -github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -10,14 +9,12 @@ github.com/netdata/go-orchestrator v0.0.0-20190905093727-c793edba0e8f h1:jSzujNr github.com/netdata/go-orchestrator v0.0.0-20190905093727-c793edba0e8f/go.mod h1:ECF8anFVCt/TfTIWVPgPrNaYJXtAtpAOF62ugDbw41A= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223 h1:DH4skfRX4EBpamg7iV4ZlCpblAHI6s6TDM39bFZumv8= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/lib/svrsample/README.md b/lib/svrsample/README.md new file mode 100644 index 0000000..f23438d --- /dev/null +++ b/lib/svrsample/README.md @@ -0,0 +1,8 @@ +# SvrSample package + +This server sample package provides sample libraries for providing basic responses to server query requests. +It is not designed to implement the full specs of protocols, however it will provide enough to respond to basic +server information requests. + +The sample implementation here will be enough to satisfy the requirements for Multiplay's scaling system to query +the server for health, player counts and other useful information. diff --git a/lib/svrsample/common/wire_encoder.go b/lib/svrsample/common/wire_encoder.go new file mode 100644 index 0000000..abbd6c1 --- /dev/null +++ b/lib/svrsample/common/wire_encoder.go @@ -0,0 +1,67 @@ +package common + +import ( + "bytes" + "encoding/binary" + "reflect" +) + +// WireEncoder is an interface which allows for different query implementations +// to write data to a byte buffer in a specific format. +type WireEncoder interface { + WriteString(resp *bytes.Buffer, s string) error + Write(resp *bytes.Buffer, v interface{}) error +} + +// Encoder is a struct which implements proto.WireEncoder +type Encoder struct{} + +// WriteString writes a string to the provided buffer. +func (e *Encoder) WriteString(resp *bytes.Buffer, s string) error { + if err := binary.Write(resp, binary.BigEndian, byte(len(s))); err != nil { + return err + } + + return binary.Write(resp, binary.BigEndian, []byte(s)) +} + +// Write writes arbitrary data to the provided buffer. +func (e *Encoder) Write(resp *bytes.Buffer, v interface{}) error { + return binary.Write(resp, binary.BigEndian, v) +} + +// WireWrite writes the provided data to resp with the provided WireEncoder w. +func WireWrite(resp *bytes.Buffer, w WireEncoder, data interface{}) error { + t := reflect.TypeOf(data) + vs := reflect.Indirect(reflect.ValueOf(data)) + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + v := vs.FieldByName(f.Name) + + // Dereference pointer + if f.Type.Kind() == reflect.Ptr { + if v.IsNil() { + continue + } + v = v.Elem() + } + + switch v.Kind() { + case reflect.Struct: + if err := WireWrite(resp, w, v.Interface()); err != nil { + return err + } + + case reflect.String: + if err := w.WriteString(resp, v.String()); err != nil { + return err + } + + default: + if err := w.Write(resp, v.Interface()); err != nil { + return err + } + } + } + return nil +} diff --git a/lib/svrsample/protocol/interfaces.go b/lib/svrsample/protocol/interfaces.go new file mode 100644 index 0000000..f67b0cd --- /dev/null +++ b/lib/svrsample/protocol/interfaces.go @@ -0,0 +1,7 @@ +package protocol + +// QueryResponder represents an interface to a concrete type which responds +// to query requests. +type QueryResponder interface { + Respond(clientAddress string, buf []byte) ([]byte, error) +} diff --git a/lib/svrsample/protocol/sqp/register.go b/lib/svrsample/protocol/sqp/register.go new file mode 100644 index 0000000..44febc9 --- /dev/null +++ b/lib/svrsample/protocol/sqp/register.go @@ -0,0 +1,7 @@ +package sqp + +import "github.com/multiplay/go-svrquery/lib/svrsample/protocol" + +func init() { + protocol.MustRegister("sqp", NewQueryResponder) +} diff --git a/lib/svrsample/protocol/sqp/server_info.go b/lib/svrsample/protocol/sqp/server_info.go new file mode 100644 index 0000000..4e9b683 --- /dev/null +++ b/lib/svrsample/protocol/sqp/server_info.go @@ -0,0 +1,38 @@ +package sqp + +import "github.com/multiplay/go-svrquery/lib/svrsample" + +type serverInfo struct { + CurrentPlayers uint16 + MaxPlayers uint16 + ServerName string + GameType string + BuildID string + GameMap string + Port uint16 +} + +// QueryStateToServerInfo converts proto.QueryState to serverInfo. +func QueryStateToServerInfo(qs svrsample.QueryState) serverInfo { + return serverInfo{ + CurrentPlayers: uint16(qs.CurrentPlayers), + MaxPlayers: uint16(qs.MaxPlayers), + ServerName: qs.ServerName, + GameType: qs.GameType, + GameMap: qs.Map, + Port: qs.Port, + } +} + +// Size returns the number of bytes sqpServerInfo will use on the wire. +func (si serverInfo) Size() uint32 { + return uint32( + 2 + // CurrentPlayers + 2 + // MaxPlayers + len([]byte(si.ServerName)) + 1 + + len([]byte(si.GameType)) + 1 + + len([]byte(si.BuildID)) + 1 + + len([]byte(si.GameMap)) + 1 + + 2, // Port + ) +} diff --git a/lib/svrsample/protocol/sqp/sqp.go b/lib/svrsample/protocol/sqp/sqp.go new file mode 100644 index 0000000..4330601 --- /dev/null +++ b/lib/svrsample/protocol/sqp/sqp.go @@ -0,0 +1,136 @@ +package sqp + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "math/rand" + "sync" + + "github.com/multiplay/go-svrquery/lib/svrsample/common" + + "github.com/multiplay/go-svrquery/lib/svrsample" +) + +// QueryResponder responds to queries +type QueryResponder struct { + challenges sync.Map + enc *common.Encoder + state svrsample.QueryState +} + +// challengeWireFormat describes the format of an SQP challenge response +type challengeWireFormat struct { + Header byte + Challenge uint32 +} + +// queryWireFormat describes the format of an SQP query response +type queryWireFormat struct { + Header byte + Challenge uint32 + SQPVersion uint16 + CurrentPacketNum byte + LastPacketNum byte + PayloadLength uint16 + ServerInfoLength uint32 + ServerInfo serverInfo +} + +// NewQueryResponder returns creates a new responder capable of responding +// to SQP-formatted queries. +func NewQueryResponder(state svrsample.QueryState) (svrsample.QueryResponder, error) { + q := &QueryResponder{ + enc: &common.Encoder{}, + state: state, + } + return q, nil +} + +// Respond writes a query response to the requester in the SQP wire protocol. +func (q *QueryResponder) Respond(clientAddress string, buf []byte) ([]byte, error) { + switch { + case isChallenge(buf): + return q.handleChallenge(clientAddress) + + case isQuery(buf): + return q.handleQuery(clientAddress, buf) + } + + return nil, errors.New("unsupported query") +} + +// isChallenge determines if the input buffer corresponds to a challenge packet. +func isChallenge(buf []byte) bool { + return bytes.Equal(buf[0:5], []byte{0, 0, 0, 0, 0}) +} + +// isQuery determines if the input buffer corresponds to a query packet. +func isQuery(buf []byte) bool { + return buf[0] == 1 +} + +// handleChallenge handles an incoming challenge packet. +func (q *QueryResponder) handleChallenge(clientAddress string) ([]byte, error) { + v := rand.Uint32() + q.challenges.Store(clientAddress, v) + + resp := bytes.NewBuffer(nil) + err := common.WireWrite( + resp, + q.enc, + challengeWireFormat{ + Header: 0, + Challenge: v, + }, + ) + if err != nil { + return nil, err + } + + return resp.Bytes(), nil +} + +// handleQuery handles an incoming query packet. +func (q *QueryResponder) handleQuery(clientAddress string, buf []byte) ([]byte, error) { + expectedChallenge, ok := q.challenges.LoadAndDelete(clientAddress) + if !ok { + return nil, errors.New("no challenge") + } + + if len(buf) < 8 { + return nil, errors.New("packet not long enough") + } + + // Challenge doesn't match, return with no response + if binary.BigEndian.Uint32(buf[1:5]) != expectedChallenge.(uint32) { + return nil, errors.New("challenge mismatch") + } + + if binary.BigEndian.Uint16(buf[5:7]) != 1 { + return nil, fmt.Errorf("unsupported sqp version: %d", buf[6]) + } + + requestedChunks := buf[7] + wantsServerInfo := requestedChunks&0x1 == 1 + f := queryWireFormat{ + Header: 1, + Challenge: expectedChallenge.(uint32), + SQPVersion: 1, + } + + resp := bytes.NewBuffer(nil) + + if wantsServerInfo { + f.ServerInfo = QueryStateToServerInfo(q.state) + f.ServerInfoLength = f.ServerInfo.Size() + f.PayloadLength += uint16(f.ServerInfoLength) + 4 + } + + if err := common.WireWrite(resp, q.enc, f); err != nil { + return nil, err + } + + return resp.Bytes(), nil +} diff --git a/lib/svrsample/protocol/sqp/sqp_test.go b/lib/svrsample/protocol/sqp/sqp_test.go new file mode 100644 index 0000000..3129e23 --- /dev/null +++ b/lib/svrsample/protocol/sqp/sqp_test.go @@ -0,0 +1,55 @@ +package sqp + +import ( + "bytes" + "testing" + + "github.com/multiplay/go-svrquery/lib/svrsample" + "github.com/stretchr/testify/require" +) + +func Test_Respond(t *testing.T) { + q, err := NewQueryResponder(&svrsample.QueryState{ + CurrentPlayers: 1, + MaxPlayers: 2, + }) + require.NoError(t, err) + require.NotNil(t, q) + + addr := "client-addr:65534" + + // Challenge packet + resp, err := q.Respond(addr, []byte{0, 0, 0, 0, 0}) + require.NoError(t, err) + require.Equal(t, byte(0), resp[0]) + + // Query packet + resp, err = q.Respond( + addr, + bytes.Join( + [][]byte{ + {1}, + resp[1:5], // challenge + {0, 1}, // SQP version + {1}, // Request chunks (server info only) + }, + nil, + ), + ) + require.NoError(t, err) + require.Equal( + t, + bytes.Join( + [][]byte{ + {1}, + resp[1:5], + resp[5:7], + {0}, + {0}, + {0x0, 0xe, 0x0, 0x0, 0x0, 0xa, 0x0, 0x1, 0x0, 0x2, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}, + }, + nil, + ), + resp, + ) +} diff --git a/lib/svrsample/query.go b/lib/svrsample/query.go new file mode 100644 index 0000000..f97f471 --- /dev/null +++ b/lib/svrsample/query.go @@ -0,0 +1,38 @@ +package svrsample + +import ( + "errors" + "fmt" + + "github.com/multiplay/go-svrquery/lib/svrsample/protocol/sqp" +) + +var ( + // ErrProtoNotFound returned when a protocol is not found + ErrProtoNotFound = errors.New("protocol not found") +) + +// QueryResponder represents an interface to a concrete type which responds +// to query requests. +type QueryResponder interface { + Respond(clientAddress string, buf []byte) ([]byte, error) +} + +// QueryState represents the state of a currently running game. +type QueryState struct { + CurrentPlayers int32 + MaxPlayers int32 + ServerName string + GameType string + Map string + Port uint16 +} + +// GetResponder gets the appropriate responder for the protocol provided +func GetResponder(proto string, state QueryState) (QueryResponder, error) { + switch proto { + case "sqp": + return sqp.NewQueryResponder(state) + } + return nil, fmt.Errorf("%w: %s", ErrProtoNotFound, proto) +} From 577663594973ccf87391872979913a200dea4585 Mon Sep 17 00:00:00 2001 From: lwaddicor Date: Fri, 25 Jun 2021 17:20:57 +0100 Subject: [PATCH 2/4] feat: SQP performance reporting --- lib/svrquery/protocol/sqp/consts.go | 3 - lib/svrquery/protocol/sqp/enums.go | 1 + lib/svrquery/protocol/sqp/query.go | 106 +++++++++++++++++- lib/svrquery/protocol/sqp/register.go | 1 + lib/svrquery/protocol/sqp/types.go | 42 ++++++- lib/svrquery/protocol/titanfall/types_test.go | 4 +- 6 files changed, 141 insertions(+), 16 deletions(-) diff --git a/lib/svrquery/protocol/sqp/consts.go b/lib/svrquery/protocol/sqp/consts.go index 92d2fbd..d530fd1 100644 --- a/lib/svrquery/protocol/sqp/consts.go +++ b/lib/svrquery/protocol/sqp/consts.go @@ -4,7 +4,4 @@ const ( // TODO(steve): remove this? // DefaultMaxPacketSize is the default maximum size of a packet (MTU 1500 - UDP+IP header size) DefaultMaxPacketSize = 1472 - - // Version is the query protocol version this client uses. - Version = uint16(1) ) diff --git a/lib/svrquery/protocol/sqp/enums.go b/lib/svrquery/protocol/sqp/enums.go index 9212ba4..08df35a 100644 --- a/lib/svrquery/protocol/sqp/enums.go +++ b/lib/svrquery/protocol/sqp/enums.go @@ -40,4 +40,5 @@ const ( ServerRules PlayerInfo TeamInfo + PerformanceInfo ) diff --git a/lib/svrquery/protocol/sqp/query.go b/lib/svrquery/protocol/sqp/query.go index 0b457c9..3a5d595 100644 --- a/lib/svrquery/protocol/sqp/query.go +++ b/lib/svrquery/protocol/sqp/query.go @@ -4,8 +4,10 @@ import ( "bufio" "bytes" "encoding/binary" + "fmt" "io" "io/ioutil" + "math" "github.com/multiplay/go-svrquery/lib/svrquery/protocol" ) @@ -16,18 +18,24 @@ type queryer struct { reader *packetReader challengeID uint32 requestedChunks byte + version uint16 } func newCreator(c protocol.Client) protocol.Queryer { - return newQueryer(ServerInfo, DefaultMaxPacketSize, c) + return newQueryer(ServerInfo, DefaultMaxPacketSize, 1, c) } -func newQueryer(requestedChunks byte, maxPktSize int, c protocol.Client) *queryer { +func newCreatorV2(c protocol.Client) protocol.Queryer { + return newQueryer(ServerInfo|PerformanceInfo, DefaultMaxPacketSize, 2, c) +} + +func newQueryer(requestedChunks byte, maxPktSize int, version uint16, c protocol.Client) *queryer { return &queryer{ c: c, maxPktSize: maxPktSize, requestedChunks: requestedChunks, reader: newPacketReader(bufio.NewReaderSize(c, maxPktSize)), + version: version, } } @@ -37,6 +45,8 @@ func (q *queryer) Query() (protocol.Responser, error) { return nil, err } + fmt.Println("query 1") + return q.readQuery(q.requestedChunks) } @@ -55,25 +65,31 @@ func (q *queryer) sendQuery(requestedChunks byte) error { return err } - if err := binary.Write(pkt, binary.BigEndian, Version); err != nil { + if err := binary.Write(pkt, binary.BigEndian, q.version); err != nil { return err } - if err := pkt.WriteByte(requestedChunks); err != nil { + fmt.Printf("Requested Chunks: %b %d\n", requestedChunks, requestedChunks) + //if err := pkt.WriteByte(requestedChunks); err != nil { + // return err + //} + + if err := pkt.WriteByte(ServerInfo); err != nil { return err } - _, err := q.c.Write(pkt.Bytes()) return err } func (q *queryer) readQueryHeader() (uint16, byte, byte, uint16, error) { + fmt.Println("readQuery header 1") pktType, err := q.reader.ReadByte() if err != nil { return 0, 0, 0, 0, err } else if pktType != QueryResponseType { return 0, 0, 0, 0, NewErrMalformedPacketf("was expecting 0x%02x for response type, got 0x%02x", QueryResponseType, pktType) } + fmt.Println("readQuery header 2") if err = q.validateChallenge(); err != nil { return 0, 0, 0, 0, err @@ -113,6 +129,8 @@ func (q *queryer) readQuery(requestedChunks byte) (*QueryResponse, error) { return nil, err } + fmt.Println("readQuery 1") + if lastPkt == 0 && curPkt == 0 { // If the header says the body is empty, we should just return now if pktLen == 0 { @@ -128,6 +146,9 @@ func (q *queryer) readQuery(requestedChunks byte) (*QueryResponse, error) { func (q *queryer) readQuerySinglePacket(r *packetReader, version uint16, requestedChunks byte, pktLen uint32) (*QueryResponse, error) { qr := &QueryResponse{Version: version, Address: q.c.Address()} + fmt.Println("SP 1") + fmt.Println("SP 1a: ", requestedChunks&ServerInfo, requestedChunks&PerformanceInfo) + l := pktLen if requestedChunks&ServerInfo > 0 { if err := q.readQueryServerInfo(qr, r); err != nil { @@ -157,6 +178,13 @@ func (q *queryer) readQuerySinglePacket(r *packetReader, version uint16, request l -= qr.TeamInfo.ChunkLength + uint32(Uint32.Size()) } + if requestedChunks&PerformanceInfo > 0 && version == 2 { + if err := q.readQueryPerformanceInfo(qr, r); err != nil { + return nil, err + } + l -= qr.PerformanceInfo.ChunkLength + uint32(Uint32.Size()) + } + if l > 0 { // If we have extra bytes remaining, we assume they are new fields from a future // query version and discard them. @@ -171,6 +199,8 @@ func (q *queryer) readQuerySinglePacket(r *packetReader, version uint16, request func (q *queryer) readQueryServerInfo(qr *QueryResponse, r *packetReader) (err error) { qr.ServerInfo = &ServerInfoChunk{} + fmt.Println("SI 1") + if qr.ServerInfo.ChunkLength, err = r.ReadUint32(); err != nil { return err } @@ -420,6 +450,72 @@ func (q *queryer) readQueryTeamInfo(qr *QueryResponse, r *packetReader) (err err return nil } +// FlagsNum byte +// Flags uint32 +// GaugesNum byte +// Gauges []float32 + +func (q *queryer) readQueryPerformanceInfo(qr *QueryResponse, r *packetReader) (err error) { + qr.PerformanceInfo = &PerformanceInfoChunk{ + Gauges: []float32{}, + } + + fmt.Println("p1") + + if qr.PerformanceInfo.ChunkLength, err = r.ReadUint32(); err != nil { + return err + } + l := int64(qr.PerformanceInfo.ChunkLength) + + fmt.Println("p2") + if qr.PerformanceInfo.NumFlags, err = r.ReadByte(); err != nil { + return err + } + l -= int64(Byte.Size()) + + //fmt.Printf("num: %d, mask: %b\n", flagsNum, mask) + + if qr.PerformanceInfo.Flags, err = r.ReadUint32(); err != nil { + return err + } + l -= int64(Uint32.Size()) + + fmt.Println("p2") + var gaugesNum byte + if gaugesNum, err = r.ReadByte(); err != nil { + return err + } + l -= int64(Byte.Size()) + + fmt.Println("p3 ", gaugesNum) + + for i := byte(0); i < gaugesNum; i++ { + fmt.Println("p4") + u, err := r.ReadUint32() + if err != nil { + return err + } + l -= int64(Uint32.Size()) + qr.PerformanceInfo.Gauges = append(qr.PerformanceInfo.Gauges, math.Float32frombits(u)) + } + + fmt.Println("p5") + + switch { + case l < 0: + // If we have read more bytes than expected, the packet is malformed + return NewErrMalformedPacketf("expected chunk length of %v, but have %v bytes remaining", qr.PerformanceInfo.ChunkLength, l) + case l > 0: + // If we have extra bytes remaining, we assume they are new fields from a future + // query version and discard them + if _, err := io.CopyN(ioutil.Discard, r, l); err != nil { + return err + } + } + + return nil +} + func (q *queryer) readQueryMultiPacket(version uint16, curPkt, lastPkt, requestedChunks byte, pktLen uint16) (*QueryResponse, error) { // Setup our array of packet bodies expectedPkts := lastPkt + 1 diff --git a/lib/svrquery/protocol/sqp/register.go b/lib/svrquery/protocol/sqp/register.go index 3aadcf5..8dc0ec7 100644 --- a/lib/svrquery/protocol/sqp/register.go +++ b/lib/svrquery/protocol/sqp/register.go @@ -6,4 +6,5 @@ import ( func init() { protocol.MustRegister("sqp", newCreator) + protocol.MustRegister("sqp-v2", newCreatorV2) } diff --git a/lib/svrquery/protocol/sqp/types.go b/lib/svrquery/protocol/sqp/types.go index 9fc345f..60bc514 100644 --- a/lib/svrquery/protocol/sqp/types.go +++ b/lib/svrquery/protocol/sqp/types.go @@ -2,6 +2,7 @@ package sqp import ( "encoding/json" + "strconv" ) // ServerInfoChunk is the response chunk for server info data @@ -49,14 +50,43 @@ func (tic *TeamInfoChunk) MarshalJSON() ([]byte, error) { return json.Marshal(tic.Teams) } +type PerformanceInfoChunk struct { + ChunkLength uint32 `json:"-"` + NumFlags byte `json:"-"` + Flags uint32 `json:"-"` + Gauges []float32 `json:"-"` +} + +// MarshalJSON implements json.Marshaler +func (pi PerformanceInfoChunk) MarshalJSON() ([]byte, error) { + obj := make(map[string]interface{}, pi.NumFlags) + for i := 0; i < int(pi.NumFlags); i++ { + obj["flag_"+strconv.Itoa(i)] = (pi.Flags>>i)&1 == 1 + } + + for i, f := range pi.Gauges { + obj["gauge_"+strconv.Itoa(i)] = f + } + + // obj.PacketLossIn = a.PacketLossIn() + //obj.PacketLossOut = a.PacketLossOut() + //obj.PacketChokedIn = a.PacketChokedIn() + //obj.PacketChokedOut = a.PacketChokedOut() + //obj.SlowServerFrames = a.SlowServerFrames() + //obj.Hitching = a.Hitching() + + return json.Marshal(obj) +} + // QueryResponse is the combined response to a query request type QueryResponse struct { - Version uint16 `json:"version"` - Address string `json:"address"` - ServerInfo *ServerInfoChunk `json:"server_info,omitempty"` - ServerRules *ServerRulesChunk `json:"server_rules,omitempty"` - PlayerInfo *PlayerInfoChunk `json:"player_info,omitempty"` - TeamInfo *TeamInfoChunk `json:"team_info,omitempty"` + Version uint16 `json:"version"` + Address string `json:"address"` + ServerInfo *ServerInfoChunk `json:"server_info,omitempty"` + ServerRules *ServerRulesChunk `json:"server_rules,omitempty"` + PlayerInfo *PlayerInfoChunk `json:"player_info,omitempty"` + TeamInfo *TeamInfoChunk `json:"team_info,omitempty"` + PerformanceInfo *PerformanceInfoChunk `json:"performance_info,omitempty"` } func (q *QueryResponse) MaxClients() int64 { diff --git a/lib/svrquery/protocol/titanfall/types_test.go b/lib/svrquery/protocol/titanfall/types_test.go index ba0927a..dbbc3c0 100644 --- a/lib/svrquery/protocol/titanfall/types_test.go +++ b/lib/svrquery/protocol/titanfall/types_test.go @@ -17,7 +17,7 @@ func TestHealthFlags(t *testing.T) { expPacketChokedOut bool expSlowServerFrames bool expHitching bool - expDOS bool + expDOS bool }{ { input: 0, @@ -48,7 +48,7 @@ func TestHealthFlags(t *testing.T) { expHitching: true, }, { - input: 1 << 6, + input: 1 << 6, expDOS: true, }, } From 19619c2f36c88c1d8398f75cdf87173ac828876c Mon Sep 17 00:00:00 2001 From: lwaddicor Date: Mon, 28 Jun 2021 14:37:55 +0100 Subject: [PATCH 3/4] Embed sample sqp client --- cmd/cli/main.go | 17 ++++++++-------- lib/svrquery/protocol/titanfall/types_test.go | 4 ++-- lib/svrsample/common/interfaces.go | 17 ++++++++++++++++ lib/svrsample/protocol/interfaces.go | 7 ------- lib/svrsample/protocol/sqp/register.go | 7 ------- lib/svrsample/protocol/sqp/server_info.go | 15 ++++++++------ lib/svrsample/protocol/sqp/sqp.go | 8 +++----- lib/svrsample/protocol/sqp/sqp_test.go | 4 ++-- lib/svrsample/query.go | 20 +++---------------- 9 files changed, 45 insertions(+), 54 deletions(-) create mode 100644 lib/svrsample/common/interfaces.go delete mode 100644 lib/svrsample/protocol/interfaces.go delete mode 100644 lib/svrsample/protocol/sqp/register.go diff --git a/cmd/cli/main.go b/cmd/cli/main.go index cd9c1bd..4800db6 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -11,10 +11,11 @@ import ( "github.com/multiplay/go-svrquery/lib/svrquery" "github.com/multiplay/go-svrquery/lib/svrsample" + "github.com/multiplay/go-svrquery/lib/svrsample/common" ) func main() { - clientAddr := flag.String("addr", "", "Address e.g. 127.0.0.1:12345") + clientAddr := flag.String("addr", "", "Address to connect to e.g. 127.0.0.1:12345") proto := flag.String("proto", "", "Protocol e.g. sqp, tf2e, tf2e-v7, tf2e-v8") serverAddr := flag.String("server", "", "Address to start server e.g. 127.0.0.1:12121, :23232") flag.Parse() @@ -25,19 +26,19 @@ func main() { bail(l, "Cannot run both a server and a client. Specify either -addr OR -server flags") } - if *serverAddr != "" { + switch { + case *serverAddr != "": if *proto == "" { - bail(l, "No protocol provided") + bail(l, "No protocol provided in client mode") } serverMode(l, *proto, *serverAddr) - } else { + case *clientAddr != "": if *proto == "" { bail(l, "Protocol required in server mode") } - if *clientAddr == "" { - bail(l, "No address provided") - } queryMode(l, *proto, *clientAddr) + default: + bail(l, "Please supply some options") } } @@ -75,7 +76,7 @@ func serverMode(l *log.Logger, proto, serverAddr string) { func server(l *log.Logger, proto, address string) error { l.Printf("Starting sample server using protocol %s on %s", proto, address) - responder, err := svrsample.GetResponder(proto, svrsample.QueryState{ + responder, err := svrsample.GetResponder(proto, common.QueryState{ CurrentPlayers: 1, MaxPlayers: 2, ServerName: "Name", diff --git a/lib/svrquery/protocol/titanfall/types_test.go b/lib/svrquery/protocol/titanfall/types_test.go index ba0927a..dbbc3c0 100644 --- a/lib/svrquery/protocol/titanfall/types_test.go +++ b/lib/svrquery/protocol/titanfall/types_test.go @@ -17,7 +17,7 @@ func TestHealthFlags(t *testing.T) { expPacketChokedOut bool expSlowServerFrames bool expHitching bool - expDOS bool + expDOS bool }{ { input: 0, @@ -48,7 +48,7 @@ func TestHealthFlags(t *testing.T) { expHitching: true, }, { - input: 1 << 6, + input: 1 << 6, expDOS: true, }, } diff --git a/lib/svrsample/common/interfaces.go b/lib/svrsample/common/interfaces.go new file mode 100644 index 0000000..261dea2 --- /dev/null +++ b/lib/svrsample/common/interfaces.go @@ -0,0 +1,17 @@ +package common + +// QueryResponder represents an interface to a concrete type which responds +// to query requests. +type QueryResponder interface { + Respond(clientAddress string, buf []byte) ([]byte, error) +} + +// QueryState represents the state of a currently running game. +type QueryState struct { + CurrentPlayers int32 + MaxPlayers int32 + ServerName string + GameType string + Map string + Port uint16 +} diff --git a/lib/svrsample/protocol/interfaces.go b/lib/svrsample/protocol/interfaces.go deleted file mode 100644 index f67b0cd..0000000 --- a/lib/svrsample/protocol/interfaces.go +++ /dev/null @@ -1,7 +0,0 @@ -package protocol - -// QueryResponder represents an interface to a concrete type which responds -// to query requests. -type QueryResponder interface { - Respond(clientAddress string, buf []byte) ([]byte, error) -} diff --git a/lib/svrsample/protocol/sqp/register.go b/lib/svrsample/protocol/sqp/register.go deleted file mode 100644 index 44febc9..0000000 --- a/lib/svrsample/protocol/sqp/register.go +++ /dev/null @@ -1,7 +0,0 @@ -package sqp - -import "github.com/multiplay/go-svrquery/lib/svrsample/protocol" - -func init() { - protocol.MustRegister("sqp", NewQueryResponder) -} diff --git a/lib/svrsample/protocol/sqp/server_info.go b/lib/svrsample/protocol/sqp/server_info.go index 4e9b683..6c1c512 100644 --- a/lib/svrsample/protocol/sqp/server_info.go +++ b/lib/svrsample/protocol/sqp/server_info.go @@ -1,8 +1,11 @@ package sqp -import "github.com/multiplay/go-svrquery/lib/svrsample" +import ( + "github.com/multiplay/go-svrquery/lib/svrsample/common" +) -type serverInfo struct { +// ServerInfo holds the ServerInfo chunk data. +type ServerInfo struct { CurrentPlayers uint16 MaxPlayers uint16 ServerName string @@ -12,9 +15,9 @@ type serverInfo struct { Port uint16 } -// QueryStateToServerInfo converts proto.QueryState to serverInfo. -func QueryStateToServerInfo(qs svrsample.QueryState) serverInfo { - return serverInfo{ +// QueryStateToServerInfo converts proto.QueryState to ServerInfo. +func QueryStateToServerInfo(qs common.QueryState) ServerInfo { + return ServerInfo{ CurrentPlayers: uint16(qs.CurrentPlayers), MaxPlayers: uint16(qs.MaxPlayers), ServerName: qs.ServerName, @@ -25,7 +28,7 @@ func QueryStateToServerInfo(qs svrsample.QueryState) serverInfo { } // Size returns the number of bytes sqpServerInfo will use on the wire. -func (si serverInfo) Size() uint32 { +func (si ServerInfo) Size() uint32 { return uint32( 2 + // CurrentPlayers 2 + // MaxPlayers diff --git a/lib/svrsample/protocol/sqp/sqp.go b/lib/svrsample/protocol/sqp/sqp.go index 4330601..15f0932 100644 --- a/lib/svrsample/protocol/sqp/sqp.go +++ b/lib/svrsample/protocol/sqp/sqp.go @@ -9,15 +9,13 @@ import ( "sync" "github.com/multiplay/go-svrquery/lib/svrsample/common" - - "github.com/multiplay/go-svrquery/lib/svrsample" ) // QueryResponder responds to queries type QueryResponder struct { challenges sync.Map enc *common.Encoder - state svrsample.QueryState + state common.QueryState } // challengeWireFormat describes the format of an SQP challenge response @@ -35,12 +33,12 @@ type queryWireFormat struct { LastPacketNum byte PayloadLength uint16 ServerInfoLength uint32 - ServerInfo serverInfo + ServerInfo ServerInfo } // NewQueryResponder returns creates a new responder capable of responding // to SQP-formatted queries. -func NewQueryResponder(state svrsample.QueryState) (svrsample.QueryResponder, error) { +func NewQueryResponder(state common.QueryState) (*QueryResponder, error) { q := &QueryResponder{ enc: &common.Encoder{}, state: state, diff --git a/lib/svrsample/protocol/sqp/sqp_test.go b/lib/svrsample/protocol/sqp/sqp_test.go index 3129e23..ae40ca1 100644 --- a/lib/svrsample/protocol/sqp/sqp_test.go +++ b/lib/svrsample/protocol/sqp/sqp_test.go @@ -4,12 +4,12 @@ import ( "bytes" "testing" - "github.com/multiplay/go-svrquery/lib/svrsample" + "github.com/multiplay/go-svrquery/lib/svrsample/common" "github.com/stretchr/testify/require" ) func Test_Respond(t *testing.T) { - q, err := NewQueryResponder(&svrsample.QueryState{ + q, err := NewQueryResponder(common.QueryState{ CurrentPlayers: 1, MaxPlayers: 2, }) diff --git a/lib/svrsample/query.go b/lib/svrsample/query.go index f97f471..8cb019e 100644 --- a/lib/svrsample/query.go +++ b/lib/svrsample/query.go @@ -4,6 +4,8 @@ import ( "errors" "fmt" + "github.com/multiplay/go-svrquery/lib/svrsample/common" + "github.com/multiplay/go-svrquery/lib/svrsample/protocol/sqp" ) @@ -12,24 +14,8 @@ var ( ErrProtoNotFound = errors.New("protocol not found") ) -// QueryResponder represents an interface to a concrete type which responds -// to query requests. -type QueryResponder interface { - Respond(clientAddress string, buf []byte) ([]byte, error) -} - -// QueryState represents the state of a currently running game. -type QueryState struct { - CurrentPlayers int32 - MaxPlayers int32 - ServerName string - GameType string - Map string - Port uint16 -} - // GetResponder gets the appropriate responder for the protocol provided -func GetResponder(proto string, state QueryState) (QueryResponder, error) { +func GetResponder(proto string, state common.QueryState) (common.QueryResponder, error) { switch proto { case "sqp": return sqp.NewQueryResponder(state) From 86c42d282fc752820a526e67224f89335f710308 Mon Sep 17 00:00:00 2001 From: lwaddicor Date: Mon, 28 Jun 2021 14:45:38 +0100 Subject: [PATCH 4/4] update readme --- README.md | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8a6d198..36ece24 100644 --- a/README.md +++ b/README.md @@ -45,10 +45,40 @@ func main() { CLI ------------- -A cli is available in https://github.com/multiplay/go-svrquery/tree/master/cmd/cli +A cli is available in github releases and also at https://github.com/multiplay/go-svrquery/tree/master/cmd/cli This enables you make queries to servers using the specified protocol, and returns the response in pretty json. +### Client + +``` +./go-svrquery -addr localhost:12121 -proto sqp +{ + "version": 1, + "address": "localhost:12121", + "server_info": { + "current_players": 1, + "max_players": 2, + "server_name": "Name", + "game_type": "Game Type", + "build_id": "", + "map": "Map", + "port": 1000 + } +} +``` + +### Example Server + +This tool also provides the ability to start a very basic sample server using a given protocol. + +Currently, only `sqp` is supported + +``` +./go-svrquery -server :12121 -proto sqp +Starting sample server using protocol sqp on :12121 +``` + Documentation ------------- - [GoDoc API Reference](http://godoc.org/github.com/multiplay/go-svrquery).