Add Minecraft input plugin (#2960)
This commit is contained in:
parent
d774c2a170
commit
a726579d50
|
@ -12,6 +12,7 @@ works:
|
||||||
- github.com/boltdb/bolt [MIT](https://github.com/boltdb/bolt/blob/master/LICENSE)
|
- github.com/boltdb/bolt [MIT](https://github.com/boltdb/bolt/blob/master/LICENSE)
|
||||||
- github.com/bsm/sarama-cluster [MIT](https://github.com/bsm/sarama-cluster/blob/master/LICENSE)
|
- github.com/bsm/sarama-cluster [MIT](https://github.com/bsm/sarama-cluster/blob/master/LICENSE)
|
||||||
- github.com/cenkalti/backoff [MIT](https://github.com/cenkalti/backoff/blob/master/LICENSE)
|
- github.com/cenkalti/backoff [MIT](https://github.com/cenkalti/backoff/blob/master/LICENSE)
|
||||||
|
- github.com/chuckpreslar/rcon [MIT](https://github.com/chuckpreslar/rcon#license)
|
||||||
- github.com/couchbase/go-couchbase [MIT](https://github.com/couchbase/go-couchbase/blob/master/LICENSE)
|
- github.com/couchbase/go-couchbase [MIT](https://github.com/couchbase/go-couchbase/blob/master/LICENSE)
|
||||||
- github.com/couchbase/gomemcached [MIT](https://github.com/couchbase/gomemcached/blob/master/LICENSE)
|
- github.com/couchbase/gomemcached [MIT](https://github.com/couchbase/gomemcached/blob/master/LICENSE)
|
||||||
- github.com/couchbase/goutils [MIT](https://github.com/couchbase/go-couchbase/blob/master/LICENSE)
|
- github.com/couchbase/goutils [MIT](https://github.com/couchbase/go-couchbase/blob/master/LICENSE)
|
||||||
|
|
|
@ -45,6 +45,7 @@ import (
|
||||||
_ "github.com/influxdata/telegraf/plugins/inputs/mailchimp"
|
_ "github.com/influxdata/telegraf/plugins/inputs/mailchimp"
|
||||||
_ "github.com/influxdata/telegraf/plugins/inputs/memcached"
|
_ "github.com/influxdata/telegraf/plugins/inputs/memcached"
|
||||||
_ "github.com/influxdata/telegraf/plugins/inputs/mesos"
|
_ "github.com/influxdata/telegraf/plugins/inputs/mesos"
|
||||||
|
_ "github.com/influxdata/telegraf/plugins/inputs/minecraft"
|
||||||
_ "github.com/influxdata/telegraf/plugins/inputs/mongodb"
|
_ "github.com/influxdata/telegraf/plugins/inputs/mongodb"
|
||||||
_ "github.com/influxdata/telegraf/plugins/inputs/mqtt_consumer"
|
_ "github.com/influxdata/telegraf/plugins/inputs/mqtt_consumer"
|
||||||
_ "github.com/influxdata/telegraf/plugins/inputs/mysql"
|
_ "github.com/influxdata/telegraf/plugins/inputs/mysql"
|
||||||
|
|
|
@ -0,0 +1,66 @@
|
||||||
|
# Minecraft Plugin
|
||||||
|
|
||||||
|
This plugin uses the RCON protocol to collect [statistics](http://minecraft.gamepedia.com/Statistics) from a [scoreboard](http://minecraft.gamepedia.com/Scoreboard) on a
|
||||||
|
Minecraft server.
|
||||||
|
|
||||||
|
To enable [RCON](http://wiki.vg/RCON) on the minecraft server, add this to your server configuration in the `server.properties` file:
|
||||||
|
|
||||||
|
```
|
||||||
|
enable-rcon=true
|
||||||
|
rcon.password=<your password>
|
||||||
|
rcon.port=<1-65535>
|
||||||
|
```
|
||||||
|
|
||||||
|
To create a new scoreboard objective called `jump` on a minecraft server tracking the `stat.jump` criteria, run this command
|
||||||
|
in the Minecraft console:
|
||||||
|
|
||||||
|
`/scoreboard objectives add jump stat.jump`
|
||||||
|
|
||||||
|
Stats are collected with the following RCON command, issued by the plugin:
|
||||||
|
|
||||||
|
`/scoreboard players list *`
|
||||||
|
|
||||||
|
### Configuration:
|
||||||
|
```
|
||||||
|
[[inputs.minecraft]]
|
||||||
|
# server address for minecraft
|
||||||
|
server = "localhost"
|
||||||
|
# port for RCON
|
||||||
|
port = "25575"
|
||||||
|
# password RCON for mincraft server
|
||||||
|
password = "replace_me"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Measurements & Fields:
|
||||||
|
|
||||||
|
*This plugin uses only one measurement, titled* `minecraft`
|
||||||
|
|
||||||
|
- The field name is the scoreboard objective name.
|
||||||
|
- The field value is the count of the scoreboard objective
|
||||||
|
|
||||||
|
- `minecraft`
|
||||||
|
- `<objective_name>` (integer, count)
|
||||||
|
|
||||||
|
### Tags:
|
||||||
|
|
||||||
|
- The `minecraft` measurement:
|
||||||
|
- `server`: the Minecraft RCON server
|
||||||
|
- `player`: the Minecraft player
|
||||||
|
|
||||||
|
|
||||||
|
### Sample Queries:
|
||||||
|
|
||||||
|
Get the number of jumps per player in the last hour:
|
||||||
|
```
|
||||||
|
SELECT SPREAD("jump") FROM "minecraft" WHERE time > now() - 1h GROUP BY "player"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example Output:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ telegraf --input-filter minecraft --test
|
||||||
|
* Plugin: inputs.minecraft, Collection 1
|
||||||
|
> minecraft,player=notch,server=127.0.0.1:25575 jumps=178i 1498261397000000000
|
||||||
|
> minecraft,player=dinnerbone,server=127.0.0.1:25575 deaths=1i,jumps=1999i,cow_kills=1i 1498261397000000000
|
||||||
|
> minecraft,player=jeb,server=127.0.0.1:25575 d_pickaxe=1i,damage_dealt=80i,d_sword=2i,hunger=20i,health=20i,kills=1i,level=33i,jumps=264i,armor=15i 1498261397000000000
|
||||||
|
```
|
|
@ -0,0 +1,200 @@
|
||||||
|
// Package rcon implements the communication protocol for communicating
|
||||||
|
// with RCON servers. Tested and working with Valve game servers.
|
||||||
|
package rcon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
PacketPaddingSize uint8 = 2 // Size of Packet's padding.
|
||||||
|
PacketHeaderSize uint8 = 8 // Size of Packet's header.
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
TerminationSequence = "\x00" // Null empty ASCII string suffix.
|
||||||
|
)
|
||||||
|
|
||||||
|
// Packet type constants.
|
||||||
|
// https://developer.valvesoftware.com/wiki/Source_RCON_Protocol#Packet_Type
|
||||||
|
const (
|
||||||
|
Exec int32 = 2
|
||||||
|
Auth int32 = 3
|
||||||
|
AuthResponse int32 = 2
|
||||||
|
ResponseValue int32 = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rcon package errors.
|
||||||
|
var (
|
||||||
|
ErrInvalidWrite = errors.New("Failed to write the payload corretly to remote connection.")
|
||||||
|
ErrInvalidRead = errors.New("Failed to read the response corretly from remote connection.")
|
||||||
|
ErrInvalidChallenge = errors.New("Server failed to mirror request challenge.")
|
||||||
|
ErrUnauthorizedRequest = errors.New("Client not authorized to remote server.")
|
||||||
|
ErrFailedAuthorization = errors.New("Failed to authorize to the remote server.")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
Host string // The IP address of the remote server.
|
||||||
|
Port int // The Port the remote server's listening on.
|
||||||
|
Authorized bool // Has the client been authorized by the server?
|
||||||
|
Connection net.Conn // The TCP connection to the server.
|
||||||
|
}
|
||||||
|
|
||||||
|
type Header struct {
|
||||||
|
Size int32 // The size of the payload.
|
||||||
|
Challenge int32 // The challenge ths server should mirror.
|
||||||
|
Type int32 // The type of request being sent.
|
||||||
|
}
|
||||||
|
|
||||||
|
type Packet struct {
|
||||||
|
Header Header // Packet header.
|
||||||
|
Body string // Body of packet.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile converts a packets header and body into its approriate
|
||||||
|
// byte array payload, returning an error if the binary packages
|
||||||
|
// Write method fails to write the header bytes in their little
|
||||||
|
// endian byte order.
|
||||||
|
func (p Packet) Compile() (payload []byte, err error) {
|
||||||
|
var size int32 = p.Header.Size
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
var padding [PacketPaddingSize]byte
|
||||||
|
|
||||||
|
if err = binary.Write(&buffer, binary.LittleEndian, &size); nil != err {
|
||||||
|
return
|
||||||
|
} else if err = binary.Write(&buffer, binary.LittleEndian, &p.Header.Challenge); nil != err {
|
||||||
|
return
|
||||||
|
} else if err = binary.Write(&buffer, binary.LittleEndian, &p.Header.Type); nil != err {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.WriteString(p.Body)
|
||||||
|
buffer.Write(padding[:])
|
||||||
|
|
||||||
|
return buffer.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPacket returns a pointer to a new Packet type.
|
||||||
|
func NewPacket(challenge, typ int32, body string) (packet *Packet) {
|
||||||
|
size := int32(len([]byte(body)) + int(PacketHeaderSize+PacketPaddingSize))
|
||||||
|
return &Packet{Header{size, challenge, typ}, body}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorize calls Send with the appropriate command type and the provided
|
||||||
|
// password. The response packet is returned if authorization is successful
|
||||||
|
// or a potential error.
|
||||||
|
func (c *Client) Authorize(password string) (response *Packet, err error) {
|
||||||
|
if response, err = c.Send(Auth, password); nil == err {
|
||||||
|
if response.Header.Type == AuthResponse {
|
||||||
|
c.Authorized = true
|
||||||
|
} else {
|
||||||
|
err = ErrFailedAuthorization
|
||||||
|
response = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute calls Send with the appropriate command type and the provided
|
||||||
|
// command. The response packet is returned if the command executed successfully
|
||||||
|
// or a potential error.
|
||||||
|
func (c *Client) Execute(command string) (response *Packet, err error) {
|
||||||
|
return c.Send(Exec, command)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sends accepts the commands type and its string to execute to the clients server,
|
||||||
|
// creating a packet with a random challenge id for the server to mirror,
|
||||||
|
// and compiling its payload bytes in the appropriate order. The resonse is
|
||||||
|
// decompiled from its bytes into a Packet type for return. An error is returned
|
||||||
|
// if send fails.
|
||||||
|
func (c *Client) Send(typ int32, command string) (response *Packet, err error) {
|
||||||
|
if typ != Auth && !c.Authorized {
|
||||||
|
err = ErrUnauthorizedRequest
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a random challenge for the server to mirror in its response.
|
||||||
|
var challenge int32
|
||||||
|
binary.Read(rand.Reader, binary.LittleEndian, &challenge)
|
||||||
|
|
||||||
|
// Create the packet from the challenge, typ and command
|
||||||
|
// and compile it to its byte payload
|
||||||
|
packet := NewPacket(challenge, typ, command)
|
||||||
|
payload, err := packet.Compile()
|
||||||
|
|
||||||
|
var n int
|
||||||
|
|
||||||
|
if nil != err {
|
||||||
|
return
|
||||||
|
} else if n, err = c.Connection.Write(payload); nil != err {
|
||||||
|
return
|
||||||
|
} else if n != len(payload) {
|
||||||
|
err = ErrInvalidWrite
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var header Header
|
||||||
|
|
||||||
|
if err = binary.Read(c.Connection, binary.LittleEndian, &header.Size); nil != err {
|
||||||
|
return
|
||||||
|
} else if err = binary.Read(c.Connection, binary.LittleEndian, &header.Challenge); nil != err {
|
||||||
|
return
|
||||||
|
} else if err = binary.Read(c.Connection, binary.LittleEndian, &header.Type); nil != err {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if packet.Header.Type == Auth && header.Type == ResponseValue {
|
||||||
|
// Discard, empty SERVERDATA_RESPOSE_VALUE from authorization.
|
||||||
|
c.Connection.Read(make([]byte, header.Size-int32(PacketHeaderSize)))
|
||||||
|
|
||||||
|
// Reread the packet header.
|
||||||
|
if err = binary.Read(c.Connection, binary.LittleEndian, &header.Size); nil != err {
|
||||||
|
return
|
||||||
|
} else if err = binary.Read(c.Connection, binary.LittleEndian, &header.Challenge); nil != err {
|
||||||
|
return
|
||||||
|
} else if err = binary.Read(c.Connection, binary.LittleEndian, &header.Type); nil != err {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if header.Challenge != packet.Header.Challenge {
|
||||||
|
err = ErrInvalidChallenge
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body := make([]byte, header.Size-int32(PacketHeaderSize))
|
||||||
|
|
||||||
|
n, err = c.Connection.Read(body)
|
||||||
|
|
||||||
|
if nil != err {
|
||||||
|
return
|
||||||
|
} else if n != len(body) {
|
||||||
|
err = ErrInvalidRead
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response = new(Packet)
|
||||||
|
response.Header = header
|
||||||
|
response.Body = strings.TrimRight(string(body), TerminationSequence)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a new Client type, creating the connection
|
||||||
|
// to the server specified by the host and port arguements. If
|
||||||
|
// the connection fails, an error is returned.
|
||||||
|
func NewClient(host string, port int) (client *Client, err error) {
|
||||||
|
client = new(Client)
|
||||||
|
client.Host = host
|
||||||
|
client.Port = port
|
||||||
|
client.Connection, err = net.Dial("tcp", fmt.Sprintf("%v:%v", client.Host, client.Port))
|
||||||
|
return
|
||||||
|
}
|
|
@ -0,0 +1,142 @@
|
||||||
|
package minecraft
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/influxdata/telegraf"
|
||||||
|
"github.com/influxdata/telegraf/plugins/inputs"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sampleConfig = `
|
||||||
|
## server address for minecraft
|
||||||
|
# server = "localhost"
|
||||||
|
## port for RCON
|
||||||
|
# port = "25575"
|
||||||
|
## password RCON for mincraft server
|
||||||
|
# password = ""
|
||||||
|
`
|
||||||
|
|
||||||
|
var (
|
||||||
|
playerNameRegex = regexp.MustCompile(`for\s([^:]+):-`)
|
||||||
|
scoreboardRegex = regexp.MustCompile(`(?U):\s(\d+)\s\((.*)\)`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client is an interface for a client which gathers data from a minecraft server
|
||||||
|
type Client interface {
|
||||||
|
Gather() ([]string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minecraft represents a connection to a minecraft server
|
||||||
|
type Minecraft struct {
|
||||||
|
Server string
|
||||||
|
Port string
|
||||||
|
Password string
|
||||||
|
client Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description gives a brief description.
|
||||||
|
func (s *Minecraft) Description() string {
|
||||||
|
return "Collects scores from a minecraft server's scoreboard using the RCON protocol"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SampleConfig returns our sampleConfig.
|
||||||
|
func (s *Minecraft) SampleConfig() string {
|
||||||
|
return sampleConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gather uses the RCON protocal to collect player and
|
||||||
|
// scoreboard stats from a minecraft server.
|
||||||
|
func (s *Minecraft) Gather(acc telegraf.Accumulator) error {
|
||||||
|
if s.client == nil {
|
||||||
|
var err error
|
||||||
|
s.client, err = NewRCON(s.Server, s.Port, s.Password)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scores, err := s.client.Gather()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, score := range scores {
|
||||||
|
player, err := ParsePlayerName(score)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tags := map[string]string{
|
||||||
|
"player": player,
|
||||||
|
"server": s.Server + ":" + s.Port,
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err := ParseScoreboard(score)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var fields = make(map[string]interface{}, len(stats))
|
||||||
|
for _, stat := range stats {
|
||||||
|
fields[stat.Name] = stat.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.AddFields("minecraft", fields, tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePlayerName takes an input string from rcon, to parse
|
||||||
|
// the player.
|
||||||
|
func ParsePlayerName(input string) (string, error) {
|
||||||
|
playerMatches := playerNameRegex.FindAllStringSubmatch(input, -1)
|
||||||
|
if playerMatches == nil {
|
||||||
|
return "", fmt.Errorf("no player was matched")
|
||||||
|
}
|
||||||
|
return playerMatches[0][1], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Score is an individual tracked scoreboard stat.
|
||||||
|
type Score struct {
|
||||||
|
Name string
|
||||||
|
Value int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseScoreboard takes an input string from rcon, to parse
|
||||||
|
// scoreboard stats.
|
||||||
|
func ParseScoreboard(input string) ([]Score, error) {
|
||||||
|
scoreMatches := scoreboardRegex.FindAllStringSubmatch(input, -1)
|
||||||
|
if scoreMatches == nil {
|
||||||
|
return nil, fmt.Errorf("No scores found")
|
||||||
|
}
|
||||||
|
|
||||||
|
var scores []Score
|
||||||
|
|
||||||
|
for _, match := range scoreMatches {
|
||||||
|
number := match[1]
|
||||||
|
name := match[2]
|
||||||
|
n, err := strconv.Atoi(number)
|
||||||
|
// Not necessary in current state, because regex can only match integers,
|
||||||
|
// maybe become necessary if regex is modified to match more types of
|
||||||
|
// numbers
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Failed to parse score")
|
||||||
|
}
|
||||||
|
s := Score{
|
||||||
|
Name: name,
|
||||||
|
Value: n,
|
||||||
|
}
|
||||||
|
scores = append(scores, s)
|
||||||
|
}
|
||||||
|
return scores, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
inputs.Add("minecraft", func() telegraf.Input {
|
||||||
|
return &Minecraft{
|
||||||
|
Server: "localhost",
|
||||||
|
Port: "25575",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
|
@ -0,0 +1,228 @@
|
||||||
|
package minecraft
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/influxdata/telegraf/testutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestParsePlayerName tests different Minecraft RCON inputs for players
|
||||||
|
func TestParsePlayerName(t *testing.T) {
|
||||||
|
// Test a valid input string to ensure player is extracted
|
||||||
|
input := "1 tracked objective(s) for divislight:- jumps: 178 (jumps)"
|
||||||
|
got, err := ParsePlayerName(input)
|
||||||
|
want := "divislight"
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("player returned error. Error: %s\n", err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("got %s\nwant %s\n", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test an invalid input string to ensure error is returned
|
||||||
|
input = ""
|
||||||
|
got, err = ParsePlayerName(input)
|
||||||
|
want = ""
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error when player not present. No error found.")
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("got %s\n want %s\n", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test an invalid input string to ensure error is returned
|
||||||
|
input = "1 tracked objective(s) for 😂:- jumps: 178 (jumps)"
|
||||||
|
got, err = ParsePlayerName(input)
|
||||||
|
want = "😂"
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("player returned error. Error: %s\n", err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("got %s\n want %s\n", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseScoreboard tests different Minecraft RCON inputs for scoreboard stats.
|
||||||
|
func TestParseScoreboard(t *testing.T) {
|
||||||
|
// test a valid input string to ensure stats are parsed correctly.
|
||||||
|
input := `1 tracked objective(s) for divislight:- jumps: 178 (jumps)- sword: 5 (sword)`
|
||||||
|
got, err := ParseScoreboard(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("Unexpected error")
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []Score{
|
||||||
|
{
|
||||||
|
Name: "jumps",
|
||||||
|
Value: 178,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "sword",
|
||||||
|
Value: 5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("Got: \n%#v\nWant: %#v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests a partial input string.
|
||||||
|
input = `1 tracked objective(s) for divislight:- jumps: (jumps)- sword: 5 (sword)`
|
||||||
|
got, err = ParseScoreboard(input)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("Unexpected error")
|
||||||
|
}
|
||||||
|
|
||||||
|
want = []Score{
|
||||||
|
{
|
||||||
|
Name: "sword",
|
||||||
|
Value: 5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("Got: \n%#v\nWant:\n%#v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests an empty string.
|
||||||
|
input = ``
|
||||||
|
_, err = ParseScoreboard(input)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected input error, but error was nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests when a number isn't an integer.
|
||||||
|
input = `1 tracked objective(s) for divislight:- jumps: 178.5 (jumps)- sword: 5 (sword)`
|
||||||
|
got, err = ParseScoreboard(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("Unexpected error")
|
||||||
|
}
|
||||||
|
|
||||||
|
want = []Score{
|
||||||
|
{
|
||||||
|
Name: "sword",
|
||||||
|
Value: 5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("Got: \n%#v\nWant: %#v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
//Testing a real life data scenario with unicode characters
|
||||||
|
input = `7 tracked objective(s) for mauxlaim:- total_kills: 39 (total_kills)- "howdy doody": 37 (dalevel)- howdy: 37 (lvl)- jumps: 1290 (jumps)- iron_pickaxe: 284 (iron_pickaxe)- cow_kills: 1 (cow_kills)- "asdf": 37 (😂)`
|
||||||
|
got, err = ParseScoreboard(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("Unexpected error")
|
||||||
|
}
|
||||||
|
|
||||||
|
want = []Score{
|
||||||
|
{
|
||||||
|
Name: "total_kills",
|
||||||
|
Value: 39,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "dalevel",
|
||||||
|
Value: 37,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "lvl",
|
||||||
|
Value: 37,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "jumps",
|
||||||
|
Value: 1290,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "iron_pickaxe",
|
||||||
|
Value: 284,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "cow_kills",
|
||||||
|
Value: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "😂",
|
||||||
|
Value: 37,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("Got: \n%#v\nWant: %#v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockClient struct {
|
||||||
|
Result []string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockClient) Gather() ([]string, error) {
|
||||||
|
return m.Result, m.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGather(t *testing.T) {
|
||||||
|
var acc testutil.Accumulator
|
||||||
|
testConfig := Minecraft{
|
||||||
|
Server: "biffsgang.net",
|
||||||
|
Port: "25575",
|
||||||
|
client: &MockClient{
|
||||||
|
Result: []string{
|
||||||
|
`1 tracked objective(s) for divislight:- jumps: 178 (jumps)`,
|
||||||
|
`7 tracked objective(s) for mauxlaim:- total_kills: 39 (total_kills)- "howdy doody": 37 (dalevel)- howdy: 37 (lvl)- jumps: 1290 (jumps)- iron_pickaxe: 284 (iron_pickaxe)- cow_kills: 1 (cow_kills)- "asdf": 37 (😂)`,
|
||||||
|
`5 tracked objective(s) for torham:- total_kills: 29 (total_kills)- "howdy doody": 33 (dalevel)- howdy: 33 (lvl)- jumps: 263 (jumps)- "asdf": 33 (😂)`,
|
||||||
|
},
|
||||||
|
Err: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := testConfig.Gather(&acc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("gather returned error. Error: %s\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tags := map[string]string{
|
||||||
|
"player": "divislight",
|
||||||
|
"server": "biffsgang.net:25575",
|
||||||
|
}
|
||||||
|
|
||||||
|
assertContainsTaggedStat(t, &acc, "minecraft", "jumps", 178, tags)
|
||||||
|
tags["player"] = "mauxlaim"
|
||||||
|
assertContainsTaggedStat(t, &acc, "minecraft", "cow_kills", 1, tags)
|
||||||
|
tags["player"] = "torham"
|
||||||
|
assertContainsTaggedStat(t, &acc, "minecraft", "total_kills", 29, tags)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertContainsTaggedStat(
|
||||||
|
t *testing.T,
|
||||||
|
acc *testutil.Accumulator,
|
||||||
|
measurement string,
|
||||||
|
field string,
|
||||||
|
expectedValue int,
|
||||||
|
tags map[string]string,
|
||||||
|
) {
|
||||||
|
var actualValue int
|
||||||
|
for _, pt := range acc.Metrics {
|
||||||
|
if pt.Measurement == measurement && reflect.DeepEqual(pt.Tags, tags) {
|
||||||
|
for fieldname, value := range pt.Fields {
|
||||||
|
if fieldname == field {
|
||||||
|
actualValue = value.(int)
|
||||||
|
if value == expectedValue {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Errorf("Expected value %d\n got value %d\n", expectedValue, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msg := fmt.Sprintf(
|
||||||
|
"Could not find measurement \"%s\" with requested tags within %s, Actual: %d",
|
||||||
|
measurement, field, actualValue)
|
||||||
|
t.Fatal(msg)
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,88 @@
|
||||||
|
package minecraft
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/influxdata/telegraf/plugins/inputs/minecraft/internal/rcon"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// NoMatches is a sentinel value returned when there are no statistics defined on the
|
||||||
|
//minecraft server
|
||||||
|
NoMatches = `All matches failed`
|
||||||
|
// ScoreboardPlayerList is the command to see all player statistics
|
||||||
|
ScoreboardPlayerList = `scoreboard players list *`
|
||||||
|
)
|
||||||
|
|
||||||
|
// RCONClient is a representation of RCON command authorizaiton and exectution
|
||||||
|
type RCONClient interface {
|
||||||
|
Authorize(password string) (*rcon.Packet, error)
|
||||||
|
Execute(command string) (*rcon.Packet, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RCON represents a RCON server connection
|
||||||
|
type RCON struct {
|
||||||
|
Server string
|
||||||
|
Port string
|
||||||
|
Password string
|
||||||
|
client RCONClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRCON creates a new RCON
|
||||||
|
func NewRCON(server, port, password string) (*RCON, error) {
|
||||||
|
client, err := newClient(server, port)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &RCON{
|
||||||
|
Server: server,
|
||||||
|
Port: port,
|
||||||
|
Password: password,
|
||||||
|
client: client,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newClient(server, port string) (*rcon.Client, error) {
|
||||||
|
p, err := strconv.Atoi(port)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return rcon.NewClient(server, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gather recieves all player scoreboard information and returns it per player.
|
||||||
|
func (r *RCON) Gather() ([]string, error) {
|
||||||
|
if r.client == nil {
|
||||||
|
var err error
|
||||||
|
r.client, err = newClient(r.Server, r.Port)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := r.client.Authorize(r.Password); err != nil {
|
||||||
|
// Potentially a network problem where the client will need to be
|
||||||
|
// re-initialized
|
||||||
|
r.client = nil
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
packet, err := r.client.Execute(ScoreboardPlayerList)
|
||||||
|
if err != nil {
|
||||||
|
// Potentially a network problem where the client will need to be
|
||||||
|
// re-initialized
|
||||||
|
r.client = nil
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(packet.Body, NoMatches) {
|
||||||
|
players := strings.Split(packet.Body, "Showing")
|
||||||
|
if len(players) > 1 {
|
||||||
|
return players[1:], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
|
@ -0,0 +1,67 @@
|
||||||
|
package minecraft
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/influxdata/telegraf/plugins/inputs/minecraft/internal/rcon"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MockRCONClient struct {
|
||||||
|
Result *rcon.Packet
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRCONClient) Authorize(password string) (*rcon.Packet, error) {
|
||||||
|
return m.Result, m.Err
|
||||||
|
}
|
||||||
|
func (m *MockRCONClient) Execute(command string) (*rcon.Packet, error) {
|
||||||
|
return m.Result, m.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRCONGather test the RCON gather function
|
||||||
|
func TestRCONGather(t *testing.T) {
|
||||||
|
mock := &MockRCONClient{
|
||||||
|
Result: &rcon.Packet{
|
||||||
|
Body: `Showing 1 tracked objective(s) for divislight:- jumps: 178 (jumps)Showing 7 tracked objective(s) for mauxlaim:- total_kills: 39 (total_kills)- "howdy doody": 37 (dalevel)- howdy: 37 (lvl)- jumps: 1290 (jumps)- iron_pickaxe: 284 (iron_pickaxe)- cow_kills: 1 (cow_kills)- "asdf": 37 (😂)Showing 5 tracked objective(s) for torham:- total_kills: 29 (total_kills)- "howdy doody": 33 (dalevel)- howdy: 33 (lvl)- jumps: 263 (jumps)- "asdf": 33 (😂)`,
|
||||||
|
},
|
||||||
|
Err: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{
|
||||||
|
` 1 tracked objective(s) for divislight:- jumps: 178 (jumps)`,
|
||||||
|
` 7 tracked objective(s) for mauxlaim:- total_kills: 39 (total_kills)- "howdy doody": 37 (dalevel)- howdy: 37 (lvl)- jumps: 1290 (jumps)- iron_pickaxe: 284 (iron_pickaxe)- cow_kills: 1 (cow_kills)- "asdf": 37 (😂)`,
|
||||||
|
` 5 tracked objective(s) for torham:- total_kills: 29 (total_kills)- "howdy doody": 33 (dalevel)- howdy: 33 (lvl)- jumps: 263 (jumps)- "asdf": 33 (😂)`,
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &RCON{
|
||||||
|
Server: "craftstuff.com",
|
||||||
|
Port: "2222",
|
||||||
|
Password: "pass",
|
||||||
|
client: mock,
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := client.Gather()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Gather returned an error. Error %s\n", err)
|
||||||
|
}
|
||||||
|
for i, s := range got {
|
||||||
|
if want[i] != s {
|
||||||
|
t.Fatalf("Got %s at index %d, want %s at index %d", s, i, want[i], i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client.client = &MockRCONClient{
|
||||||
|
Result: &rcon.Packet{
|
||||||
|
Body: "",
|
||||||
|
},
|
||||||
|
Err: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err = client.Gather()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Gather returned an error. Error %s\n", err)
|
||||||
|
}
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Fatalf("Expected empty slice of length %d, got slice of length %d", 0, len(got))
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue