diff --git a/README.md b/README.md index 2e12d0d..3461f78 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ go-svrquery is a [Go](http://golang.org/) client for talking to game servers usi Features -------- * Support for various game server query protocol's including: -** SQP, TF2E +** SQP, TF2E, Prometheus Installation ------------ @@ -22,7 +22,6 @@ package main import ( "log" - "time" "github.com/multiplay/go-svrquery/lib/svrquery" ) @@ -42,6 +41,29 @@ func main() { } ``` +### Prometheus + +To scape metrics exposed in Prometheus/OpenMetrics format, use the protocol `prom`, and the URL of the HTTP metrics +endpoint. For example: + +```go +client, err := svrquery.NewClient("prom", "http://127.0.0.1:9100/metrics") +``` + +The library expects the following metrics to be available: + +```text +# HELP current_players Number of players currently connected to the server. +# TYPE current_players gauge +current_players 1 +# HELP max_players Maximum number of players that can connect to the server. +# TYPE max_players gauge +max_players 2 +# HELP server_info Server status info. +# TYPE server_info gauge +server_info{game_type="Game Type",map_name="Map",port="1000",server_name="Name"} 1 +``` + CLI ------------- A cli is available in github releases and also at https://github.com/multiplay/go-svrquery/tree/master/cmd/cli @@ -71,7 +93,7 @@ This enables you make queries to servers using the specified protocol, and retur This tool also provides the ability to start a very basic sample server using a given protocol. -Currently, only `sqp` is supported +Currently, only `sqp` and `prom` are supported ``` ./go-svrquery -server :12121 -proto sqp diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 15ad762..91baab9 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -1,22 +1,22 @@ package main import ( + "context" "encoding/json" "flag" "fmt" "log" - "net" "os" - "time" "github.com/multiplay/go-svrquery/lib/svrquery" + "github.com/multiplay/go-svrquery/lib/svrquery/protocol" "github.com/multiplay/go-svrquery/lib/svrsample" "github.com/multiplay/go-svrquery/lib/svrsample/common" ) 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, tf2e, tf2e-v7, tf2e-v8, prom") key := flag.String("key", "", "Key to use to authenticate") file := flag.String("file", "", "Bulk file to execute to get basic server information") serverAddr := flag.String("server", "", "Address to start server e.g. 127.0.0.1:12121, :23232") @@ -39,12 +39,12 @@ func main() { switch { case *serverAddr != "": if *proto == "" { - bail(l, "No protocol provided in client mode") + bail(l, "No protocol provided in server mode") } serverMode(l, *proto, *serverAddr) case *clientAddr != "": if *proto == "" { - bail(l, "Protocol required in server mode") + bail(l, "Protocol required in client mode") } queryMode(l, *proto, *clientAddr, *key) default: @@ -53,24 +53,26 @@ func main() { } func queryMode(l *log.Logger, proto, address, key string) { - if err := query(proto, address, key); err != nil { - l.Fatal(err) - } -} - -func query(proto, address, key string) error { + // setup client options := make([]svrquery.Option, 0) if key != "" { options = append(options, svrquery.WithKey(key)) } - c, err := svrquery.NewClient(proto, address, options...) + client, err := svrquery.NewClient(proto, address, options...) if err != nil { - return err + l.Fatal(err) } - defer c.Close() + defer client.Close() - r, err := c.Query() + // run query + if err := query(client); err != nil { + l.Fatal(err) + } +} + +func query(client protocol.Queryer) error { + r, err := client.Query() if err != nil { return err } @@ -91,6 +93,12 @@ 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) + + transport, err := svrsample.GetTransport(proto, address) + if err != nil { + return fmt.Errorf("create transport: %w", err) + } + responder, err := svrsample.GetResponder(proto, common.QueryState{ CurrentPlayers: 1, MaxPlayers: 2, @@ -100,43 +108,19 @@ func server(l *log.Logger, proto, address string) error { Port: 1000, }) if err != nil { - return err + return fmt.Errorf("create responder: %w", err) } - addr, err := net.ResolveUDPAddr("udp4", address) - if err != nil { - return err - } + // TODO: implement cancellable context + ctx, _ := context.WithCancel(context.Background()) - conn, err := net.ListenUDP("udp4", addr) + // this function will block until the transport is closed + err = transport.Start(ctx, responder) 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") - } + return fmt.Errorf("transport error") } + return nil } func bail(l *log.Logger, msg string) { diff --git a/go.mod b/go.mod index 83c1f9c..929c889 100644 --- a/go.mod +++ b/go.mod @@ -2,11 +2,23 @@ module github.com/multiplay/go-svrquery go 1.20 -require github.com/stretchr/testify v1.7.0 +require ( + github.com/prometheus/client_golang v1.17.0 + github.com/prometheus/common v0.45.0 + github.com/stretchr/testify v1.8.4 +) require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/stretchr/objx v0.3.0 // indirect - gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect + github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 // indirect + github.com/prometheus/procfs v0.11.1 // indirect + github.com/stretchr/objx v0.5.0 // indirect + golang.org/x/sys v0.13.0 // indirect + google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 1226ffb..244e4df 100644 --- a/go.sum +++ b/go.sum @@ -1,16 +1,46 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 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= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= +github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= 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/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= +github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= +github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 h1:v7DLqVdK4VrYkVD5diGdl4sxJurKJEMnODWRJlxV9oM= +github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= +github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM= +github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= +github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI= +github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.3.0 h1:NGXK3lHquSN08v5vWalVI/L8XU9hdzE/G6xsrze47As= -github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/lib/svrquery/client.go b/lib/svrquery/client.go index 5f95683..26bf45d 100644 --- a/lib/svrquery/client.go +++ b/lib/svrquery/client.go @@ -1,7 +1,11 @@ package svrquery import ( + "errors" + "fmt" + "io" "net" + "net/http" "time" "github.com/multiplay/go-svrquery/lib/svrquery/protocol" @@ -17,19 +21,20 @@ var ( DefaultNetwork = "udp" ) +var ( + _ protocol.Client = (*Client)(nil) +) + // Option represents a Client option. type Option func(*Client) error // Client provides the ability to query a server. type Client struct { protocol string - network string - addr string - ua *net.UDPAddr key string timeout time.Duration - c *net.UDPConn protocol.Queryer + transport } // WithKey sets the key used for request by for the client. @@ -43,12 +48,12 @@ func WithKey(key string) Option { // WithTimeout sets the read and write timeout for the client. func WithTimeout(t time.Duration) Option { return func(c *Client) error { - c.timeout = t + c.transport.SetTimeout(t) return nil } } -// NewClient creates a new client that talks to addr. +// NewClient creates a new client that talks to addr using proto. func NewClient(proto, addr string, options ...Option) (*Client, error) { f, err := protocol.Get(proto) if err != nil { @@ -57,49 +62,96 @@ func NewClient(proto, addr string, options ...Option) (*Client, error) { c := &Client{ protocol: proto, - addr: addr, - network: DefaultNetwork, - timeout: DefaultTimeout, } c.Queryer = f(c) + switch proto { + case "prom": + c.transport = newHTTPTransport(addr) + default: + // defaulting to udp + c.transport = newUDPTransport(addr) + } + for _, o := range options { if err := o(c); err != nil { return nil, err } } - if c.ua, err = net.ResolveUDPAddr(c.network, addr); err != nil { - return nil, err + if err := c.transport.Setup(); err != nil { + return nil, fmt.Errorf("setup client transport: %w", err) } - if c.c, err = net.DialUDP(c.network, nil, c.ua); err != nil { - return nil, err + return c, nil +} + +func (c *Client) Query() (protocol.Responser, error) { + return c.Queryer.Query() +} + +var ( + _ transport = (*udpTransport)(nil) + _ transport = (*httpTransport)(nil) +) + +type transport interface { + Setup() error + Address() string + SetTimeout(time.Duration) + io.ReadWriteCloser +} + +type udpTransport struct { + address string + timeout time.Duration + connection *net.UDPConn + udpAddress *net.UDPAddr +} + +func newUDPTransport(address string) *udpTransport { + return &udpTransport{address: address} +} + +// Address implements transport.Address. +func (u *udpTransport) Address() string { + return u.address +} + +// Setup implements transport.Setup. +func (u *udpTransport) Setup() error { + udpAddr, err := net.ResolveUDPAddr(DefaultNetwork, u.address) + if err != nil { + return err } + u.udpAddress = udpAddr - return c, nil + if u.connection, err = net.DialUDP(DefaultNetwork, nil, u.udpAddress); err != nil { + return err + } + return nil } // Write implements io.Writer. -func (c *Client) Write(b []byte) (int, error) { - if err := c.c.SetWriteDeadline(time.Now().Add(c.timeout)); err != nil { +func (u *udpTransport) Write(b []byte) (int, error) { + if err := u.connection.SetWriteDeadline(time.Now().Add(u.timeout)); err != nil { return 0, err } - return c.c.Write(b) + return u.connection.Write(b) } // Read implements io.Reader. -func (c *Client) Read(b []byte) (int, error) { - if err := c.c.SetReadDeadline(time.Now().Add(c.timeout)); err != nil { +func (u *udpTransport) Read(b []byte) (int, error) { + if err := u.connection.SetReadDeadline(time.Now().Add(u.timeout)); err != nil { return 0, err } for { - n, addr, err := c.c.ReadFromUDP(b) + n, addr, err := u.connection.ReadFromUDP(b) if err != nil { return 0, err - } else if addr.String() == c.ua.String() { // We use String as IP's can be different byte but the same value. + } else if addr.String() == u.udpAddress.String() { // We use String as IP's can be different byte but the same value. return n, nil } // Packet from unexpected source just ignore. @@ -107,8 +159,12 @@ func (c *Client) Read(b []byte) (int, error) { } // Close implements io.Closer. -func (c *Client) Close() error { - return c.c.Close() +func (u *udpTransport) Close() error { + return u.connection.Close() +} + +func (u *udpTransport) SetTimeout(t time.Duration) { + u.timeout = t } // Key implements protocol.Client. @@ -116,12 +172,50 @@ func (c *Client) Key() string { return c.key } -// Address implements protocol.Client. -func (c *Client) Address() string { - return c.addr -} - // Protocol returns the protocol of the client. func (c *Client) Protocol() string { return c.protocol } + +type httpTransport struct { + address string + httpClient *http.Client +} + +func newHTTPTransport(address string) *httpTransport { + t := &httpTransport{address: address} + t.httpClient = &http.Client{} + return t +} + +func (h *httpTransport) Setup() error { + // no-op + return nil +} + +func (h *httpTransport) Address() string { + return h.address +} + +func (h *httpTransport) Read(b []byte) (int, error) { + res, err := h.httpClient.Get(h.address) + if err != nil { + return 0, fmt.Errorf("http get: %w", err) + } + + return res.Body.Read(b) +} + +func (h *httpTransport) Write(b []byte) (int, error) { + return 0, errors.New("httpTransport.Write is unused") +} + +// Close implements io.Closer. +func (h *httpTransport) Close() error { + // no-op + return nil +} + +func (h *httpTransport) SetTimeout(t time.Duration) { + h.httpClient.Timeout = t +} diff --git a/lib/svrquery/client_test.go b/lib/svrquery/client_test.go index 403145d..2b995c9 100644 --- a/lib/svrquery/client_test.go +++ b/lib/svrquery/client_test.go @@ -20,6 +20,10 @@ func TestNewClient(t *testing.T) { name: "tf2e", protocol: "tf2e", }, + { + name: "prometheus", + protocol: "prom", + }, { name: "invalid-protocol", protocol: "my-protocol", @@ -41,6 +45,10 @@ func TestNewClient(t *testing.T) { } } +func TestRead(t *testing.T) { + t.Fatal("unimplemented") +} + func TestQuery(t *testing.T) { addr := os.Getenv("TEST_QUERY_ADDR") if addr == "" { diff --git a/lib/svrquery/protocol/all/all.go b/lib/svrquery/protocol/all/all.go index f2966cc..2c04143 100644 --- a/lib/svrquery/protocol/all/all.go +++ b/lib/svrquery/protocol/all/all.go @@ -3,6 +3,8 @@ package all import ( // Register all known protocols + + _ "github.com/multiplay/go-svrquery/lib/svrquery/protocol/prom" _ "github.com/multiplay/go-svrquery/lib/svrquery/protocol/sqp" _ "github.com/multiplay/go-svrquery/lib/svrquery/protocol/titanfall" ) diff --git a/lib/svrquery/protocol/prom/doc.go b/lib/svrquery/protocol/prom/doc.go new file mode 100644 index 0000000..7698a56 --- /dev/null +++ b/lib/svrquery/protocol/prom/doc.go @@ -0,0 +1,3 @@ +// Package prom provides the protocol implementation for metrics exposed in the Prometheus/OpenMetrics format +// Server Query Protocol. +package prom diff --git a/lib/svrquery/protocol/prom/query.go b/lib/svrquery/protocol/prom/query.go new file mode 100644 index 0000000..d1eaa25 --- /dev/null +++ b/lib/svrquery/protocol/prom/query.go @@ -0,0 +1,83 @@ +package prom + +import ( + "bytes" + "fmt" + "io" + "strconv" + + "github.com/multiplay/go-svrquery/lib/svrquery/protocol" + "github.com/prometheus/common/expfmt" +) + +const defaultBufSize = 4096 + +type queryer struct { + client protocol.Client +} + +func newCreator(c protocol.Client) protocol.Queryer { + return newQueryer(c) +} + +func newQueryer(client protocol.Client) *queryer { + return &queryer{ + client: client, + } +} + +// Query implements protocol.Queryer. +func (q *queryer) Query() (protocol.Responser, error) { + return q.makeQuery() +} + +func (q *queryer) makeQuery() (*QueryResponse, error) { + // FIXME: this won't work if the response is larger than defaultBufSize + responseBytes := make([]byte, defaultBufSize) + n, err := q.client.Read(responseBytes) + if n > 0 { + responseBytes = responseBytes[:n] + } + if err != nil && err != io.EOF { + return nil, fmt.Errorf("query response: %w", err) + } + var parser expfmt.TextParser + metrics, err := parser.TextToMetricFamilies(bytes.NewReader(responseBytes)) + if err != nil { + return nil, err + } + + resp := &QueryResponse{} + for _, v := range metrics { + switch *v.Name { + case currentPlayersMetricName: + resp.CurrentPlayers = *v.Metric[0].Gauge.Value + case maxPlayersMetricName: + resp.MaxPlayers = *v.Metric[0].Gauge.Value + case serverInfoMetricName: + if len(v.Metric) == 0 || v.Metric[0] == nil || len(v.Metric[0].Label) == 0 { + // server_info metric is missing labels + continue + } + for _, l := range v.Metric[0].Label { + switch *l.Name { + case "server_name": + resp.ServerName = *l.Value + case "game_type": + resp.GameType = *l.Value + case "map_name": + resp.MapName = *l.Value + case "port": + portInt, err := strconv.ParseInt(*l.Value, 10, 64) + if err != nil { + // invalid port + break + } + resp.Port = portInt + } + } + } + } + + return resp, nil +} diff --git a/lib/svrquery/protocol/prom/query_test.go b/lib/svrquery/protocol/prom/query_test.go new file mode 100644 index 0000000..203136b --- /dev/null +++ b/lib/svrquery/protocol/prom/query_test.go @@ -0,0 +1,10 @@ +package prom + +import ( + "testing" +) + +func TestQuery(t *testing.T) { + // use httptest.NewServer() + t.Fatal("unimplemented") +} diff --git a/lib/svrquery/protocol/prom/register.go b/lib/svrquery/protocol/prom/register.go new file mode 100644 index 0000000..0864bae --- /dev/null +++ b/lib/svrquery/protocol/prom/register.go @@ -0,0 +1,9 @@ +package prom + +import ( + "github.com/multiplay/go-svrquery/lib/svrquery/protocol" +) + +func init() { + protocol.MustRegister(protocol.Prometheus, newCreator) +} diff --git a/lib/svrquery/protocol/prom/types.go b/lib/svrquery/protocol/prom/types.go new file mode 100644 index 0000000..69b2cf7 --- /dev/null +++ b/lib/svrquery/protocol/prom/types.go @@ -0,0 +1,32 @@ +package prom + +const ( + metricNamespace = "" // adjust this if we want to enforce a namespace/prefix for metrics + currentPlayersMetricName = metricNamespace + "current_players" + maxPlayersMetricName = metricNamespace + "max_players" + serverInfoMetricName = metricNamespace + "server_info" +) + +// QueryResponse is the combined response to a query request +type QueryResponse struct { + CurrentPlayers float64 `json:"current_players"` + MaxPlayers float64 `json:"max_players"` + ServerName string `json:"server_name"` + GameType string `json:"game_type"` + MapName string `json:"map"` + Port int64 `json:"port"` +} + +// MaxClients implements protocol.Responser, returns the maximum number of clients. +func (q *QueryResponse) MaxClients() int64 { + return int64(q.MaxPlayers) +} + +// NumClients implements protocol.Responser, returns the number of clients. +func (q *QueryResponse) NumClients() int64 { + return int64(q.CurrentPlayers) +} + +func (q *QueryResponse) Map() string { + return q.MapName +} diff --git a/lib/svrquery/protocol/protocols.go b/lib/svrquery/protocol/protocols.go new file mode 100644 index 0000000..3a4fc8e --- /dev/null +++ b/lib/svrquery/protocol/protocols.go @@ -0,0 +1,11 @@ +package protocol + +const ( + SQP = "sqp" + TF2 = "tf2e" + TF2v7 = "tf2e-v7" + TF2v8 = "tf2e-v8" + TF2v9 = "tf2e-v9" + TF2v10 = "tf2e-v10" + Prometheus = "prom" +) diff --git a/lib/svrquery/protocol/sqp/register.go b/lib/svrquery/protocol/sqp/register.go index 3aadcf5..4a5646e 100644 --- a/lib/svrquery/protocol/sqp/register.go +++ b/lib/svrquery/protocol/sqp/register.go @@ -5,5 +5,5 @@ import ( ) func init() { - protocol.MustRegister("sqp", newCreator) + protocol.MustRegister(protocol.SQP, newCreator) } diff --git a/lib/svrquery/protocol/titanfall/register.go b/lib/svrquery/protocol/titanfall/register.go index c0180d7..4ea5b8a 100644 --- a/lib/svrquery/protocol/titanfall/register.go +++ b/lib/svrquery/protocol/titanfall/register.go @@ -6,9 +6,9 @@ import ( func init() { // TODO(steve): add support for tf2. - protocol.MustRegister("tf2e", newQueryer(3)) - protocol.MustRegister("tf2e-v7", newQueryer(7)) - protocol.MustRegister("tf2e-v8", newQueryer(8)) - protocol.MustRegister("tf2e-v9", newQueryer(9)) - protocol.MustRegister("tf2e-v10", newQueryer(10)) + protocol.MustRegister(protocol.TF2, newQueryer(3)) + protocol.MustRegister(protocol.TF2v7, newQueryer(7)) + protocol.MustRegister(protocol.TF2v8, newQueryer(8)) + protocol.MustRegister(protocol.TF2v9, newQueryer(9)) + protocol.MustRegister(protocol.TF2v10, newQueryer(10)) } diff --git a/lib/svrsample/protocol/prom/metrics.go b/lib/svrsample/protocol/prom/metrics.go new file mode 100644 index 0000000..215334c --- /dev/null +++ b/lib/svrsample/protocol/prom/metrics.go @@ -0,0 +1,66 @@ +package prom + +import ( + "github.com/multiplay/go-svrquery/lib/svrsample/common" + "github.com/prometheus/client_golang/prometheus" + "strconv" +) + +var _ prometheus.Collector = (*metrics)(nil) + +const ( + metricNamespace = "" +) + +// metrics holds the current prometheus metrics data for the server +type metrics struct { + currentPlayers prometheus.Gauge + maxPlayers prometheus.Gauge + serverInfo *prometheus.GaugeVec +} + +func newMetrics(reg prometheus.Registerer) *metrics { + m := &metrics{ + currentPlayers: prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: metricNamespace, + Subsystem: "", + Name: "current_players", + Help: "Number of players currently connected to the server.", + }), + maxPlayers: prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: metricNamespace, + Subsystem: "", + Name: "max_players", + Help: "Maximum number of players that can connect to the server.", + }), + serverInfo: prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: metricNamespace, + Subsystem: "", + Name: "server_info", + Help: "Server status info.", + ConstLabels: nil, + }, []string{"server_name", "game_type", "map_name", "port"}), + } + reg.MustRegister(m) + return m +} + +func (m metrics) Describe(descs chan<- *prometheus.Desc) { + m.currentPlayers.Describe(descs) + m.maxPlayers.Describe(descs) + m.serverInfo.Describe(descs) +} + +func (m metrics) Collect(c chan<- prometheus.Metric) { + m.currentPlayers.Collect(c) + m.maxPlayers.Collect(c) + m.serverInfo.Collect(c) +} + +// UpdateFromQueryState populates metrics with data from common.QueryState. +func (m metrics) UpdateFromQueryState(qs common.QueryState) { + m.currentPlayers.Set(float64(qs.CurrentPlayers)) + m.maxPlayers.Set(float64(qs.MaxPlayers)) + portString := strconv.FormatUint(uint64(qs.Port), 10) + m.serverInfo.WithLabelValues(qs.ServerName, qs.GameType, qs.Map, portString).Set(1) +} diff --git a/lib/svrsample/protocol/prom/prom.go b/lib/svrsample/protocol/prom/prom.go new file mode 100644 index 0000000..01d1407 --- /dev/null +++ b/lib/svrsample/protocol/prom/prom.go @@ -0,0 +1,43 @@ +package prom + +import ( + "github.com/multiplay/go-svrquery/lib/svrsample/common" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" + "net/http" +) + +// QueryResponder responds to queries +type QueryResponder struct { + state common.QueryState + registry *prometheus.Registry + metrics *metrics + HTTPHandler http.Handler +} + +// NewQueryResponder returns creates a new responder capable of returning metrics in Prometheus format. +func NewQueryResponder(state common.QueryState) (*QueryResponder, error) { + // Create a registry, new metrics and register them + reg := prometheus.NewRegistry() + m := newMetrics(reg) + + httpHandler := promhttp.HandlerFor(reg, promhttp.HandlerOpts{Registry: reg}) + + q := &QueryResponder{ + state: state, + registry: reg, + metrics: m, + HTTPHandler: httpHandler, + } + + // update metrics (though in this sample server they never change) + m.UpdateFromQueryState(state) + + return q, nil +} + +// Respond generates the query response for the requester. +func (q *QueryResponder) Respond(_ string, _ []byte) ([]byte, error) { + // no-op, the http handler takes care of writing responses + return nil, nil +} diff --git a/lib/svrsample/protocol/prom/prom_test.go b/lib/svrsample/protocol/prom/prom_test.go new file mode 100644 index 0000000..9372c0d --- /dev/null +++ b/lib/svrsample/protocol/prom/prom_test.go @@ -0,0 +1,9 @@ +package prom + +import ( + "testing" +) + +func TestPrometheusServer(t *testing.T) { + t.Fatal("unimplemented") +} diff --git a/lib/svrsample/query.go b/lib/svrsample/query.go index 8fde442..838b631 100644 --- a/lib/svrsample/query.go +++ b/lib/svrsample/query.go @@ -5,7 +5,7 @@ import ( "fmt" "github.com/multiplay/go-svrquery/lib/svrsample/common" - + "github.com/multiplay/go-svrquery/lib/svrsample/protocol/prom" "github.com/multiplay/go-svrquery/lib/svrsample/protocol/sqp" ) @@ -19,6 +19,20 @@ func GetResponder(proto string, state common.QueryState) (common.QueryResponder, switch proto { case "sqp": return sqp.NewQueryResponder(state) + case "prom": + return prom.NewQueryResponder(state) } + return nil, fmt.Errorf("%w: %s", ErrProtoNotSupported, proto) } + +func GetTransport(proto, address string) (Transport, error) { + switch proto { + case "sqp": + return NewUDPTransport(address), nil + case "prom": + return NewHTTPTransport(address), nil + default: + return nil, fmt.Errorf("%w: %s", ErrProtoNotSupported, proto) + } +} diff --git a/lib/svrsample/transport.go b/lib/svrsample/transport.go new file mode 100644 index 0000000..322f46d --- /dev/null +++ b/lib/svrsample/transport.go @@ -0,0 +1,121 @@ +package svrsample + +import ( + "context" + "errors" + "fmt" + "github.com/multiplay/go-svrquery/lib/svrsample/common" + "github.com/multiplay/go-svrquery/lib/svrsample/protocol/prom" + "log" + "net" + "net/http" + "time" +) + +var ( + _ Transport = (*UDPTransport)(nil) + _ Transport = (*HTTPTransport)(nil) +) + +// Transport is an abstraction of the metrics transport (UDP, HTTP, etc.) +type Transport interface { + // Start starts the transport and blocks until it is stopped + Start(context.Context, common.QueryResponder) error +} + +type UDPTransport struct { + address string + udpAddress *net.UDPAddr + conn *net.UDPConn +} + +func NewUDPTransport(address string) UDPTransport { + return UDPTransport{address: address} +} + +func (u UDPTransport) Start(ctx context.Context, responder common.QueryResponder) error { + // TODO: do something with context + + addr, err := net.ResolveUDPAddr("udp4", u.address) + if err != nil { + return fmt.Errorf("resolved udp: %w", err) + } + + conn, err := net.ListenUDP("udp4", addr) + if err != nil { + return fmt.Errorf("listen udp: %w", err) + } + u.conn = conn + + for { + buf := make([]byte, 16) + err := u.read(buf) + if err != nil { + log.Println("read", err) + continue + } + + resp, err := responder.Respond(u.udpAddress.String(), buf) + if err != nil { + log.Println("responding to query", err) + continue + } + + if err = u.write(resp); err != nil { + log.Println("writing response") + } + } +} + +func (u UDPTransport) read(buf []byte) error { + _, udpAddr, err := u.conn.ReadFromUDP(buf) + if err != nil { + return fmt.Errorf("read udp: %w", err) + } + u.udpAddress = udpAddr + return nil +} + +func (u UDPTransport) write(resp []byte) error { + if err := u.conn.SetWriteDeadline(time.Now().Add(1 * time.Second)); err != nil { + return fmt.Errorf("set write deadline: %w", err) + } + + if _, err := u.conn.WriteTo(resp, u.udpAddress); err != nil { + return fmt.Errorf("write udp: %w", err) + } + return nil +} + +type HTTPTransport struct { + address string + httpServer *http.Server +} + +func NewHTTPTransport(address string) HTTPTransport { + return HTTPTransport{address: address} +} + +func (h HTTPTransport) Start(ctx context.Context, responder common.QueryResponder) error { + promResponder, ok := responder.(*prom.QueryResponder) + if !ok { + return errors.New(fmt.Sprintf("bad responder type, expected prom.QueryResponder but got %T", responder)) + } + + // TODO: do something with context + + listener, err := net.Listen("tcp", h.address) + if err != nil { + return fmt.Errorf("listen tcp: %w", err) + } + + mux := http.NewServeMux() + mux.Handle("/metrics", promResponder.HTTPHandler) + httpServer := &http.Server{Addr: h.address, Handler: mux} + h.httpServer = httpServer + + if err = h.httpServer.Serve(listener); err != nil { + return fmt.Errorf("serve http: %w", err) + } + return nil +} diff --git a/lib/svrsample/transport_test.go b/lib/svrsample/transport_test.go new file mode 100644 index 0000000..922f791 --- /dev/null +++ b/lib/svrsample/transport_test.go @@ -0,0 +1,13 @@ +package svrsample + +import ( + "testing" +) + +func TestUDPTransport(t *testing.T) { + t.Fatal("unimplemented") +} + +func TestHTTPTransport(t *testing.T) { + t.Fatal("unimplemented") +}