From 2acfe1fa37077d64d039d20a0d02dbf12e3ec3ea Mon Sep 17 00:00:00 2001 From: lwaddicor Date: Fri, 25 Jun 2021 17:19:59 +0100 Subject: [PATCH 1/5] 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 19619c2f36c88c1d8398f75cdf87173ac828876c Mon Sep 17 00:00:00 2001 From: lwaddicor Date: Mon, 28 Jun 2021 14:37:55 +0100 Subject: [PATCH 2/5] 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 3/5] 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). From 9c4b84ff72be9f6b6fdac0cd3a84203456cda5ce Mon Sep 17 00:00:00 2001 From: altrey Date: Tue, 29 Jun 2021 14:24:22 +0100 Subject: [PATCH 4/5] implement tf2e proto --- .gitignore | 3 + cmd/cli/main.go | 2 +- lib/svrsample/protocol/tf2/encoder.go | 21 +++ lib/svrsample/protocol/tf2/tf2.go | 171 +++++++++++++++++++++++++ lib/svrsample/protocol/tf2/tf2_test.go | 104 +++++++++++++++ lib/svrsample/query.go | 20 ++- 6 files changed, 319 insertions(+), 2 deletions(-) create mode 100644 lib/svrsample/protocol/tf2/encoder.go create mode 100644 lib/svrsample/protocol/tf2/tf2.go create mode 100644 lib/svrsample/protocol/tf2/tf2_test.go diff --git a/.gitignore b/.gitignore index f1c181e..f4aba4c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ # Output of the go coverage tool, specifically when used with LiteIDE *.out + +# OSX Junk +.DS_Store \ No newline at end of file diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 4800db6..dced13c 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -16,7 +16,7 @@ import ( func main() { 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") + proto := flag.String("proto", "", "Protocol e.g. sqp, tf2, tf2e, tf2e-v7, tf2e-v8") serverAddr := flag.String("server", "", "Address to start server e.g. 127.0.0.1:12121, :23232") flag.Parse() diff --git a/lib/svrsample/protocol/tf2/encoder.go b/lib/svrsample/protocol/tf2/encoder.go new file mode 100644 index 0000000..9efd559 --- /dev/null +++ b/lib/svrsample/protocol/tf2/encoder.go @@ -0,0 +1,21 @@ +package tf2 + +import ( + "bytes" + "encoding/binary" +) + +type ( + // encoder is a struct which implements proto.WireEncoder + encoder struct{} +) + +// WriteString writes a string to the provided buffer. +func (e *encoder) WriteString(resp *bytes.Buffer, s string) error { + return binary.Write(resp, binary.LittleEndian, []byte(s+"\x00")) +} + +// Write writes arbitrary data to the provided buffer. +func (e *encoder) Write(resp *bytes.Buffer, v interface{}) error { + return binary.Write(resp, binary.LittleEndian, v) +} diff --git a/lib/svrsample/protocol/tf2/tf2.go b/lib/svrsample/protocol/tf2/tf2.go new file mode 100644 index 0000000..0538a23 --- /dev/null +++ b/lib/svrsample/protocol/tf2/tf2.go @@ -0,0 +1,171 @@ +package tf2 + +import ( + "bytes" + "runtime" + + "github.com/multiplay/go-svrquery/lib/svrsample/common" +) + +type ( + QueryResponder struct { + enc *encoder + extended bool + state common.QueryState + version int8 + } + + instanceInfo struct { + Retail byte + InstanceType byte + ClientDLLCRC uint32 + NetProtocol uint16 + RandomServerID uint64 + } + + instanceInfoV8 struct { + Retail byte + InstanceType byte + ClientDLLCRC uint32 + NetProtocol uint16 + HealthFlags uint32 + RandomServerID uint32 + } + + performanceInfo struct { + AverageFrameTime float32 + MaxFrameTime float32 + AverageUserCommandTime float32 + MaxUserCommandTime float32 + } + + matchState struct { + Phase byte + MaxRounds byte + MaxRoundsIMC byte + MaxRoundsMilitia byte + TimeLimitSeconds uint16 + TimePassedSeconds uint16 + MaxScore uint16 + Team byte + } + + queryWireFormat struct { + Header int32 + ResponseType byte + Version int8 + InstanceInfo *instanceInfo + InstanceInfoV8 *instanceInfoV8 + BuildName *string + Datacenter *string + GameMode *string + Port uint16 + Platform string + PlaylistVersion string + PlaylistNum uint32 + PlaylistName string + PlatformNum *byte + NumClients uint8 + MaxClients uint8 + Map string + PerformanceInfo *performanceInfo + TeamsLeftWithPlayersNum *uint16 + MatchState *matchState + EOP uint64 + } +) + +const ( + notApplicable = "n/a" +) + +// NewQueryResponder returns creates a new responder capable of responding +// to tf2-formatted queries. +func NewQueryResponder(state common.QueryState, version int8, extended bool) (common.QueryResponder, error) { + q := &QueryResponder{ + enc: &encoder{}, + extended: extended, + state: state, + version: version, + } + return q, nil +} + +// Respond writes a query response to the requester in the tf2 wire protocol. +func (q *QueryResponder) Respond(_ string, _ []byte) ([]byte, error) { + resp := bytes.NewBuffer(nil) + responseFlag := byte(80) + dc := "multiplay-dc" + + if q.extended { + responseFlag = byte(78) + } + + f := queryWireFormat{ + Header: -1, + ResponseType: responseFlag, + Version: q.version, + Port: q.state.Port, + Platform: runtime.GOOS, + PlaylistVersion: notApplicable, + PlaylistName: notApplicable, + NumClients: uint8(q.state.CurrentPlayers), + MaxClients: uint8(q.state.MaxPlayers), + Map: q.state.Map, + } + + if q.version > 1 { + f.BuildName = &q.state.ServerName + f.Datacenter = &dc + f.GameMode = &q.state.GameType + } + + if q.version > 2 { + f.MatchState = &matchState{ + Team: 255, // Team information omitted + } + } + + // Performance Info + if q.version > 4 { + f.PerformanceInfo = &performanceInfo{ + AverageFrameTime: 1.2, + MaxFrameTime: 3.4, + AverageUserCommandTime: 5.6, + MaxUserCommandTime: 7.8, + } + } + + if q.version > 5 { + i := uint16(0) + f.TeamsLeftWithPlayersNum = &i + } + + if q.version > 6 { + i := byte(0) + f.PlatformNum = &i + } + + if q.version > 7 { + f.InstanceInfoV8 = &instanceInfoV8{ + HealthFlags: uint32( + 1<<0 | // Packet Loss In + 1<<1 | // Packet Loss Out + 1<<2 | // Packet Choked In + 1<<3 | // Packet Choked Out + 1<<4 | // Slow Server Frames + 1<<5 | // Hitching + 1<<6, // DOS + ), + RandomServerID: 123456, + } + } else if q.version > 1 { + f.InstanceInfo = &instanceInfo{RandomServerID: 123456} + } + + if err := common.WireWrite(resp, q.enc, f); err != nil { + return nil, err + } + + return resp.Bytes(), nil +} diff --git a/lib/svrsample/protocol/tf2/tf2_test.go b/lib/svrsample/protocol/tf2/tf2_test.go new file mode 100644 index 0000000..79ad335 --- /dev/null +++ b/lib/svrsample/protocol/tf2/tf2_test.go @@ -0,0 +1,104 @@ +package tf2 + +import ( + "bytes" + "runtime" + "testing" + + "github.com/multiplay/go-svrquery/lib/svrquery/protocol" + "github.com/multiplay/go-svrquery/lib/svrquery/protocol/titanfall" + "github.com/multiplay/go-svrquery/lib/svrsample/common" + "github.com/stretchr/testify/require" +) + +type ( + // queryerFromBytes is a queryer which responds with data in the provided + // byte buffer. + queryerFromBytes struct { + *bytes.Buffer + } +) + +func (q queryerFromBytes) Close() error { + return nil +} + +func (q queryerFromBytes) Key() string { + return "" +} + +func (q queryerFromBytes) Address() string { + return "" +} + +func Test_Respond(t *testing.T) { + q, err := NewQueryResponder(common.QueryState{ + CurrentPlayers: 1, + MaxPlayers: 2, + ServerName: "my server", + GameType: "game type", + Map: "my map", + Port: 8080, + }, 1, false) + require.NoError(t, err) + require.NotNil(t, q) + + resp, err := q.Respond("", nil) + require.NoError(t, err) + require.Equal(t, []byte{0xff, 0xff, 0xff, 0xff, 0x50, 0x1, 0x90, 0x1f, 0x64, 0x61, 0x72, 0x77, 0x69, 0x6e, 0x0, 0x6e, 0x2f, 0x61, 0x0, 0x0, 0x0, 0x0, 0x0, 0x6e, 0x2f, 0x61, 0x0, 0x1, 0x2, 0x6d, 0x79, 0x20, 0x6d, 0x61, 0x70, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}, resp) +} + +func Test_Respond_tf2e_v8(t *testing.T) { + q, err := NewQueryResponder(common.QueryState{ + CurrentPlayers: 1, + MaxPlayers: 2, + ServerName: "my server", + GameType: "game type", + Map: "my map", + Port: 8080, + }, 8, true) + require.NoError(t, err) + require.NotNil(t, q) + + data, err := q.Respond("", nil) + require.NoError(t, err) + + p, err := protocol.Get("tf2e-v8") + require.NoError(t, err) + + client := p(&queryerFromBytes{ + bytes.NewBuffer(data), + }) + resp, err := client.Query() + require.NoError(t, err) + require.Equal(t, &titanfall.Info{ + Header: titanfall.Header{ + Prefix: -1, + Command: 78, + Version: 8, + }, + InstanceInfoV8: titanfall.InstanceInfoV8{ + HealthFlags: 127, + RandomServerID: 123456, + }, + BuildName: "my server", + Datacenter: "multiplay-dc", + GameMode: "game type", + BasicInfo: titanfall.BasicInfo{ + Port: 8080, + Platform: runtime.GOOS, + PlaylistName: "n/a", + PlaylistVersion: "n/a", + NumClients: 1, + MaxClients: 2, + Map: "my map", + PlatformPlayers: map[string]uint8{}, + }, + PerformanceInfo: titanfall.PerformanceInfo{ + AverageFrameTime: 1.2, + MaxFrameTime: 3.4, + AverageUserCommandTime: 5.6, + MaxUserCommandTime: 7.8, + }, + }, resp) +} diff --git a/lib/svrsample/query.go b/lib/svrsample/query.go index 8cb019e..30b5c24 100644 --- a/lib/svrsample/query.go +++ b/lib/svrsample/query.go @@ -5,8 +5,8 @@ import ( "fmt" "github.com/multiplay/go-svrquery/lib/svrsample/common" - "github.com/multiplay/go-svrquery/lib/svrsample/protocol/sqp" + "github.com/multiplay/go-svrquery/lib/svrsample/protocol/tf2" ) var ( @@ -19,6 +19,24 @@ func GetResponder(proto string, state common.QueryState) (common.QueryResponder, switch proto { case "sqp": return sqp.NewQueryResponder(state) + case "tf2": + return tf2.NewQueryResponder(state, 1, false) + case "tf2e": + return tf2.NewQueryResponder(state, 1, true) + case "tf2e-v2": + return tf2.NewQueryResponder(state, 2, true) + case "tf2e-v3": + return tf2.NewQueryResponder(state, 3, true) + case "tf2e-v4": + return tf2.NewQueryResponder(state, 4, true) + case "tf2e-v5": + return tf2.NewQueryResponder(state, 5, true) + case "tf2e-v6": + return tf2.NewQueryResponder(state, 6, true) + case "tf2e-v7": + return tf2.NewQueryResponder(state, 7, true) + case "tf2e-v8": + return tf2.NewQueryResponder(state, 8, true) } return nil, fmt.Errorf("%w: %s", ErrProtoNotFound, proto) } From 84b964e5225d2e3e15453f659de85a7276ecaa0e Mon Sep 17 00:00:00 2001 From: altrey Date: Tue, 29 Jun 2021 16:15:30 +0100 Subject: [PATCH 5/5] clean up --- lib/svrsample/protocol/tf2/tf2.go | 2 +- lib/svrsample/query.go | 10 ---------- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/lib/svrsample/protocol/tf2/tf2.go b/lib/svrsample/protocol/tf2/tf2.go index 0538a23..8a07620 100644 --- a/lib/svrsample/protocol/tf2/tf2.go +++ b/lib/svrsample/protocol/tf2/tf2.go @@ -95,7 +95,7 @@ func NewQueryResponder(state common.QueryState, version int8, extended bool) (co func (q *QueryResponder) Respond(_ string, _ []byte) ([]byte, error) { resp := bytes.NewBuffer(nil) responseFlag := byte(80) - dc := "multiplay-dc" + dc := "dc" if q.extended { responseFlag = byte(78) diff --git a/lib/svrsample/query.go b/lib/svrsample/query.go index 30b5c24..04737a7 100644 --- a/lib/svrsample/query.go +++ b/lib/svrsample/query.go @@ -22,17 +22,7 @@ func GetResponder(proto string, state common.QueryState) (common.QueryResponder, case "tf2": return tf2.NewQueryResponder(state, 1, false) case "tf2e": - return tf2.NewQueryResponder(state, 1, true) - case "tf2e-v2": - return tf2.NewQueryResponder(state, 2, true) - case "tf2e-v3": return tf2.NewQueryResponder(state, 3, true) - case "tf2e-v4": - return tf2.NewQueryResponder(state, 4, true) - case "tf2e-v5": - return tf2.NewQueryResponder(state, 5, true) - case "tf2e-v6": - return tf2.NewQueryResponder(state, 6, true) case "tf2e-v7": return tf2.NewQueryResponder(state, 7, true) case "tf2e-v8":