├── .travis.yml ├── .gitignore ├── cmd_test.go ├── cmd.go ├── errors.go ├── LICENSE ├── README.md ├── client_test.go ├── integration_test.go ├── packet.go ├── mockserver_test.go └── client.go /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - 1.x 5 | - master 6 | 7 | install: 8 | - go get github.com/stretchr/testify/assert 9 | - go get -u gopkg.in/alecthomas/gometalinter.v1 10 | - gometalinter.v1 --install 11 | 12 | script: 13 | - go test -v -race ./... 14 | - gometalinter.v1 --cyclo-over=12 ./... 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.dll 4 | *.so 5 | *.dylib 6 | 7 | # Test binary, build with `go test -c` 8 | *.test 9 | 10 | # Output of the go coverage tool, specifically when used with LiteIDE 11 | *.out 12 | 13 | # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 14 | .glide/ 15 | -------------------------------------------------------------------------------- /cmd_test.go: -------------------------------------------------------------------------------- 1 | package source 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestCmd(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | cmd *Cmd 13 | expect string 14 | }{ 15 | {"status", NewCmd("status"), "status"}, 16 | {"echo", NewCmd("echo").WithArgs("test me"), "echo test me"}, 17 | } 18 | 19 | for _, tc := range tests { 20 | t.Run(tc.name, func(t *testing.T) { 21 | assert.Equal(t, tc.expect, tc.cmd.String()) 22 | }) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /cmd.go: -------------------------------------------------------------------------------- 1 | package source 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // Cmd represents a source rcon command. 9 | type Cmd struct { 10 | cmd string 11 | args []interface{} 12 | } 13 | 14 | // NewCmd creates a new Cmd. 15 | func NewCmd(cmd string) *Cmd { 16 | return &Cmd{cmd: cmd} 17 | } 18 | 19 | // WithArgs sets the command Args. 20 | func (c *Cmd) WithArgs(args ...interface{}) *Cmd { 21 | c.args = args 22 | return c 23 | } 24 | 25 | func (c *Cmd) String() string { 26 | args := append([]interface{}{c.cmd}, c.args...) 27 | // We use fmt.Sprintln + fmt.TrimSuffix as fmt.Sprintln guarantees all args 28 | // are space separated, which is what we want, where as fmt.Sprint doesn't. 29 | return strings.TrimSuffix(fmt.Sprintln(args...), "\n") 30 | } 31 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package source 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | var ( 9 | // ErrNilOption is returned by NewClient if an option is nil. 10 | ErrNilOption = errors.New("source: nil option") 11 | 12 | // ErrNonASCII is returned if a command with non-ASCII characters is attempted. 13 | ErrNonASCII = errors.New("source: non-ascii body") 14 | 15 | // ErrAuthFailure is returned if the client failed to authenticate. 16 | ErrAuthFailure = errors.New("source: authentication failure") 17 | ) 18 | 19 | // ErrMalformedResponse is returned if the response from the server is malformed. 20 | type ErrMalformedResponse string 21 | 22 | func (e ErrMalformedResponse) Error() string { 23 | return fmt.Sprintf("source: malformed response %v", string(e)) 24 | } 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2017, Multiplay 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Source [![Go Report Card](https://goreportcard.com/badge/github.com/multiplay/go-source)](https://goreportcard.com/report/github.com/multiplay/go-source) [![License](https://img.shields.io/badge/license-BSD-blue.svg)](https://github.com/multiplay/go-source/blob/master/LICENSE) [![GoDoc](https://godoc.org/github.com/multiplay/go-source?status.svg)](https://godoc.org/github.com/multiplay/go-source) [![Build Status](https://travis-ci.org/multiplay/go-source.svg?branch=master)](https://travis-ci.org/multiplay/go-source) 2 | 3 | go-source is a [Go](http://golang.org/) client for the [Source RCON Protocol](https://developer.valvesoftware.com/wiki/Source_RCON_Protocol). 4 | 5 | Features 6 | -------- 7 | * Full [Source RCON](https://developer.valvesoftware.com/wiki/Source_RCON_Protocol) Support. 8 | * [Multi-Packet Responses](https://developer.valvesoftware.com/wiki/Source_RCON_Protocol#Multiple-packet_Responses) Support. 9 | 10 | Supports 11 | -------- 12 | * [Valve](http://www.valvesoftware.com/) [Counter-Strike Global Offensive](http://steamcommunity.com/app/730) and others. 13 | * [Mojang](https://mojang.com/) [Minecraft](https://minecraft.net/). 14 | * [Chucklefish](https://chucklefish.org/) [Starbound](https://playstarbound.com/). 15 | 16 | Installation 17 | ------------ 18 | ```sh 19 | go get -u github.com/multiplay/go-source 20 | ``` 21 | 22 | Examples 23 | -------- 24 | 25 | Using go-source is simple just create a client, login and then send commands e.g. 26 | ```go 27 | package main 28 | 29 | import ( 30 | "log" 31 | 32 | "github.com/multiplay/go-source" 33 | ) 34 | 35 | func main() { 36 | c, err := source.NewClient("192.168.1.102:27015", source.Password("mypass")) 37 | if err != nil { 38 | log.Fatal(err) 39 | } 40 | defer c.Close() 41 | 42 | if s, err := c.Exec("status"); err != nil { 43 | log.Fatal(err) 44 | } else { 45 | log.Println("server status:", s) 46 | } 47 | } 48 | ``` 49 | 50 | Documentation 51 | ------------- 52 | - [GoDoc API Reference](http://godoc.org/github.com/multiplay/go-source). 53 | 54 | License 55 | ------- 56 | go-source is available under the [BSD 2-Clause License](https://opensource.org/licenses/BSD-2-Clause). 57 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package source 2 | 3 | import ( 4 | "errors" 5 | "net" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestClient(t *testing.T) { 13 | s := newServer(t) 14 | if s == nil { 15 | return 16 | } 17 | defer func() { 18 | assert.NoError(t, s.Close()) 19 | }() 20 | 21 | c, err := NewClient(s.Addr, Timeout(time.Second*2)) 22 | if !assert.NoError(t, err) { 23 | return 24 | } 25 | 26 | defer func() { 27 | assert.NoError(t, c.Close()) 28 | }() 29 | 30 | _, err = c.Exec("status") 31 | assert.NoError(t, err) 32 | 33 | _, err = c.ExecCmd(NewCmd("echo").WithArgs("my test")) 34 | assert.NoError(t, err) 35 | 36 | resp, err2 := c.Exec("invalid") 37 | assert.NoError(t, err2) 38 | assert.Equal(t, "unknown command 2:invalid", resp) 39 | } 40 | 41 | func TestClientNilOption(t *testing.T) { 42 | _, err := NewClient("", nil) 43 | if !assert.Error(t, err) { 44 | return 45 | } 46 | 47 | assert.Equal(t, ErrNilOption, err) 48 | } 49 | 50 | func TestClientOptionError(t *testing.T) { 51 | errBadOption := errors.New("bad option") 52 | _, err := NewClient("", func(c *Client) error { return errBadOption }) 53 | if !assert.Error(t, err) { 54 | return 55 | } 56 | 57 | assert.Equal(t, errBadOption, err) 58 | } 59 | 60 | func TestClientDisconnect(t *testing.T) { 61 | s := newServer(t) 62 | if s == nil { 63 | return 64 | } 65 | defer func() { 66 | assert.NoError(t, s.Close()) 67 | }() 68 | 69 | c, err := NewClient(s.Addr, Timeout(time.Second*2)) 70 | if !assert.NoError(t, err) { 71 | return 72 | } 73 | 74 | assert.NoError(t, c.Close()) 75 | 76 | _, err = c.Exec("version") 77 | assert.Error(t, err) 78 | } 79 | 80 | func TestClientWriteFail(t *testing.T) { 81 | s := newServer(t) 82 | if s == nil { 83 | return 84 | } 85 | defer func() { 86 | assert.NoError(t, s.Close()) 87 | }() 88 | 89 | c, err := NewClient(s.Addr, Timeout(time.Second*2)) 90 | if !assert.NoError(t, err) { 91 | return 92 | } 93 | assert.NoError(t, c.conn.(*net.TCPConn).CloseWrite()) 94 | 95 | _, err = c.Exec("version") 96 | assert.Error(t, err) 97 | } 98 | 99 | func TestClientDialFail(t *testing.T) { 100 | c, err := NewClient("127.0.0.1", Timeout(time.Nanosecond)) 101 | if assert.Error(t, err) { 102 | return 103 | } 104 | 105 | // Should never get here 106 | assert.NoError(t, c.Close()) 107 | } 108 | 109 | func TestClientFailConn(t *testing.T) { 110 | s := newServerStopped(t) 111 | if s == nil { 112 | return 113 | } 114 | s.failConn = true 115 | s.Start() 116 | defer func() { 117 | assert.NoError(t, s.Close()) 118 | }() 119 | 120 | c, err := NewClient(s.Addr, Timeout(time.Second)) 121 | if !assert.NoError(t, err) { 122 | return 123 | } 124 | 125 | assert.NoError(t, c.Close()) 126 | } 127 | -------------------------------------------------------------------------------- /integration_test.go: -------------------------------------------------------------------------------- 1 | // +build integration 2 | 3 | package source 4 | 5 | import ( 6 | "flag" 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | var ( 14 | serverAddress = flag.String("server-address", "127.0.0.1", "sets the servers address for integration tests") 15 | serverPassword = flag.String("server-password", "", "sets the rcon password for integration tests") 16 | serverFlavour = flag.String("server-flavour", "source", "configures the flavour of integration tests") 17 | ) 18 | 19 | type subtest struct { 20 | name string 21 | f func(t *testing.T) 22 | } 23 | 24 | func sourceTests(c *Client) []subtest { 25 | return []subtest{ 26 | {"source-echo", func(t *testing.T) { 27 | arg := "my test" 28 | r, err := c.ExecCmd(NewCmd("echo").WithArgs(arg)) 29 | assert.NoError(t, err) 30 | assert.Contains(t, r, arg) 31 | }}, 32 | {"source-status", func(t *testing.T) { 33 | r, err := c.Exec("status") 34 | assert.NoError(t, err) 35 | assert.NotEmpty(t, r) 36 | }}, 37 | } 38 | } 39 | 40 | func minecraftTests(c *Client) []subtest { 41 | return []subtest{ 42 | {"minecraft-help", func(t *testing.T) { 43 | r, err := c.ExecCmd(NewCmd("/help")) 44 | assert.NoError(t, err) 45 | assert.Contains(t, r, "Showing help") 46 | }}, 47 | {"minecraft-say", func(t *testing.T) { 48 | r, err := c.ExecCmd(NewCmd("/say").WithArgs("go-source test")) 49 | assert.NoError(t, err) 50 | assert.Empty(t, r) 51 | }}, 52 | } 53 | } 54 | 55 | func starboundTests(c *Client) []subtest { 56 | return []subtest{ 57 | {"starbound-help", func(t *testing.T) { 58 | r, err := c.ExecCmd(NewCmd("help")) 59 | assert.NoError(t, err) 60 | assert.Contains(t, r, "Basic commands") 61 | }}, 62 | {"starbound-echo", func(t *testing.T) { 63 | msg := "go-source test" 64 | r, err := c.ExecCmd(NewCmd("echo").WithArgs(msg)) 65 | assert.NoError(t, err) 66 | assert.Equal(t, r, msg) 67 | }}, 68 | } 69 | } 70 | 71 | func TestIntegration(t *testing.T) { 72 | opts := []func(*Client) error{Timeout(time.Second * 10)} 73 | if *serverPassword != "" { 74 | opts = append(opts, Password(*serverPassword)) 75 | } 76 | 77 | switch *serverFlavour { 78 | case "source": 79 | case "minecraft", "starbound": 80 | opts = append(opts, DisableMultiPacket()) 81 | default: 82 | t.Fatal("unsupported flavour", *serverFlavour) 83 | } 84 | 85 | c, err := NewClient(*serverAddress, opts...) 86 | if !assert.NoError(t, err) { 87 | return 88 | } 89 | defer func() { 90 | assert.NoError(t, c.Close()) 91 | }() 92 | 93 | var tests []subtest 94 | switch *serverFlavour { 95 | case "source": 96 | tests = sourceTests(c) 97 | case "minecraft": 98 | tests = minecraftTests(c) 99 | case "starbound": 100 | tests = starboundTests(c) 101 | } 102 | 103 | for _, tc := range tests { 104 | t.Run(tc.name, tc.f) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /packet.go: -------------------------------------------------------------------------------- 1 | package source 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | "io" 7 | ) 8 | 9 | const ( 10 | // responseValue is the packet type returned in response to an execCommand. 11 | responseValue = int32(0) 12 | 13 | // execCommand is the packet type which represents a command issued to the server by the client. 14 | execCommand = int32(2) 15 | 16 | // auth is the packet type which is used to authenticate the connection with the server. 17 | auth = int32(3) 18 | 19 | // authResponse is the packet type which represents the connections current auth status. 20 | authResponse = int32(2) 21 | ) 22 | 23 | // pkt represents an rcon packet 24 | type pkt struct { 25 | Size int32 26 | ID int32 27 | Type int32 28 | body []byte 29 | } 30 | 31 | // newPkt returns a new pkt for the given details. 32 | func newPkt(t, id int32, body string) *pkt { 33 | return &pkt{Type: t, Size: int32(len(body) + 10), ID: id, body: []byte(body)} 34 | } 35 | 36 | // Body returns the packet body as a string. 37 | func (p *pkt) Body() string { 38 | return string(p.body) 39 | } 40 | 41 | // WriteTo implements io.WriterTo. 42 | func (p *pkt) WriteTo(w io.Writer) (n int64, err error) { 43 | buf := bytes.NewBuffer(make([]byte, 0, p.Size+4)) 44 | 45 | // Size of the packet not including the size field itself. 46 | if err := binary.Write(buf, binary.LittleEndian, p.Size); err != nil { 47 | return 0, err 48 | } 49 | 50 | // ID 51 | if err := binary.Write(buf, binary.LittleEndian, p.ID); err != nil { 52 | return 0, err 53 | } 54 | 55 | // Type 56 | if err := binary.Write(buf, binary.LittleEndian, p.Type); err != nil { 57 | return 0, err 58 | } 59 | 60 | // Body + null terminator + empty string null terminator 61 | if _, err := buf.Write(append(p.body, 0x00, 0x00)); err != nil { 62 | return 0, err 63 | } 64 | 65 | return buf.WriteTo(w) 66 | } 67 | 68 | // ReadFrom implements io.ReaderFrom, reading a packet from r. 69 | func (p *pkt) ReadFrom(r io.Reader) (n int64, err error) { 70 | if err = binary.Read(r, binary.LittleEndian, &p.Size); err != nil { 71 | return n, err 72 | } 73 | n += 4 74 | if p.Size < 10 { 75 | return n, ErrMalformedResponse("size too small") 76 | } 77 | 78 | if err = binary.Read(r, binary.LittleEndian, &p.ID); err != nil { 79 | return n, err 80 | } 81 | n += 4 82 | 83 | if err = binary.Read(r, binary.LittleEndian, &p.Type); err != nil { 84 | return n, err 85 | } 86 | n += 4 87 | 88 | // We can't use ReadString(0x00) here as even though the spec says this 89 | // should be null terminated string, said string can actually include null 90 | // characters, which is the case in response to a responseValue packet. 91 | var i int32 92 | p.body = make([]byte, p.Size-8) 93 | for i < p.Size-8 { 94 | n2, err2 := r.Read(p.body[i:]) 95 | if err != nil { 96 | return n + int64(n2) + int64(i), err2 97 | } 98 | i += int32(n2) 99 | } 100 | n += int64(i) 101 | 102 | if !bytes.Equal(p.body[len(p.body)-2:], []byte{0x00, 0x00}) { 103 | return n, ErrMalformedResponse("invalid trailer") 104 | } 105 | p.body = p.body[0 : len(p.body)-2] 106 | 107 | return n, nil 108 | } 109 | -------------------------------------------------------------------------------- /mockserver_test.go: -------------------------------------------------------------------------------- 1 | package source 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "sync" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | var ( 13 | commands = map[string][]*pkt{ 14 | fmt.Sprintf("%v:echo test me", execCommand): {newPkt(responseValue, 0, "test me")}, 15 | fmt.Sprintf("%v:", responseValue): { 16 | newPkt(responseValue, 1, ""), 17 | newPkt(responseValue, 1, string(responseBody)), 18 | }, 19 | } 20 | ) 21 | 22 | // newLockListener creates a new listener on the local IP. 23 | func newLocalListener() (net.Listener, error) { 24 | l, err := net.Listen("tcp", "127.0.0.1:0") 25 | if err != nil { 26 | if l, err = net.Listen("tcp6", "[::1]:0"); err != nil { 27 | return nil, err 28 | } 29 | } 30 | return l, nil 31 | } 32 | 33 | // server is a mock source rcon server 34 | type server struct { 35 | Addr string 36 | Listener net.Listener 37 | 38 | t *testing.T 39 | conns map[net.Conn]struct{} 40 | done chan struct{} 41 | wg sync.WaitGroup 42 | failConn bool 43 | mtx sync.Mutex 44 | } 45 | 46 | // sconn represents a server connection 47 | type sconn struct { 48 | id int 49 | net.Conn 50 | } 51 | 52 | // newServer returns a running server or nil if an error occurred. 53 | func newServer(t *testing.T) *server { 54 | s := newServerStopped(t) 55 | s.Start() 56 | 57 | return s 58 | } 59 | 60 | // newServerStopped returns a stopped servers or nil if an error occurred. 61 | func newServerStopped(t *testing.T) *server { 62 | l, err := newLocalListener() 63 | if !assert.NoError(t, err) { 64 | return nil 65 | } 66 | 67 | s := &server{ 68 | Listener: l, 69 | conns: make(map[net.Conn]struct{}), 70 | done: make(chan struct{}), 71 | t: t, 72 | } 73 | s.Addr = s.Listener.Addr().String() 74 | return s 75 | } 76 | 77 | // Start starts the server. 78 | func (s *server) Start() { 79 | s.wg.Add(1) 80 | go s.serve() 81 | } 82 | 83 | // server processes incoming requests until signaled to stop with Close. 84 | func (s *server) serve() { 85 | defer s.wg.Done() 86 | for { 87 | conn, err := s.Listener.Accept() 88 | if err != nil { 89 | if s.running() { 90 | assert.NoError(s.t, err) 91 | } 92 | return 93 | } 94 | s.wg.Add(1) 95 | go s.handle(conn) 96 | } 97 | } 98 | 99 | // write writes msg to conn. 100 | func (s *server) write(conn net.Conn, id int32, pkts []*pkt) error { 101 | for _, p := range pkts { 102 | p.ID = id 103 | _, err := p.WriteTo(conn) 104 | if s.running() { 105 | assert.NoError(s.t, err) 106 | } 107 | if err != nil { 108 | return err 109 | } 110 | } 111 | 112 | return nil 113 | } 114 | 115 | // running returns true unless Close has been called, false otherwise. 116 | func (s *server) running() bool { 117 | select { 118 | case <-s.done: 119 | return false 120 | default: 121 | return true 122 | } 123 | } 124 | 125 | // handle handles a client connection. 126 | func (s *server) handle(conn net.Conn) { 127 | s.mtx.Lock() 128 | s.conns[conn] = struct{}{} 129 | s.mtx.Unlock() 130 | defer func() { 131 | s.closeConn(conn) 132 | s.wg.Done() 133 | }() 134 | 135 | if s.failConn { 136 | return 137 | } 138 | 139 | c := &sconn{Conn: conn} 140 | for { 141 | p := &pkt{} 142 | if _, err := p.ReadFrom(conn); err != nil { 143 | return 144 | } 145 | 146 | cmd := fmt.Sprintf("%v:%v", p.Type, p.Body()) 147 | resp, ok := commands[cmd] 148 | if !ok { 149 | resp = []*pkt{newPkt(responseValue, p.ID, fmt.Sprintf("unknown command %v", cmd))} 150 | } 151 | 152 | if err := s.write(c, p.ID, resp); err != nil { 153 | return 154 | } 155 | } 156 | } 157 | 158 | // closeConn closes a client connection and removes it from our map of connections. 159 | func (s *server) closeConn(conn net.Conn) { 160 | s.mtx.Lock() 161 | defer s.mtx.Unlock() 162 | conn.Close() // nolint: errcheck 163 | delete(s.conns, conn) 164 | } 165 | 166 | // Close cleanly shuts down the server. 167 | func (s *server) Close() error { 168 | close(s.done) 169 | err := s.Listener.Close() 170 | s.mtx.Lock() 171 | for c := range s.conns { 172 | if err2 := c.Close(); err2 != nil && err == nil { 173 | err = err2 174 | } 175 | } 176 | s.mtx.Unlock() 177 | s.wg.Wait() 178 | 179 | return err 180 | } 181 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | // Package source provides a client which can talk to game servers which 2 | // support the source RCON protocol: 3 | // https://developer.valvesoftware.com/wiki/Source_RCON_Protocol 4 | package source 5 | 6 | import ( 7 | "bufio" 8 | "bytes" 9 | "fmt" 10 | "net" 11 | "strings" 12 | "time" 13 | ) 14 | 15 | const ( 16 | // DefaultPort is the default source RCON port. 17 | DefaultPort = 27015 18 | 19 | // maxPkt is the maximum size of a response packet. 20 | maxPkt = 4096 21 | ) 22 | 23 | var ( 24 | // DefaultTimeout is the default read / write / dial timeout for Clients. 25 | DefaultTimeout = time.Second * 10 26 | 27 | // responseBody is the expected response body for the second response reply. 28 | responseBody = []byte{0x00, 0x01, 0x00, 0x00} 29 | ) 30 | 31 | // Client is a source rcon client. 32 | type Client struct { 33 | conn net.Conn 34 | addr string 35 | pwd string 36 | timeout time.Duration 37 | reader *bufio.Reader 38 | reqID int32 39 | read func(expectedID int32) (string, error) 40 | write func(pktType int32, body string) error 41 | } 42 | 43 | // Timeout sets read / write / dial timeout for a source rcon Client. 44 | func Timeout(timeout time.Duration) func(*Client) error { 45 | return func(c *Client) error { 46 | c.timeout = timeout 47 | return nil 48 | } 49 | } 50 | 51 | // Password sets authentication password for a source rcon Client. 52 | func Password(pwd string) func(*Client) error { 53 | return func(c *Client) error { 54 | c.pwd = pwd 55 | return nil 56 | } 57 | } 58 | 59 | // DisableMultiPacket disables multi-packet support, which not all servers support. 60 | // This is required for Minecraft and Starbound servers. 61 | func DisableMultiPacket() func(*Client) error { 62 | return func(c *Client) error { 63 | c.read = c.readSingle 64 | c.write = c.writePkt 65 | return nil 66 | } 67 | } 68 | 69 | // NewClient returns a new source rcon client connected to addr. 70 | // If addr doesn't include a port the DefaultPort will be used. 71 | func NewClient(addr string, options ...func(c *Client) error) (c *Client, err error) { 72 | c = &Client{timeout: DefaultTimeout, addr: addr} 73 | c.read = c.readMulti 74 | c.write = c.writeMulti 75 | for _, f := range options { 76 | if f == nil { 77 | return nil, ErrNilOption 78 | } 79 | if err = f(c); err != nil { 80 | return nil, err 81 | } 82 | } 83 | 84 | if !strings.Contains(c.addr, ":") { 85 | c.addr = fmt.Sprintf("%v:%v", c.addr, DefaultPort) 86 | } 87 | 88 | if c.conn, err = net.DialTimeout("tcp", c.addr, c.timeout); err != nil { 89 | return nil, err 90 | } 91 | 92 | c.reader = bufio.NewReaderSize(c.conn, maxPkt) 93 | 94 | if err = c.auth(); err != nil { 95 | c.conn.Close() // nolint: errcheck 96 | return nil, err 97 | } 98 | 99 | return c, nil 100 | } 101 | 102 | // auth authenticates with the server if a password is set, otherwise its a no-op. 103 | func (c *Client) auth() error { 104 | if c.pwd == "" { 105 | return nil 106 | } 107 | 108 | if err := c.writePkt(auth, c.pwd); err != nil { 109 | return err 110 | } 111 | 112 | p, err := c.readPkt() 113 | if err != nil { 114 | return err 115 | } 116 | 117 | if p.ID != 0 { 118 | return ErrAuthFailure 119 | } 120 | 121 | // The official spec says we should get a responseValue followed by authResponse 122 | // however Minecraft doesn't send the responseValue packet so we deal with that 123 | // case too. 124 | switch { 125 | case p.Type == responseValue: 126 | if p, err = c.readPkt(); err != nil { 127 | return err 128 | } 129 | 130 | if p.ID != 0 || p.Type != authResponse { 131 | return ErrAuthFailure 132 | } 133 | case p.Type != authResponse: 134 | return ErrAuthFailure 135 | } 136 | 137 | return nil 138 | } 139 | 140 | // Exec creates a new Cmd from cmd and calls ExecCmd with it. 141 | // If cmd contains non-ASCII characters it returns ErrNonASCII. 142 | func (c *Client) Exec(cmd string) (string, error) { 143 | return c.ExecCmd(NewCmd(cmd)) 144 | } 145 | 146 | // ExecCmd executes cmd on the server and returns the response. 147 | // If cmd contains non-ASCII characters it returns ErrNonASCII. 148 | func (c *Client) ExecCmd(cmd *Cmd) (resp string, err error) { 149 | body := cmd.String() 150 | 151 | // Validate body is ASCII only 152 | for _, r := range body { 153 | if r >= 0x80 { 154 | return "", ErrNonASCII 155 | } 156 | } 157 | 158 | expectedID := c.reqID 159 | if err = c.write(execCommand, body); err != nil { 160 | return "", err 161 | } 162 | 163 | return c.read(expectedID) 164 | } 165 | 166 | // Close closes the connection to the server. 167 | func (c *Client) Close() error { 168 | return c.conn.Close() 169 | } 170 | 171 | // readSingle reads a single packet, validates its ID matches expectedID and returns its body. 172 | func (c *Client) readSingle(expectedID int32) (string, error) { 173 | p, err := c.readPkt() 174 | if err != nil { 175 | return "", err 176 | } 177 | 178 | if p.ID != expectedID { 179 | return "", ErrMalformedResponse(fmt.Sprintf("unexpected packet id %v", p.ID)) 180 | } 181 | 182 | return p.Body(), nil 183 | } 184 | 185 | // readMulti reads responses packets from the server, combines multi-packet 186 | // response bodies and returns the result. 187 | func (c *Client) readMulti(expectedID int32) (body string, err error) { 188 | var buf bytes.Buffer 189 | var cnt int 190 | for { 191 | p, err := c.readPkt() 192 | if err != nil { 193 | return "", err 194 | } 195 | if p.Type != responseValue { 196 | return "", ErrMalformedResponse("unexpected type") 197 | } 198 | 199 | switch p.ID { 200 | case expectedID: 201 | // Command response packets, one or more expected. 202 | if _, err = buf.Write(p.body); err != nil { 203 | return "", err 204 | } 205 | case expectedID + 1: 206 | // Response response packets, exactly two expected. 207 | cnt++ 208 | switch cnt { 209 | case 1: 210 | // Echoed response packet. 211 | if len(p.body) != 0 { 212 | return "", ErrMalformedResponse("non-empty body") 213 | } 214 | case 2: 215 | // Response packet response. 216 | if !bytes.Equal(p.body, responseBody) { 217 | return "", ErrMalformedResponse(fmt.Sprintf("unexpected body %q", p.Body())) 218 | } 219 | return buf.String(), nil 220 | } 221 | default: 222 | return "", ErrMalformedResponse(fmt.Sprintf("unexpected packet id %v", p.ID)) 223 | } 224 | } 225 | } 226 | 227 | // readPkt reads a single packet from the server and returns it. 228 | func (c *Client) readPkt() (*pkt, error) { 229 | if err := c.setDeadline(); err != nil { 230 | return nil, err 231 | } 232 | 233 | p := &pkt{} 234 | if _, err := p.ReadFrom(c.reader); err != nil { 235 | return nil, err 236 | } 237 | 238 | return p, nil 239 | } 240 | 241 | // writeMulti writes a packet with type t and body followed by a empty body 242 | // responseValue type packet, so that we can easily decode multi-packet responses. 243 | // https://developer.valvesoftware.com/wiki/Source_RCON_Protocol#Multiple-packet_Responses 244 | func (c *Client) writeMulti(pktType int32, body string) error { 245 | if err := c.writePkt(pktType, body); err != nil { 246 | return err 247 | } 248 | 249 | // Now send an empty server response packet which will be echoed back, allowing 250 | // us to easily determine if we are processing a multi packet response. 251 | return c.writePkt(responseValue, "") 252 | } 253 | 254 | // writePkt writes a single packet to the server. 255 | func (c *Client) writePkt(pktType int32, body string) error { 256 | p := newPkt(pktType, c.reqID, body) 257 | c.reqID++ 258 | 259 | if err := c.setDeadline(); err != nil { 260 | return err 261 | } 262 | 263 | _, err := p.WriteTo(c.conn) 264 | return err 265 | } 266 | 267 | // setDeadline updates the deadline on the connection based on the clients configured timeout. 268 | func (c *Client) setDeadline() error { 269 | return c.conn.SetDeadline(time.Now().Add(c.timeout)) 270 | } 271 | --------------------------------------------------------------------------------