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). diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 9069025..4800db6 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -5,31 +5,45 @@ import ( "flag" "fmt" "log" + "net" "os" + "time" "github.com/multiplay/go-svrquery/lib/svrquery" + "github.com/multiplay/go-svrquery/lib/svrsample" + "github.com/multiplay/go-svrquery/lib/svrsample/common" ) func main() { - address := 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() 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) + switch { + case *serverAddr != "": + if *proto == "" { + bail(l, "No protocol provided in client mode") + } + serverMode(l, *proto, *serverAddr) + case *clientAddr != "": + if *proto == "" { + bail(l, "Protocol required in server mode") + } + queryMode(l, *proto, *clientAddr) + default: + bail(l, "Please supply some options") } +} - 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 +67,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, common.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/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/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/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/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/sqp/server_info.go b/lib/svrsample/protocol/sqp/server_info.go new file mode 100644 index 0000000..6c1c512 --- /dev/null +++ b/lib/svrsample/protocol/sqp/server_info.go @@ -0,0 +1,41 @@ +package sqp + +import ( + "github.com/multiplay/go-svrquery/lib/svrsample/common" +) + +// ServerInfo holds the ServerInfo chunk data. +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 common.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..15f0932 --- /dev/null +++ b/lib/svrsample/protocol/sqp/sqp.go @@ -0,0 +1,134 @@ +package sqp + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "math/rand" + "sync" + + "github.com/multiplay/go-svrquery/lib/svrsample/common" +) + +// QueryResponder responds to queries +type QueryResponder struct { + challenges sync.Map + enc *common.Encoder + state common.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 common.QueryState) (*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..ae40ca1 --- /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/common" + "github.com/stretchr/testify/require" +) + +func Test_Respond(t *testing.T) { + q, err := NewQueryResponder(common.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..8cb019e --- /dev/null +++ b/lib/svrsample/query.go @@ -0,0 +1,24 @@ +package svrsample + +import ( + "errors" + "fmt" + + "github.com/multiplay/go-svrquery/lib/svrsample/common" + + "github.com/multiplay/go-svrquery/lib/svrsample/protocol/sqp" +) + +var ( + // ErrProtoNotFound returned when a protocol is not found + ErrProtoNotFound = errors.New("protocol not found") +) + +// GetResponder gets the appropriate responder for the protocol provided +func GetResponder(proto string, state common.QueryState) (common.QueryResponder, error) { + switch proto { + case "sqp": + return sqp.NewQueryResponder(state) + } + return nil, fmt.Errorf("%w: %s", ErrProtoNotFound, proto) +}