├── .github └── workflows │ └── build.yml ├── .gitignore ├── .golangci.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── go.mod ├── go.sum ├── option.go ├── packet.go ├── packet_test.go ├── rcon.go ├── rcon_test.go └── rcontest ├── context.go ├── example_test.go ├── option.go ├── server.go └── server_test.go /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | branches: 8 | - master 9 | - develop 10 | pull_request: 11 | branches: [ master ] 12 | 13 | jobs: 14 | build: 15 | name: Build 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Set up Go 1.x 19 | uses: actions/setup-go@v5 20 | with: 21 | go-version: 1.23.3 22 | 23 | - name: Check out code into the Go module directory 24 | uses: actions/checkout@v4 25 | 26 | - name: Lint 27 | uses: golangci/golangci-lint-action@v3 28 | with: 29 | # Required: the version of golangci-lint is required and must be specified without patch version: we always use the latest patch version. 30 | version: v1.62.0 31 | 32 | # Optional: working directory, useful for monorepos 33 | # working-directory: somedir 34 | 35 | # Optional: golangci-lint command line arguments. 36 | # args: --issues-exit-code=0 37 | args: --config=.golangci.yml 38 | 39 | # Optional: show only new issues if it's a pull request. The default value is `false`. 40 | only-new-issues: false 41 | 42 | - name: Get dependencies 43 | run: | 44 | go get -v -t -d ./... 45 | 46 | - name: Test 47 | run: go test -v ./... 48 | 49 | - name: Update coverage report 50 | uses: ncruces/go-coverage-report@main 51 | 52 | - name: Build 53 | run: go build -v . 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vscode/ 3 | vendor/ 4 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | output: 2 | formats: 3 | - format: colored-line-number 4 | print-issued-lines: true 5 | print-linter-name: true 6 | 7 | # SEE: https://golangci-lint.run/usage/configuration/ 8 | linters-settings: 9 | dupl: 10 | # tokens count to trigger issue, 150 by default 11 | threshold: 100 12 | errcheck: 13 | # report about not checking of errors in type assertions: `a := b.(MyStruct)`; 14 | # default is false: such cases aren't reported by default. 15 | check-type-assertions: false 16 | # report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`; 17 | # default is false: such cases aren't reported by default. 18 | check-blank: false 19 | funlen: 20 | # default is 60 21 | lines: 60 22 | # default is 40 23 | statements: 40 24 | gocognit: 25 | # minimal code complexity to report, 30 by default (but we recommend 10-20) 26 | min-complexity: 15 27 | goconst: 28 | # minimal length of string constant, 3 by default 29 | min-len: 3 30 | # minimal occurrences count to trigger, 3 by default 31 | min-occurrences: 2 32 | gocritic: 33 | enabled-tags: 34 | - performance 35 | - style 36 | - experimental 37 | disabled-checks: 38 | - paramTypeCombine 39 | # - whyNoLint 40 | # - commentedOutCode 41 | gocyclo: 42 | # minimal code complexity to report, 30 by default (but we recommend 10-20) 43 | min-complexity: 15 44 | cyclop: 45 | max-complexity: 15 46 | godox: 47 | keywords: 48 | - "BUG" 49 | - "FIXME" 50 | # - "TODO" 51 | goimports: 52 | # put imports beginning with prefix after 3rd-party packages; 53 | # it's a comma-separated list of prefixes 54 | local-prefixes: github.com/golangci/golangci-lint 55 | lll: 56 | line-length: 120 # 120 is default 57 | misspell: 58 | locale: US 59 | nakedret: 60 | # make an issue if func has more lines of code than this setting and it has naked returns; default is 30 61 | max-func-lines: 30 62 | unparam: 63 | # Inspect exported functions, default is false. Set to true if no external program/library imports your code. 64 | # XXX: if you enable this setting, unparam will report a lot of false-positives in text editors: 65 | # if it's called for subdir of a project it can't find external interfaces. All text editor integrations 66 | # with golangci-lint call it on a directory with the changed file. 67 | check-exported: false 68 | whitespace: 69 | multi-if: false # Enforces newlines (or comments) after every multi-line if statement 70 | multi-func: false # Enforces newlines (or comments) after every multi-line function signature 71 | wsl: 72 | # If true append is only allowed to be cuddled if appending value is 73 | # matching variables, fields or types on line above. Default is true. 74 | strict-append: true 75 | # Allow calls and assignments to be cuddled as long as the lines have any 76 | # matching variables, fields or types. Default is true. 77 | allow-assign-and-call: true 78 | # Allow multiline assignments to be cuddled. Default is true. 79 | allow-multiline-assign: true 80 | # Allow declarations (var) to be cuddled. 81 | allow-cuddle-declarations: true 82 | # Allow trailing comments in ending of blocks 83 | allow-trailing-comment: true 84 | # Force newlines in end of case at this limit (0 = never). 85 | force-case-trailing-whitespace: 0 86 | varnamelen: 87 | # The longest distance, in source lines, that is being considered a "small scope." (defaults to 5) 88 | # Variables used in at most this many lines will be ignored. 89 | max-distance: 5 90 | # The minimum length of a variable's name that is considered "long." (defaults to 3) 91 | # Variable names that are at least this long will be ignored. 92 | min-name-length: 3 93 | # Check method receivers. (defaults to false) 94 | check-receiver: false 95 | # Check named return values. (defaults to false) 96 | check-return: false 97 | # Check type parameters. (defaults to false) 98 | check-type-param: false 99 | # Ignore "ok" variables that hold the bool return value of a type assertion. (defaults to false) 100 | ignore-type-assert-ok: false 101 | # Ignore "ok" variables that hold the bool return value of a map index. (defaults to false) 102 | ignore-map-index-ok: false 103 | # Ignore "ok" variables that hold the bool return value of a channel receive. (defaults to false) 104 | ignore-chan-recv-ok: false 105 | # Optional list of variable names that should be ignored completely. (defaults to empty list) 106 | ignore-names: 107 | - err 108 | # Optional list of variable declarations that should be ignored completely. (defaults to empty list) 109 | # Entries must be in one of the following forms (see below for examples): 110 | # - for variables, parameters, named return values, method receivers, or type parameters: 111 | # ( can also be a pointer/slice/map/chan/...) 112 | # - for constants: const 113 | ignore-decls: 114 | - t testing.T 115 | - f *foo.Bar 116 | - e error 117 | - i int 118 | - const C 119 | - T any 120 | - m map[string]int 121 | - x int 122 | - y int 123 | - w io.Writer 124 | - r io.Reader 125 | - i int64 126 | - f *os.File 127 | - m int 128 | - n int64 129 | - i int32 130 | - c *Context 131 | 132 | linters: 133 | enable-all: true 134 | disable: 135 | - exportloopref # is deprecated (since v1.60.2) 136 | - dupword 137 | - wrapcheck 138 | - exhaustruct # mad linter 139 | - depguard 140 | - mnd 141 | 142 | issues: 143 | exclude: 144 | - "don't use ALL_CAPS in Go names; use CamelCase" # revive 145 | - "ST1003: should not use ALL_CAPS in Go names; use CamelCase instead" # stylecheck 146 | - "DefaultSettings`? is a global variable" # gochecknoglobals 147 | exclude-dirs: 148 | - vendor/ 149 | exclude-files: 150 | - ".*_test.go$" 151 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | **ATTN**: This project uses [semantic versioning](http://semver.org/). 5 | 6 | ## [Unreleased] 7 | ### Added 8 | - Added maxCommandLen setting and SetMaxCommandLen function. 9 | 10 | ## [v1.4.0] - 2024-11-16 11 | ### Fixed 12 | - Minor fixes in packet package. 13 | - Fixed Go Coverage badge. 14 | 15 | ### Updated 16 | - Updated Golang to 1.23.3 version. 17 | - Updated golangci linter to 1.62.0 version. 18 | 19 | ### Added 20 | - Added `Open` function as `Dial` alternative for create RCON connection with an existing net.Conn connection. 21 | 22 | ## [v1.3.5] - 2024-02-03 23 | ### Updated 24 | - Updated Golang to 1.21.6 version. 25 | - Updated golangci linter to 1.55.2 version. 26 | 27 | ## [v1.3.4] - 2022-11-12 28 | ### Fixed 29 | - Minor fixes in packet package. 30 | 31 | ### Updated 32 | - Updated Golang version to 1.19. 33 | - Updated golangci linter to 1.50.1 version. 34 | 35 | ## [v1.3.3] - 2022-05-16 36 | ### Fixed 37 | - Added "response from not rcon server" error on auth request (Re fixed panic: runtime error: makeslice: len out of range in rcon.Dial #5). 38 | 39 | ## [v1.3.2] - 2022-05-16 40 | ### Fixed 41 | - Fixed panic: runtime error: makeslice: len out of range in rcon.Dial 42 | 43 | ### Updated 44 | - Updated golangci linter to 1.42.1 version 45 | 46 | ## [v1.3.1] - 2021-01-06 47 | ### Updated 48 | - Updated golangci linter to 1.33 version 49 | 50 | ### Changed 51 | - Changed errors handling - added wrapping. 52 | 53 | ## [v1.3.0] - 2020-12-02 54 | ### Fixed 55 | - Fixed wrong number of bytes written in Packet WriteTo function. 56 | 57 | ### Added 58 | - Added rcontest Server for mocking RCON connections. 59 | 60 | ## [v1.2.4] - 2020-11-14 61 | ### Added 62 | - Added the ability to run tests on a real Project Zomboid server. To do this, set environment variables 63 | `TEST_PZ_SERVER=true`, `TEST_PZ_SERVER_ADDR` and `TEST_PZ_SERVER_PASSWORD` with address and password from Project Zomboid 64 | remote console. 65 | - Added the ability to run tests on a real Rust server. To do this, set environment variables `TEST_RUST_SERVER=true`, 66 | `TEST_RUST_SERVER_ADDR` and `TEST_RUST_SERVER_PASSWORD` with address and password from Rust remote console. 67 | - Added invalid padding test. 68 | 69 | ### Changed 70 | - Changed CI workflows and related badges. Integration with Travis-CI was changed to GitHub actions workflow. Golangci-lint 71 | job was joined with tests workflow. 72 | 73 | ## [v1.2.3] - 2020-10-20 74 | ### Fixed 75 | - Fixed read/write deadline. The deadline was started from the moment the connection was established and was not updated 76 | after the command was sent. 77 | 78 | ## [v1.2.2] - 2020-10-18 79 | ### Added 80 | - Added one more workaround for Rust server. When sent command "Say" there is no response data from server 81 | with packet.ID = SERVERDATA_EXECCOMMAND_ID, only previous console message that command was received with 82 | packet.ID = -1, therefore, forcibly set packet.ID to SERVERDATA_EXECCOMMAND_ID. 83 | 84 | ## [v1.2.1] - 2020-10-06 85 | ### Added 86 | - Added authentication failed test. 87 | 88 | ### Changed 89 | - Updated Golang version to 1.15. 90 | 91 | ## [v1.2.0] - 2020-07-10 92 | ### Added 93 | - Added options to Dial. It is possible to set timeout and deadline settings. 94 | 95 | ### Fixed 96 | - Change `SERVERDATA_AUTH_ID` and `SERVERDATA_EXECCOMMAND_ID` from 42 to 0. Conan Exiles has a bug because of which it 97 | always responds 42 regardless of the value of the request ID. This is no longer relevant, so the values have been 98 | changed. 99 | 100 | ### Changed 101 | - Renamed `DefaultTimeout` const to `DefaultDeadline` 102 | - Changed default timeouts from 10 seconds to 5 seconds 103 | 104 | ## [v1.1.2] - 2020-05-13 105 | ### Added 106 | - Added go modules (go 1.13). 107 | - Added golangci.yml linter config. To run linter use `golangci-lint run` command. 108 | - Added CHANGELOG.md. 109 | - Added more tests. 110 | 111 | ## v1.0.0 - 2019-07-27 112 | ### Added 113 | - Initial implementation. 114 | 115 | [Unreleased]: https://github.com/gorcon/rcon/compare/v1.4.0...HEAD 116 | [v1.4.0]: https://github.com/gorcon/rcon/compare/v1.3.5...v1.4.0 117 | [v1.3.5]: https://github.com/gorcon/rcon/compare/v1.3.4...v1.3.5 118 | [v1.3.4]: https://github.com/gorcon/rcon/compare/v1.3.3...v1.3.4 119 | [v1.3.3]: https://github.com/gorcon/rcon/compare/v1.3.2...v1.3.3 120 | [v1.3.2]: https://github.com/gorcon/rcon/compare/v1.3.1...v1.3.2 121 | [v1.3.1]: https://github.com/gorcon/rcon/compare/v1.3.0...v1.3.1 122 | [v1.3.0]: https://github.com/gorcon/rcon/compare/v1.2.4...v1.3.0 123 | [v1.2.4]: https://github.com/gorcon/rcon/compare/v1.2.3...v1.2.4 124 | [v1.2.3]: https://github.com/gorcon/rcon/compare/v1.2.2...v1.2.3 125 | [v1.2.2]: https://github.com/gorcon/rcon/compare/v1.2.1...v1.2.2 126 | [v1.2.1]: https://github.com/gorcon/rcon/compare/v1.2.0...v1.2.1 127 | [v1.2.0]: https://github.com/gorcon/rcon/compare/v1.1.2...v1.2.0 128 | [v1.1.2]: https://github.com/gorcon/rcon/compare/v1.0.0...v1.1.2 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Pavel Korotkiy (outdead) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rcon 2 | [![GitHub Build](https://github.com/gorcon/rcon/workflows/build/badge.svg)](https://github.com/gorcon/rcon/actions) 3 | [![Go Coverage](https://github.com/gorcon/rcon/wiki/coverage.svg)](https://raw.githack.com/wiki/gorcon/rcon/coverage.html) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/gorcon/rcon)](https://goreportcard.com/report/github.com/gorcon/rcon) 5 | [![GoDoc](https://img.shields.io/badge/godoc-reference-blue.svg)](https://godoc.org/github.com/gorcon/rcon) 6 | 7 | Source RCON Protocol implementation in Go. 8 | 9 | ## Protocol Specifications 10 | RCON Protocol described in the [valve documentation](https://developer.valvesoftware.com/wiki/Source_RCON_Protocol). 11 | 12 | ## Supported Games 13 | Works for any game using the [Source RCON Protocol](https://developer.valvesoftware.com/wiki/Source_RCON_Protocol). Tested on: 14 | 15 | * [Project Zomboid](https://store.steampowered.com/app/108600) 16 | * [Conan Exiles](https://store.steampowered.com/app/440900) 17 | * [Rust](https://store.steampowered.com/app/252490) (add +rcon.web 0 to the args when starting the server) 18 | * [ARK: Survival Evolved](https://store.steampowered.com/app/346110) 19 | * [Counter-Strike: Global Offensive](https://store.steampowered.com/app/730) 20 | * [Minecraft](https://www.minecraft.net) 21 | * [Palworld](https://store.steampowered.com/app/1623730/Palworld/) 22 | * [Factorio](https://www.factorio.com/) (start the server with `--rcon-bind`/`--rcon-port` and `--rcon-password` args) 23 | 24 | Open pull request if you have successfully used a package with another game with rcon support and add it to the list. 25 | 26 | ## Install 27 | ```text 28 | go get github.com/gorcon/rcon 29 | ``` 30 | 31 | See [Changelog](CHANGELOG.md) for release details. 32 | 33 | ## Usage 34 | ```go 35 | package main 36 | 37 | import ( 38 | "fmt" 39 | "log" 40 | 41 | "github.com/gorcon/rcon" 42 | ) 43 | 44 | func main() { 45 | conn, err := rcon.Dial("127.0.0.1:16260", "password") 46 | if err != nil { 47 | log.Fatal(err) 48 | } 49 | defer conn.Close() 50 | 51 | response, err := conn.Execute("help") 52 | if err != nil { 53 | log.Fatal(err) 54 | } 55 | 56 | fmt.Println(response) 57 | } 58 | ``` 59 | 60 | ### With an existing net.Conn 61 | If you wish to initialize a RCON connection with an already initialized net.Conn, you can use the `Open` function: 62 | ```go 63 | package main 64 | 65 | import ( 66 | "fmt" 67 | "log" 68 | "net" 69 | 70 | "github.com/gorcon/rcon" 71 | ) 72 | 73 | func main() { 74 | netConn, err := net.Dial("tcp", "127.0.0.1:16260") 75 | if err != nil { 76 | // Failed to open TCP connection to the server. 77 | log.Fatalf("expected nil got error: %s", err) 78 | } 79 | defer netConn.Close() 80 | 81 | conn, err := rcon.Open(netConn, "password") 82 | if err != nil { 83 | log.Fatal(err) 84 | } 85 | defer conn.Close() 86 | 87 | response, err := conn.Execute("help") 88 | if err != nil { 89 | log.Fatal(err) 90 | } 91 | 92 | fmt.Println(response) 93 | } 94 | ``` 95 | 96 | ## Requirements 97 | Go 1.15 or higher 98 | 99 | ## Contribute 100 | Contributions are more than welcome! 101 | 102 | If you think that you have found a bug, create an issue and publish the minimum amount of code triggering the bug, so 103 | it can be reproduced. 104 | 105 | If you want to fix the bug then you can create a pull request. If possible, write a test that will cover this bug. 106 | 107 | ## License 108 | MIT License, see [LICENSE](LICENSE) 109 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/gorcon/rcon 2 | 3 | go 1.23 4 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gorcon/rcon/24392bd410da25da875bbe406bb3f0ea43d67b41/go.sum -------------------------------------------------------------------------------- /option.go: -------------------------------------------------------------------------------- 1 | package rcon 2 | 3 | import "time" 4 | 5 | // Settings contains option to Conn. 6 | type Settings struct { 7 | dialTimeout time.Duration 8 | deadline time.Duration 9 | maxCommandLen int 10 | } 11 | 12 | // DefaultSettings provides default deadline settings to Conn. 13 | var DefaultSettings = Settings{ 14 | dialTimeout: DefaultDialTimeout, 15 | deadline: DefaultDeadline, 16 | maxCommandLen: DefaultMaxCommandLen, 17 | } 18 | 19 | // Option allows to inject settings to Settings. 20 | type Option func(s *Settings) 21 | 22 | // SetDialTimeout injects dial Timeout to Settings. 23 | func SetDialTimeout(timeout time.Duration) Option { 24 | return func(s *Settings) { 25 | s.dialTimeout = timeout 26 | } 27 | } 28 | 29 | // SetDeadline injects read/write Timeout to Settings. 30 | func SetDeadline(timeout time.Duration) Option { 31 | return func(s *Settings) { 32 | s.deadline = timeout 33 | } 34 | } 35 | 36 | // SetMaxCommandLen injects maxCommandLen to Settings. 37 | func SetMaxCommandLen(maxCommandLen int) Option { 38 | return func(s *Settings) { 39 | s.maxCommandLen = maxCommandLen 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packet.go: -------------------------------------------------------------------------------- 1 | package rcon 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | "fmt" 7 | "io" 8 | ) 9 | 10 | // Packet sizes definitions. 11 | const ( 12 | PacketPaddingSize int32 = 2 // Size of Packet's padding. 13 | PacketHeaderSize int32 = 8 // Size of Packet's header. 14 | 15 | MinPacketSize = PacketPaddingSize + PacketHeaderSize 16 | MaxPacketSize = 4096 + MinPacketSize 17 | ) 18 | 19 | // Packet is a rcon packet. Both requests and responses are sent as 20 | // TCP packets. Their payload follows the following basic structure. 21 | type Packet struct { 22 | // The packet size field is a 32-bit little endian integer, representing 23 | // the length of the request in bytes. Note that the packet size field 24 | // itself is not included when determining the size of the packet, 25 | // so the value of this field is always 4 less than the packet's actual 26 | // length. The minimum possible value for packet size is 10. 27 | // The maximum possible value of packet size is 4096. 28 | // If the response is too large to fit into one packet, it will be split 29 | // and sent as multiple packets. 30 | Size int32 31 | 32 | // The packet id field is a 32-bit little endian integer chosen by the 33 | // client for each request. It may be set to any positive integer. 34 | // When the RemoteServer responds to the request, the response packet 35 | // will have the same packet id as the original request (unless it is 36 | // a failed SERVERDATA_AUTH_RESPONSE packet). 37 | // It need not be unique, but if a unique packet id is assigned, 38 | // it can be used to match incoming responses to their corresponding requests. 39 | ID int32 40 | 41 | // The packet type field is a 32-bit little endian integer, which indicates 42 | // the purpose of the packet. Its value will always be either 0, 2, or 3, 43 | // depending on which of the following request/response types the packet 44 | // represents: 45 | // SERVERDATA_AUTH = 3, 46 | // SERVERDATA_AUTH_RESPONSE = 2, 47 | // SERVERDATA_EXECCOMMAND = 2, 48 | // SERVERDATA_RESPONSE_VALUE = 0. 49 | Type int32 50 | 51 | // The packet body field is a null-terminated string encoded in ASCII 52 | // (i.e. ASCIIZ). Depending on the packet type, it may contain either the 53 | // RCON MockPassword for the RemoteServer, the command to be executed, 54 | // or the RemoteServer's response to a request. 55 | body []byte 56 | } 57 | 58 | // NewPacket creates and initializes a new Packet using packetType, 59 | // packetID and body as its initial contents. NewPacket is intended to 60 | // calculate packet size from body length and 10 bytes for rcon headers 61 | // and termination strings. 62 | func NewPacket(packetType int32, packetID int32, body string) *Packet { 63 | size := len([]byte(body)) + int(PacketHeaderSize+PacketPaddingSize) 64 | 65 | return &Packet{ 66 | Size: int32(size), //nolint:gosec // No matter 67 | Type: packetType, 68 | ID: packetID, 69 | body: []byte(body), 70 | } 71 | } 72 | 73 | // Body returns packet bytes body as a string. 74 | func (packet *Packet) Body() string { 75 | return string(packet.body) 76 | } 77 | 78 | // WriteTo implements io.WriterTo for write a packet to w. 79 | func (packet *Packet) WriteTo(w io.Writer) (int64, error) { 80 | buffer := bytes.NewBuffer(make([]byte, 0, packet.Size+4)) 81 | 82 | _ = binary.Write(buffer, binary.LittleEndian, packet.Size) 83 | _ = binary.Write(buffer, binary.LittleEndian, packet.ID) 84 | _ = binary.Write(buffer, binary.LittleEndian, packet.Type) 85 | 86 | // Write command body, null terminated ASCII string and an empty ASCIIZ string. 87 | buffer.Write(append(packet.body, 0x00, 0x00)) 88 | 89 | return buffer.WriteTo(w) 90 | } 91 | 92 | // ReadFrom implements io.ReaderFrom for read a packet from r. 93 | func (packet *Packet) ReadFrom(r io.Reader) (int64, error) { 94 | var n int64 95 | 96 | if err := binary.Read(r, binary.LittleEndian, &packet.Size); err != nil { 97 | return n, fmt.Errorf("rcon: read packet size: %w", err) 98 | } 99 | 100 | n += 4 101 | 102 | if packet.Size < MinPacketSize { 103 | return n, ErrResponseTooSmall 104 | } 105 | 106 | if err := binary.Read(r, binary.LittleEndian, &packet.ID); err != nil { 107 | return n, fmt.Errorf("rcon: read packet id: %w", err) 108 | } 109 | 110 | n += 4 111 | 112 | if err := binary.Read(r, binary.LittleEndian, &packet.Type); err != nil { 113 | return n, fmt.Errorf("rcon: read packet type: %w", err) 114 | } 115 | 116 | n += 4 117 | 118 | // String can actually include null characters which is the case in 119 | // response to a SERVERDATA_RESPONSE_VALUE packet. 120 | packet.body = make([]byte, packet.Size-PacketHeaderSize) 121 | 122 | var i int64 123 | for i < int64(packet.Size-PacketHeaderSize) { 124 | var m int 125 | var err error 126 | 127 | if m, err = r.Read(packet.body[i:]); err != nil { 128 | return n + int64(m) + i, fmt.Errorf("rcon: %w", err) 129 | } 130 | 131 | i += int64(m) 132 | } 133 | 134 | n += i 135 | 136 | // Remove null terminated strings from response body. 137 | if !bytes.Equal(packet.body[len(packet.body)-int(PacketPaddingSize):], []byte{0x00, 0x00}) { 138 | return n, ErrInvalidPacketPadding 139 | } 140 | 141 | packet.body = packet.body[0 : len(packet.body)-int(PacketPaddingSize)] 142 | 143 | return n, nil 144 | } 145 | -------------------------------------------------------------------------------- /packet_test.go: -------------------------------------------------------------------------------- 1 | package rcon 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | "errors" 7 | "io" 8 | "testing" 9 | ) 10 | 11 | func TestNewPacket(t *testing.T) { 12 | body := []byte("testdata") 13 | packet := NewPacket(SERVERDATA_RESPONSE_VALUE, 42, string(body)) 14 | 15 | if packet.Body() != string(body) { 16 | t.Errorf("%q, want %q", packet.Body(), body) 17 | } 18 | 19 | want := int32(len([]byte(body))) + PacketHeaderSize + PacketPaddingSize 20 | if packet.Size != want { 21 | t.Errorf("got %d, want %d", packet.Size, want) 22 | } 23 | } 24 | 25 | func TestPacket_WriteTo(t *testing.T) { 26 | t.Run("check bytes written", func(t *testing.T) { 27 | body := []byte("testdata") 28 | packet := NewPacket(SERVERDATA_RESPONSE_VALUE, 42, string(body)) 29 | 30 | var buffer bytes.Buffer 31 | n, err := packet.WriteTo(&buffer) 32 | if err != nil { 33 | t.Fatal(err) 34 | } 35 | 36 | wantN := packet.Size + 4 37 | if n != int64(wantN) { 38 | t.Errorf("got %d, want %d", n, int64(wantN)) 39 | } 40 | }) 41 | } 42 | 43 | func TestPacket_ReadFrom(t *testing.T) { 44 | t.Run("check read", func(t *testing.T) { 45 | body := []byte("testdata") 46 | packetWant := NewPacket(SERVERDATA_RESPONSE_VALUE, 42, string(body)) 47 | 48 | var buffer bytes.Buffer 49 | nWant, err := packetWant.WriteTo(&buffer) 50 | if err != nil { 51 | t.Fatal(err) 52 | } 53 | 54 | packetGot := new(Packet) 55 | nGot, err := packetGot.ReadFrom(&buffer) 56 | if err != nil { 57 | t.Fatal(err) 58 | } 59 | 60 | if nGot != nWant { 61 | t.Fatalf("got %d, want %d", nGot, nWant) 62 | } 63 | 64 | if packetGot.Body() != packetWant.Body() { 65 | t.Fatalf("got %q, want %q", packetGot.body, packetWant.body) 66 | } 67 | }) 68 | 69 | t.Run("EOF", func(t *testing.T) { 70 | var buffer bytes.Buffer 71 | 72 | packetGot := new(Packet) 73 | nGot, err := packetGot.ReadFrom(&buffer) 74 | if !errors.Is(err, io.EOF) { 75 | t.Fatalf("got %q, want %q", err, io.EOF) 76 | } 77 | 78 | if nGot != 0 { 79 | t.Fatalf("got %d, want %d", nGot, 0) 80 | } 81 | }) 82 | 83 | t.Run("response too small", func(t *testing.T) { 84 | var buffer bytes.Buffer 85 | 86 | packetGot := new(Packet) 87 | binary.Write(&buffer, binary.LittleEndian, packetGot.Size) 88 | 89 | _, err := packetGot.ReadFrom(&buffer) 90 | if !errors.Is(err, ErrResponseTooSmall) { 91 | t.Fatalf("got %q, want %q", err, ErrResponseTooSmall) 92 | } 93 | }) 94 | 95 | t.Run("EOF 2", func(t *testing.T) { 96 | var buffer bytes.Buffer 97 | binary.Write(&buffer, binary.LittleEndian, int32(18)) 98 | 99 | packetGot := new(Packet) 100 | nGot, err := packetGot.ReadFrom(&buffer) 101 | if !errors.Is(err, io.EOF) { 102 | t.Fatalf("got %q, want %q", err, io.EOF) 103 | } 104 | 105 | if nGot != 4 { 106 | t.Fatalf("got %d, want %d", nGot, 4) 107 | } 108 | }) 109 | 110 | t.Run("EOF 3", func(t *testing.T) { 111 | var buffer bytes.Buffer 112 | binary.Write(&buffer, binary.LittleEndian, int32(18)) 113 | binary.Write(&buffer, binary.LittleEndian, int32(42)) 114 | 115 | packetGot := new(Packet) 116 | nGot, err := packetGot.ReadFrom(&buffer) 117 | if !errors.Is(err, io.EOF) { 118 | t.Fatalf("got %q, want %q", err, io.EOF) 119 | } 120 | 121 | if nGot != 8 { 122 | t.Fatalf("got %d, want %d", nGot, 8) 123 | } 124 | }) 125 | 126 | t.Run("padding", func(t *testing.T) { 127 | body := []byte("testdata") 128 | packetWant := NewPacket(SERVERDATA_RESPONSE_VALUE, 42, string(body)) 129 | packetWant.Size = 10 130 | var buffer bytes.Buffer 131 | _, err := packetWant.WriteTo(&buffer) 132 | if err != nil { 133 | t.Fatal(err) 134 | } 135 | 136 | packetGot := new(Packet) 137 | _, err = packetGot.ReadFrom(&buffer) 138 | if !errors.Is(err, ErrInvalidPacketPadding) { 139 | t.Fatalf("got %q, want %q", err, ErrInvalidPacketPadding) 140 | } 141 | }) 142 | 143 | t.Run("EOF 4", func(t *testing.T) { 144 | var buffer bytes.Buffer 145 | binary.Write(&buffer, binary.LittleEndian, int32(18)) 146 | binary.Write(&buffer, binary.LittleEndian, int32(42)) 147 | buffer.Write(append([]byte("testdata"), 0x00, 0x00)) 148 | 149 | packetGot := new(Packet) 150 | nGot, err := packetGot.ReadFrom(&buffer) 151 | if !errors.Is(err, io.EOF) { 152 | t.Fatalf("got %q, want %q", err, io.EOF) 153 | } 154 | 155 | if nGot != 18 { 156 | t.Fatalf("got %d, want %d", nGot, 18) 157 | } 158 | }) 159 | } 160 | -------------------------------------------------------------------------------- /rcon.go: -------------------------------------------------------------------------------- 1 | // Package rcon implements Source RCON Protocol which is described in the 2 | // documentation: https://developer.valvesoftware.com/wiki/Source_RCON_Protocol. 3 | package rcon 4 | 5 | import ( 6 | "encoding/binary" 7 | "errors" 8 | "fmt" 9 | "net" 10 | "time" 11 | ) 12 | 13 | const ( 14 | // DefaultDialTimeout provides default auth timeout to remote server. 15 | DefaultDialTimeout = 5 * time.Second 16 | 17 | // DefaultDeadline provides default deadline to tcp read/write operations. 18 | DefaultDeadline = 5 * time.Second 19 | 20 | // DefaultMaxCommandLen is an artificial restriction, but it will help in case of random 21 | // large queries. 22 | DefaultMaxCommandLen = 1000 23 | 24 | // SERVERDATA_AUTH is the first packet sent by the client, 25 | // which is used to authenticate the conn with the server. 26 | SERVERDATA_AUTH int32 = 3 27 | 28 | // SERVERDATA_AUTH_ID is any positive integer, chosen by the client 29 | // (will be mirrored back in the server's response). 30 | SERVERDATA_AUTH_ID int32 = 0 31 | 32 | // SERVERDATA_AUTH_RESPONSE packet is a notification of the conn's current auth 33 | // status. When the server receives an auth request, it will respond with an empty 34 | // SERVERDATA_RESPONSE_VALUE, followed immediately by a SERVERDATA_AUTH_RESPONSE 35 | // indicating whether authentication succeeded or failed. Note that the status 36 | // code is returned in the packet id field, so when pairing the response with 37 | // the original auth request, you may need to look at the packet id of the 38 | // preceding SERVERDATA_RESPONSE_VALUE. 39 | // If authentication was successful, the ID assigned by the request. 40 | // If auth failed, -1 (0xFF FF FF FF). 41 | SERVERDATA_AUTH_RESPONSE int32 = 2 42 | 43 | // SERVERDATA_RESPONSE_VALUE packet is the response to a SERVERDATA_EXECCOMMAND 44 | // request. The ID assigned by the original request. 45 | SERVERDATA_RESPONSE_VALUE int32 = 0 46 | 47 | // SERVERDATA_EXECCOMMAND packet type represents a command issued to the server 48 | // by a client. The response will vary depending on the command issued. 49 | SERVERDATA_EXECCOMMAND int32 = 2 50 | 51 | // SERVERDATA_EXECCOMMAND_ID is any positive integer, chosen by the client 52 | // (will be mirrored back in the server's response). 53 | SERVERDATA_EXECCOMMAND_ID int32 = 0 54 | ) 55 | 56 | var ( 57 | // ErrAuthNotRCON is returned when got auth response with negative size. 58 | ErrAuthNotRCON = errors.New("response from not rcon server") 59 | 60 | // ErrInvalidAuthResponse is returned when we didn't get an auth packet 61 | // back for second read try after discard empty SERVERDATA_RESPONSE_VALUE 62 | // from authentication response. 63 | ErrInvalidAuthResponse = errors.New("invalid authentication packet type response") 64 | 65 | // ErrAuthFailed is returned when the package id from authentication 66 | // response is -1. 67 | ErrAuthFailed = errors.New("authentication failed") 68 | 69 | // ErrInvalidPacketID is returned when the package id from server response 70 | // was not mirrored back from request. 71 | ErrInvalidPacketID = errors.New("response for another request") 72 | 73 | // ErrInvalidPacketPadding is returned when the bytes after type field from 74 | // response is not equal to null-terminated ASCII strings. 75 | ErrInvalidPacketPadding = errors.New("invalid response padding") 76 | 77 | // ErrResponseTooSmall is returned when the server response is smaller 78 | // than 10 bytes. 79 | ErrResponseTooSmall = errors.New("response too small") 80 | 81 | // ErrCommandTooLong is returned when executed command length is bigger 82 | // than MaxCommandLen characters. 83 | ErrCommandTooLong = errors.New("command too long") 84 | 85 | // ErrCommandEmpty is returned when executed command length equal 0. 86 | ErrCommandEmpty = errors.New("command too small") 87 | 88 | // ErrMultiErrorOccurred is returned when close connection failed with 89 | // error after auth failed. 90 | ErrMultiErrorOccurred = errors.New("an error occurred while handling another error") 91 | ) 92 | 93 | // Conn is source RCON generic stream-oriented network connection. 94 | type Conn struct { 95 | conn net.Conn 96 | settings Settings 97 | } 98 | 99 | // open creates a new Conn from an existing net.Conn and authenticates it. 100 | func open(conn net.Conn, password string, settings Settings) (*Conn, error) { 101 | client := Conn{conn: conn, settings: settings} 102 | 103 | if err := client.auth(password); err != nil { 104 | // Failed to auth conn with the server. 105 | if err2 := client.Close(); err2 != nil { 106 | return &client, fmt.Errorf("%w: %s. Previous error: %s", ErrMultiErrorOccurred, err2.Error(), err.Error()) 107 | } 108 | 109 | return &client, fmt.Errorf("rcon: %w", err) 110 | } 111 | 112 | return &client, nil 113 | } 114 | 115 | // Open creates a new authorized Conn from an existing net.Conn. 116 | func Open(conn net.Conn, password string, options ...Option) (*Conn, error) { 117 | settings := DefaultSettings 118 | for _, option := range options { 119 | option(&settings) 120 | } 121 | 122 | return open(conn, password, settings) 123 | } 124 | 125 | // Dial creates a new authorized Conn tcp dialer connection. 126 | func Dial(address string, password string, options ...Option) (*Conn, error) { 127 | settings := DefaultSettings 128 | 129 | for _, option := range options { 130 | option(&settings) 131 | } 132 | 133 | conn, err := net.DialTimeout("tcp", address, settings.dialTimeout) 134 | if err != nil { 135 | // Failed to open TCP connection to the server. 136 | return nil, fmt.Errorf("rcon: %w", err) 137 | } 138 | 139 | return open(conn, password, settings) 140 | } 141 | 142 | // Execute sends command type and it string to execute to the remote server, 143 | // creating a packet with a SERVERDATA_EXECCOMMAND_ID for the server to mirror, 144 | // and compiling its payload bytes in the appropriate order. The response body 145 | // is decompiled from bytes into a string for return. 146 | func (c *Conn) Execute(command string) (string, error) { 147 | if command == "" { 148 | return "", ErrCommandEmpty 149 | } 150 | 151 | if c.settings.maxCommandLen > 0 && len(command) > c.settings.maxCommandLen { 152 | return "", ErrCommandTooLong 153 | } 154 | 155 | if err := c.write(SERVERDATA_EXECCOMMAND, SERVERDATA_EXECCOMMAND_ID, command); err != nil { 156 | return "", err 157 | } 158 | 159 | response, err := c.read() 160 | if err != nil { 161 | return response.Body(), err 162 | } 163 | 164 | if response.ID != SERVERDATA_EXECCOMMAND_ID { 165 | return response.Body(), ErrInvalidPacketID 166 | } 167 | 168 | return response.Body(), nil 169 | } 170 | 171 | // LocalAddr returns the local network address. 172 | func (c *Conn) LocalAddr() net.Addr { 173 | return c.conn.LocalAddr() 174 | } 175 | 176 | // RemoteAddr returns the remote network address. 177 | func (c *Conn) RemoteAddr() net.Addr { 178 | return c.conn.RemoteAddr() 179 | } 180 | 181 | // Close closes the connection. 182 | func (c *Conn) Close() error { 183 | return c.conn.Close() 184 | } 185 | 186 | // auth sends SERVERDATA_AUTH request to the remote server and 187 | // authenticates client for the next requests. 188 | func (c *Conn) auth(password string) error { 189 | if err := c.write(SERVERDATA_AUTH, SERVERDATA_AUTH_ID, password); err != nil { 190 | return err 191 | } 192 | 193 | if c.settings.deadline != 0 { 194 | if err := c.conn.SetReadDeadline(time.Now().Add(c.settings.deadline)); err != nil { 195 | return fmt.Errorf("rcon: %w", err) 196 | } 197 | } 198 | 199 | response, err := c.readHeader() 200 | if err != nil { 201 | return err 202 | } 203 | 204 | size := response.Size - PacketHeaderSize 205 | if size < 0 { 206 | return ErrAuthNotRCON 207 | } 208 | 209 | // When the server receives an auth request, it will respond with an empty 210 | // SERVERDATA_RESPONSE_VALUE, followed immediately by a SERVERDATA_AUTH_RESPONSE 211 | // indicating whether authentication succeeded or failed. 212 | // Some servers doesn't send an empty SERVERDATA_RESPONSE_VALUE packet, so we 213 | // do this case optional. 214 | if response.Type == SERVERDATA_RESPONSE_VALUE { 215 | // Discard empty SERVERDATA_RESPONSE_VALUE from authentication response. 216 | _, _ = c.conn.Read(make([]byte, size)) 217 | 218 | if response, err = c.readHeader(); err != nil { 219 | return err 220 | } 221 | } 222 | 223 | // We must to read response body. 224 | buffer := make([]byte, size) 225 | if _, err := c.conn.Read(buffer); err != nil { 226 | return fmt.Errorf("rcon: %w", err) 227 | } 228 | 229 | if response.Type != SERVERDATA_AUTH_RESPONSE { 230 | return ErrInvalidAuthResponse 231 | } 232 | 233 | if response.ID == -1 { 234 | return ErrAuthFailed 235 | } 236 | 237 | if response.ID != SERVERDATA_AUTH_ID { 238 | return ErrInvalidPacketID 239 | } 240 | 241 | return nil 242 | } 243 | 244 | // write creates packet and writes it to established tcp conn. 245 | func (c *Conn) write(packetType int32, packetID int32, command string) error { 246 | if c.settings.deadline != 0 { 247 | if err := c.conn.SetWriteDeadline(time.Now().Add(c.settings.deadline)); err != nil { 248 | return fmt.Errorf("rcon: %w", err) 249 | } 250 | } 251 | 252 | packet := NewPacket(packetType, packetID, command) 253 | _, err := packet.WriteTo(c.conn) 254 | 255 | return err 256 | } 257 | 258 | // read reads structured binary data from c.conn into packet. 259 | func (c *Conn) read() (*Packet, error) { 260 | if c.settings.deadline != 0 { 261 | if err := c.conn.SetReadDeadline(time.Now().Add(c.settings.deadline)); err != nil { 262 | return nil, fmt.Errorf("rcon: %w", err) 263 | } 264 | } 265 | 266 | packet := &Packet{} 267 | if _, err := packet.ReadFrom(c.conn); err != nil { 268 | return packet, err 269 | } 270 | 271 | // Workaround for Rust server. 272 | // Rust rcon server responses packet with a type of 4 and the next packet 273 | // is valid. It is undocumented, so skip packet and read next. 274 | if packet.Type == 4 { 275 | if _, err := packet.ReadFrom(c.conn); err != nil { 276 | return packet, err 277 | } 278 | 279 | // One more workaround for Rust server. 280 | // When sent command "Say" there is no response data from server with 281 | // packet.ID = SERVERDATA_EXECCOMMAND_ID, only previous console message 282 | // that command was received with packet.ID = -1, therefore, forcibly 283 | // set packet.ID to SERVERDATA_EXECCOMMAND_ID. 284 | if packet.ID == -1 { 285 | packet.ID = SERVERDATA_EXECCOMMAND_ID 286 | } 287 | } 288 | 289 | return packet, nil 290 | } 291 | 292 | // readHeader reads structured binary data without body from c.conn into packet. 293 | func (c *Conn) readHeader() (Packet, error) { 294 | var packet Packet 295 | if err := binary.Read(c.conn, binary.LittleEndian, &packet.Size); err != nil { 296 | return packet, fmt.Errorf("rcon: read packet size: %w", err) 297 | } 298 | 299 | if err := binary.Read(c.conn, binary.LittleEndian, &packet.ID); err != nil { 300 | return packet, fmt.Errorf("rcon: read packet id: %w", err) 301 | } 302 | 303 | if err := binary.Read(c.conn, binary.LittleEndian, &packet.Type); err != nil { 304 | return packet, fmt.Errorf("rcon: read packet type: %w", err) 305 | } 306 | 307 | return packet, nil 308 | } 309 | -------------------------------------------------------------------------------- /rcon_test.go: -------------------------------------------------------------------------------- 1 | package rcon_test 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "os" 10 | "strings" 11 | "testing" 12 | "time" 13 | 14 | "github.com/gorcon/rcon" 15 | "github.com/gorcon/rcon/rcontest" 16 | ) 17 | 18 | func authHandler(c *rcontest.Context) { 19 | switch c.Request().Body() { 20 | case "invalid packet type": 21 | rcon.NewPacket(42, c.Request().ID, "").WriteTo(c.Conn()) 22 | case "another": 23 | rcon.NewPacket(rcon.SERVERDATA_AUTH_RESPONSE, 42, "").WriteTo(c.Conn()) 24 | case "makeslice": 25 | size := int32(len([]byte(""))) 26 | 27 | buffer := bytes.NewBuffer(make([]byte, 0, size+4)) 28 | 29 | _ = binary.Write(buffer, binary.LittleEndian, size) 30 | _ = binary.Write(buffer, binary.LittleEndian, c.Request().ID) 31 | _ = binary.Write(buffer, binary.LittleEndian, rcon.SERVERDATA_RESPONSE_VALUE) 32 | 33 | buffer.WriteTo(c.Conn()) 34 | case c.Server().Settings.Password: 35 | rcon.NewPacket(rcon.SERVERDATA_RESPONSE_VALUE, c.Request().ID, "").WriteTo(c.Conn()) 36 | rcon.NewPacket(rcon.SERVERDATA_AUTH_RESPONSE, c.Request().ID, "").WriteTo(c.Conn()) 37 | default: 38 | rcon.NewPacket(rcon.SERVERDATA_AUTH_RESPONSE, -1, string([]byte{0x00})).WriteTo(c.Conn()) 39 | } 40 | } 41 | 42 | func commandHandler(c *rcontest.Context) { 43 | writeWithInvalidPadding := func(conn io.Writer, packet *rcon.Packet) { 44 | buffer := bytes.NewBuffer(make([]byte, 0, packet.Size+4)) 45 | 46 | binary.Write(buffer, binary.LittleEndian, packet.Size) 47 | binary.Write(buffer, binary.LittleEndian, packet.ID) 48 | binary.Write(buffer, binary.LittleEndian, packet.Type) 49 | 50 | // Write command body, null terminated ASCII string and an empty ASCIIZ string. 51 | // Second padding byte is incorrect. 52 | buffer.Write(append([]byte(packet.Body()), 0x00, 0x01)) 53 | 54 | buffer.WriteTo(conn) 55 | } 56 | 57 | switch c.Request().Body() { 58 | case "help": 59 | responseBody := "lorem ipsum dolor sit amet" 60 | rcon.NewPacket(rcon.SERVERDATA_RESPONSE_VALUE, c.Request().ID, responseBody).WriteTo(c.Conn()) 61 | case "rust": 62 | // Write specific Rust package. 63 | rcon.NewPacket(4, c.Request().ID, "").WriteTo(c.Conn()) 64 | 65 | rcon.NewPacket(rcon.SERVERDATA_RESPONSE_VALUE, -1, c.Request().Body()).WriteTo(c.Conn()) 66 | case "padding": 67 | writeWithInvalidPadding(c.Conn(), rcon.NewPacket(rcon.SERVERDATA_RESPONSE_VALUE, c.Request().ID, "")) 68 | case "another": 69 | rcon.NewPacket(rcon.SERVERDATA_RESPONSE_VALUE, 42, "").WriteTo(c.Conn()) 70 | default: 71 | rcon.NewPacket(rcon.SERVERDATA_RESPONSE_VALUE, c.Request().ID, "unknown command").WriteTo(c.Conn()) 72 | } 73 | } 74 | 75 | func TestDial(t *testing.T) { 76 | server := rcontest.NewServer(rcontest.SetSettings(rcontest.Settings{Password: "password"})) 77 | defer server.Close() 78 | 79 | t.Run("connection refused", func(t *testing.T) { 80 | wantErrContains := "connect: connection refused" 81 | 82 | _, err := rcon.Dial("127.0.0.2:12345", "password") 83 | if err == nil || !strings.Contains(err.Error(), wantErrContains) { 84 | t.Errorf("got err %q, want to contain %q", err, wantErrContains) 85 | } 86 | }) 87 | 88 | t.Run("connection timeout", func(t *testing.T) { 89 | server := rcontest.NewServer(rcontest.SetSettings(rcontest.Settings{Password: "password", AuthResponseDelay: 6 * time.Second})) 90 | defer server.Close() 91 | 92 | wantErrContains := "i/o timeout" 93 | 94 | _, err := rcon.Dial(server.Addr(), "", rcon.SetDialTimeout(5*time.Second)) 95 | if err == nil || !strings.Contains(err.Error(), wantErrContains) { 96 | t.Errorf("got err %q, want to contain %q", err, wantErrContains) 97 | } 98 | }) 99 | 100 | t.Run("authentication failed", func(t *testing.T) { 101 | _, err := rcon.Dial(server.Addr(), "wrong") 102 | if !errors.Is(err, rcon.ErrAuthFailed) { 103 | t.Errorf("got err %q, want %q", err, rcon.ErrAuthFailed) 104 | } 105 | }) 106 | 107 | t.Run("invalid packet type", func(t *testing.T) { 108 | server := rcontest.NewServer( 109 | rcontest.SetSettings(rcontest.Settings{Password: "password"}), 110 | rcontest.SetAuthHandler(authHandler), 111 | ) 112 | defer server.Close() 113 | 114 | _, err := rcon.Dial(server.Addr(), "invalid packet type") 115 | if !errors.Is(err, rcon.ErrInvalidAuthResponse) { 116 | t.Errorf("got err %q, want %q", err, rcon.ErrInvalidAuthResponse) 117 | } 118 | }) 119 | 120 | t.Run("invalid response id", func(t *testing.T) { 121 | server := rcontest.NewServer( 122 | rcontest.SetSettings(rcontest.Settings{Password: "password"}), 123 | rcontest.SetAuthHandler(authHandler), 124 | ) 125 | defer server.Close() 126 | 127 | _, err := rcon.Dial(server.Addr(), "another") 128 | if !errors.Is(err, rcon.ErrInvalidPacketID) { 129 | t.Errorf("got err %q, want %q", err, rcon.ErrInvalidPacketID) 130 | } 131 | }) 132 | 133 | t.Run("makeslice", func(t *testing.T) { 134 | server := rcontest.NewServer( 135 | rcontest.SetSettings(rcontest.Settings{Password: "makeslice"}), 136 | rcontest.SetAuthHandler(authHandler), 137 | ) 138 | defer server.Close() 139 | 140 | _, err := rcon.Dial(server.Addr(), "makeslice") 141 | if !errors.Is(err, rcon.ErrAuthNotRCON) { 142 | t.Errorf("got err %q, want %q", err, rcon.ErrAuthNotRCON) 143 | } 144 | }) 145 | 146 | t.Run("auth success", func(t *testing.T) { 147 | conn, err := rcon.Dial(server.Addr(), "password") 148 | if err != nil { 149 | t.Errorf("got err %q, want %v", err, nil) 150 | return 151 | } 152 | 153 | conn.Close() 154 | }) 155 | } 156 | 157 | func TestConn_Execute(t *testing.T) { 158 | server := rcontest.NewUnstartedServer() 159 | server.Settings.Password = "password" 160 | server.SetCommandHandler(commandHandler) 161 | server.Start() 162 | defer server.Close() 163 | 164 | t.Run("incorrect command", func(t *testing.T) { 165 | conn, err := rcon.Dial(server.Addr(), "password") 166 | if err != nil { 167 | t.Fatalf("got err %q, want %v", err, nil) 168 | } 169 | defer conn.Close() 170 | 171 | result, err := conn.Execute("") 172 | if !errors.Is(err, rcon.ErrCommandEmpty) { 173 | t.Errorf("got err %q, want %q", err, rcon.ErrCommandEmpty) 174 | } 175 | 176 | if len(result) != 0 { 177 | t.Fatalf("got result len %d, want %d", len(result), 0) 178 | } 179 | 180 | result, err = conn.Execute(string(make([]byte, 1001))) 181 | if !errors.Is(err, rcon.ErrCommandTooLong) { 182 | t.Errorf("got err %q, want %q", err, rcon.ErrCommandTooLong) 183 | } 184 | 185 | if len(result) != 0 { 186 | t.Fatalf("got result len %d, want %d", len(result), 0) 187 | } 188 | }) 189 | 190 | t.Run("closed network connection 1", func(t *testing.T) { 191 | conn, err := rcon.Dial(server.Addr(), "password", rcon.SetDeadline(0)) 192 | if err != nil { 193 | t.Fatalf("got err %q, want %v", err, nil) 194 | } 195 | conn.Close() 196 | 197 | result, err := conn.Execute("help") 198 | wantErrMsg := fmt.Sprintf("write tcp %s->%s: use of closed network connection", conn.LocalAddr(), conn.RemoteAddr()) 199 | if err == nil || err.Error() != wantErrMsg { 200 | t.Errorf("got err %q, want to contain %q", err, wantErrMsg) 201 | } 202 | 203 | if len(result) != 0 { 204 | t.Fatalf("got result len %d, want %d", len(result), 0) 205 | } 206 | }) 207 | 208 | t.Run("closed network connection 2", func(t *testing.T) { 209 | conn, err := rcon.Dial(server.Addr(), "password") 210 | if err != nil { 211 | t.Fatalf("got err %q, want %v", err, nil) 212 | } 213 | conn.Close() 214 | 215 | result, err := conn.Execute("help") 216 | wantErrMsg := fmt.Sprintf("rcon: set tcp %s: use of closed network connection", conn.LocalAddr()) 217 | if err == nil || err.Error() != wantErrMsg { 218 | t.Errorf("got err %q, want to contain %q", err, wantErrMsg) 219 | } 220 | 221 | if len(result) != 0 { 222 | t.Fatalf("got result len %d, want %d", len(result), 0) 223 | } 224 | }) 225 | 226 | t.Run("read deadline", func(t *testing.T) { 227 | server := rcontest.NewServer(rcontest.SetSettings(rcontest.Settings{Password: "password", CommandResponseDelay: 2 * time.Second})) 228 | defer server.Close() 229 | 230 | conn, err := rcon.Dial(server.Addr(), "password", rcon.SetDeadline(1*time.Second)) 231 | if err != nil { 232 | t.Fatalf("got err %q, want %v", err, nil) 233 | } 234 | defer conn.Close() 235 | 236 | result, err := conn.Execute("deadline") 237 | wantErrMsg := fmt.Sprintf("rcon: read packet size: read tcp %s->%s: i/o timeout", conn.LocalAddr(), conn.RemoteAddr()) 238 | if err == nil || err.Error() != wantErrMsg { 239 | t.Errorf("got err %q, want to contain %q", err, wantErrMsg) 240 | } 241 | 242 | if len(result) != 0 { 243 | t.Fatalf("got result len %d, want %d", len(result), 0) 244 | } 245 | }) 246 | 247 | t.Run("invalid padding", func(t *testing.T) { 248 | conn, err := rcon.Dial(server.Addr(), "password") 249 | if err != nil { 250 | t.Fatalf("got err %q, want %v", err, nil) 251 | } 252 | defer conn.Close() 253 | 254 | result, err := conn.Execute("padding") 255 | if !errors.Is(err, rcon.ErrInvalidPacketPadding) { 256 | t.Errorf("got err %q, want %q", err, rcon.ErrInvalidPacketPadding) 257 | } 258 | 259 | if len(result) != 2 { 260 | t.Fatalf("got result len %d, want %d", len(result), 2) 261 | } 262 | }) 263 | 264 | t.Run("invalid response id", func(t *testing.T) { 265 | conn, err := rcon.Dial(server.Addr(), "password") 266 | if err != nil { 267 | t.Fatalf("got err %q, want %v", err, nil) 268 | } 269 | defer conn.Close() 270 | 271 | result, err := conn.Execute("another") 272 | if !errors.Is(err, rcon.ErrInvalidPacketID) { 273 | t.Errorf("got err %q, want %q", err, rcon.ErrInvalidPacketID) 274 | } 275 | 276 | if len(result) != 0 { 277 | t.Fatalf("got result len %d, want %d", len(result), 0) 278 | } 279 | }) 280 | 281 | t.Run("success help command", func(t *testing.T) { 282 | conn, err := rcon.Dial(server.Addr(), "password") 283 | if err != nil { 284 | t.Fatalf("got err %q, want %v", err, nil) 285 | } 286 | defer conn.Close() 287 | 288 | result, err := conn.Execute("help") 289 | if err != nil { 290 | t.Fatalf("got err %q, want %v", err, nil) 291 | } 292 | 293 | resultWant := "lorem ipsum dolor sit amet" 294 | if result != resultWant { 295 | t.Fatalf("got result %q, want %q", result, resultWant) 296 | } 297 | }) 298 | 299 | t.Run("rust workaround", func(t *testing.T) { 300 | conn, err := rcon.Dial(server.Addr(), "password", rcon.SetDeadline(1*time.Second)) 301 | if err != nil { 302 | t.Fatalf("got err %q, want %v", err, nil) 303 | } 304 | defer conn.Close() 305 | 306 | result, err := conn.Execute("rust") 307 | if err != nil { 308 | t.Fatalf("got err %q, want %v", err, nil) 309 | } 310 | 311 | resultWant := "rust" 312 | if result != resultWant { 313 | t.Fatalf("got result %q, want %q", result, resultWant) 314 | } 315 | }) 316 | 317 | if run := getVar("TEST_PZ_SERVER", "false"); run == "true" { 318 | addr := getVar("TEST_PZ_SERVER_ADDR", "127.0.0.1:16260") 319 | password := getVar("TEST_PZ_SERVER_PASSWORD", "docker") 320 | 321 | t.Run("pz server", func(t *testing.T) { 322 | needle := func() string { 323 | n := `List of server commands : 324 | * additem : Give an item to a player. If no username is given then you will receive the item yourself. Count is optional. Use: /additem "username" "module.item" count. Example: /additem "rj" Base.Axe 5 325 | * adduser : Use this command to add a new user to a whitelisted server. Use: /adduser "username" "password" 326 | * addvehicle : Spawn a vehicle. Use: /addvehicle "script" "user or x,y,z", ex /addvehicle "Base.VanAmbulance" "rj" 327 | * addxp : Give XP to a player. Use /addxp "playername" perkname=xp. Example /addxp "rj" Woodwork=2 328 | * alarm : Sound a building alarm at the Admin's position. (Must be in a room) 329 | * banid : Ban a SteamID. Use /banid SteamID 330 | * banuser : Ban a user. Add a -ip to also ban the IP. Add a -r "reason" to specify a reason for the ban. Use: /banuser "username" -ip -r "reason". For example: /banuser "rj" -ip -r "spawn kill" 331 | * changeoption : Change a server option. Use: /changeoption optionName "newValue" 332 | * checkModsNeedUpdate : Indicates whether a mod has been updated. Writes answer to log file 333 | * chopper : Place a helicopter event on a random player 334 | * createhorde : Spawn a horde near a player. Use : /createhorde count "username". Example /createhorde 150 "rj" Username is optional except from the server console. With no username the horde will be created around you 335 | * createhorde2 : UI_ServerOptionDesc_CreateHorde2 336 | * godmod : Make a player invincible. If no username is set, then you will become invincible yourself. Use: /godmode "username" -value, ex /godmode "rj" -true (could be -false) 337 | * gunshot : Place a gunshot sound on a random player 338 | * help : Help 339 | * invisible : Make a player invisible to zombies. If no username is set then you will become invisible yourself. Use: /invisible "username" -value, ex /invisible "rj" -true (could be -false) 340 | * kick : Kick a user. Add a -r "reason" to specify a reason for the kick. Use: /kickuser "username" -r "reason" 341 | * lightning : Use /lightning "username", username is optional except from the server console 342 | * log : Set log level. Use /log %1 %2 343 | * noclip : Makes a player pass through walls and structures. Toggles with no value. Use: /noclip "username" -value. Example /noclip "rj" -true (could be -false) 344 | * players : List all connected players 345 | * quit : Save and quit the server 346 | * releasesafehouse : Release a safehouse you own. Use /releasesafehouse 347 | * reloadlua : Reload a Lua script on the server. Use /reloadlua "filename" 348 | * reloadoptions : Reload server options (ServerOptions.ini) and send to clients 349 | * removeuserfromwhitelist : Remove a user from the whitelist. Use: /removeuserfromwhitelist "username" 350 | * removezombies : UI_ServerOptionDesc_RemoveZombies 351 | * replay : Record and play replay for moving player. Use /replay "playername" -record|-play|-stop filename. Example: /replay user1 -record stadion.bin 352 | * save : Save the current world 353 | * servermsg : Broadcast a message to all connected players. Use: /servermsg "My Message" 354 | * setaccesslevel : Set access level of a player. Current levels: Admin, Moderator, Overseer, GM, Observer. Use /setaccesslevel "username" "accesslevel". Example /setaccesslevel "rj" "moderator" 355 | * showoptions : Show the list of current server options and values. 356 | * startrain : Starts raining on the server. Use /startrain "intensity", optional intensity is from 1 to 100 357 | * startstorm : Starts a storm on the server. Use /startstorm "duration", optional duration is in game hours 358 | * stats : Set and clear server statistics. Use /stats none|file|console|all period. Example /stats file 10 359 | * stoprain : Stop raining on the server 360 | * stopweather : Stop weather on the server 361 | * teleport : Teleport to a player. Once teleported, wait for the map to appear. Use /teleport "playername" or /teleport "player1" "player2". Example /teleport "rj" or /teleport "rj" "toUser" 362 | * teleportto : Teleport to coordinates. Use /teleportto x,y,z. Example /teleportto 10000,11000,0 363 | * thunder : Use /thunder "username", username is optional except from the server console 364 | * unbanid : Unban a SteamID. Use /unbanid SteamID 365 | * unbanuser : Unban a player. Use /unbanuser "username" 366 | * voiceban : Block voice from user "username". Use /voiceban "username" -value. Example /voiceban "rj" -true (could be -false)` 367 | 368 | n = strings.Replace(n, "List of server commands :", "List of server commands : ", -1) 369 | 370 | return n 371 | }() 372 | 373 | conn, err := rcon.Dial(addr, password) 374 | if err != nil { 375 | t.Fatalf("got err %q, want %v", err, nil) 376 | } 377 | defer conn.Close() 378 | 379 | result, err := conn.Execute("help") 380 | if err != nil { 381 | t.Fatalf("got err %q, want %v", err, nil) 382 | } 383 | 384 | if result != needle { 385 | t.Fatalf("got result %q, want %q", result, needle) 386 | } 387 | }) 388 | } 389 | 390 | if run := getVar("TEST_RUST_SERVER", "false"); run == "true" { 391 | addr := getVar("TEST_RUST_SERVER_ADDR", "127.0.0.1:28016") 392 | password := getVar("TEST_RUST_SERVER_PASSWORD", "docker") 393 | 394 | t.Run("rust server", func(t *testing.T) { 395 | conn, err := rcon.Dial(addr, password) 396 | if err != nil { 397 | t.Fatalf("got err %q, want %v", err, nil) 398 | } 399 | defer conn.Close() 400 | 401 | result, err := conn.Execute("status") 402 | if err != nil { 403 | t.Fatalf("got err %q, want %v", err, nil) 404 | } 405 | 406 | if result == "" { 407 | t.Fatal("got empty result, want value") 408 | } 409 | 410 | fmt.Println(result) 411 | }) 412 | } 413 | } 414 | 415 | // getVar returns environment variable or default value. 416 | func getVar(key string, fallback string) string { 417 | if value := os.Getenv(key); value != "" { 418 | return value 419 | } 420 | 421 | return fallback 422 | } 423 | -------------------------------------------------------------------------------- /rcontest/context.go: -------------------------------------------------------------------------------- 1 | package rcontest 2 | 3 | import ( 4 | "net" 5 | 6 | "github.com/gorcon/rcon" 7 | ) 8 | 9 | // Context represents the context of the current RCON request. It holds request 10 | // and conn objects. 11 | type Context struct { 12 | server *Server 13 | conn net.Conn 14 | request *rcon.Packet 15 | } 16 | 17 | // Server returns the Server instance. 18 | func (c *Context) Server() *Server { 19 | return c.server 20 | } 21 | 22 | // Conn returns current RCON connection. 23 | func (c *Context) Conn() net.Conn { 24 | return c.conn 25 | } 26 | 27 | // Request returns received *rcon.Packet. 28 | func (c *Context) Request() *rcon.Packet { 29 | return c.request 30 | } 31 | -------------------------------------------------------------------------------- /rcontest/example_test.go: -------------------------------------------------------------------------------- 1 | package rcontest_test 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/gorcon/rcon" 8 | "github.com/gorcon/rcon/rcontest" 9 | ) 10 | 11 | func ExampleServer() { 12 | server := rcontest.NewServer( 13 | rcontest.SetSettings(rcontest.Settings{Password: "password"}), 14 | rcontest.SetCommandHandler(func(c *rcontest.Context) { 15 | switch c.Request().Body() { 16 | case "Hello, server": 17 | rcon.NewPacket(rcon.SERVERDATA_RESPONSE_VALUE, c.Request().ID, "Hello, client").WriteTo(c.Conn()) 18 | default: 19 | rcon.NewPacket(rcon.SERVERDATA_RESPONSE_VALUE, c.Request().ID, "unknown command").WriteTo(c.Conn()) 20 | } 21 | }), 22 | ) 23 | defer server.Close() 24 | 25 | client, err := rcon.Dial(server.Addr(), "password") 26 | if err != nil { 27 | log.Fatal(err) 28 | } 29 | defer client.Close() 30 | 31 | response, err := client.Execute("Hello, server") 32 | if err != nil { 33 | log.Fatal(err) 34 | } 35 | 36 | fmt.Println(response) 37 | 38 | response, err = client.Execute("Hi!") 39 | if err != nil { 40 | log.Fatal(err) 41 | } 42 | 43 | fmt.Println(response) 44 | 45 | // Output: 46 | // Hello, client 47 | // unknown command 48 | } 49 | -------------------------------------------------------------------------------- /rcontest/option.go: -------------------------------------------------------------------------------- 1 | package rcontest 2 | 3 | // Option allows to inject Settings to Server. 4 | type Option func(s *Server) 5 | 6 | // SetSettings injects configuration for RCON Server. 7 | func SetSettings(settings Settings) Option { 8 | return func(s *Server) { 9 | s.Settings = settings 10 | } 11 | } 12 | 13 | // SetAuthHandler injects HandlerFunc with authorisation data checking. 14 | func SetAuthHandler(handler HandlerFunc) Option { 15 | return func(s *Server) { 16 | s.SetAuthHandler(handler) 17 | } 18 | } 19 | 20 | // SetCommandHandler injects HandlerFunc with commands processing. 21 | func SetCommandHandler(handler HandlerFunc) Option { 22 | return func(s *Server) { 23 | s.SetCommandHandler(handler) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /rcontest/server.go: -------------------------------------------------------------------------------- 1 | // Package rcontest contains RCON server for RCON client testing. 2 | package rcontest 3 | 4 | import ( 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net" 9 | "sync" 10 | "time" 11 | 12 | "github.com/gorcon/rcon" 13 | ) 14 | 15 | // Server is an RCON server listening on a system-chosen port on the 16 | // local loopback interface, for use in end-to-end RCON tests. 17 | type Server struct { 18 | Settings Settings 19 | Listener net.Listener 20 | addr string 21 | authHandler HandlerFunc 22 | commandHandler HandlerFunc 23 | connections map[net.Conn]struct{} 24 | quit chan bool 25 | wg sync.WaitGroup 26 | mu sync.Mutex 27 | closed bool 28 | } 29 | 30 | // Settings contains configuration for RCON Server. 31 | type Settings struct { 32 | Password string 33 | AuthResponseDelay time.Duration 34 | CommandResponseDelay time.Duration 35 | } 36 | 37 | // HandlerFunc defines a function to serve RCON requests. 38 | type HandlerFunc func(c *Context) 39 | 40 | // AuthHandler checks authorisation data and responses with 41 | // SERVERDATA_AUTH_RESPONSE packet. 42 | func AuthHandler(c *Context) { 43 | if c.Request().Body() == c.Server().Settings.Password { 44 | // First write SERVERDATA_RESPONSE_VALUE packet with empty body. 45 | _, _ = rcon.NewPacket(rcon.SERVERDATA_RESPONSE_VALUE, c.Request().ID, "").WriteTo(c.Conn()) 46 | 47 | // Than write SERVERDATA_AUTH_RESPONSE packet to allow authHandler success. 48 | _, _ = rcon.NewPacket(rcon.SERVERDATA_AUTH_RESPONSE, rcon.SERVERDATA_AUTH_ID, "").WriteTo(c.Conn()) 49 | } else { 50 | // If authentication was failed, the ID must be assigned to -1. 51 | _, _ = rcon.NewPacket(rcon.SERVERDATA_AUTH_RESPONSE, -1, string([]byte{0x00})).WriteTo(c.Conn()) 52 | } 53 | } 54 | 55 | // EmptyHandler responses with empty body. Is used when start RCON Server with nil 56 | // commandHandler. 57 | func EmptyHandler(c *Context) { 58 | _, _ = rcon.NewPacket(rcon.SERVERDATA_RESPONSE_VALUE, c.Request().ID, "").WriteTo(c.Conn()) 59 | } 60 | 61 | func newLocalListener() net.Listener { 62 | l, err := net.Listen("tcp", "127.0.0.1:0") 63 | if err != nil { 64 | panic(fmt.Sprintf("rcontest: failed to listen on a port: %v", err)) 65 | } 66 | 67 | return l 68 | } 69 | 70 | // NewServer returns a running RCON Server or nil if an error occurred. 71 | // The caller should call Close when finished, to shut it down. 72 | func NewServer(options ...Option) *Server { 73 | server := NewUnstartedServer(options...) 74 | server.Start() 75 | 76 | return server 77 | } 78 | 79 | // NewUnstartedServer returns a new Server but doesn't start it. 80 | // After changing its configuration, the caller should call Start. 81 | // The caller should call Close when finished, to shut it down. 82 | func NewUnstartedServer(options ...Option) *Server { 83 | server := Server{ 84 | Listener: newLocalListener(), 85 | authHandler: AuthHandler, 86 | commandHandler: EmptyHandler, 87 | connections: make(map[net.Conn]struct{}), 88 | quit: make(chan bool), 89 | } 90 | 91 | for _, option := range options { 92 | option(&server) 93 | } 94 | 95 | return &server 96 | } 97 | 98 | // SetAuthHandler injects HandlerFunc with authorisation data checking. 99 | func (s *Server) SetAuthHandler(handler HandlerFunc) { 100 | s.authHandler = handler 101 | } 102 | 103 | // SetCommandHandler injects HandlerFunc with commands processing. 104 | func (s *Server) SetCommandHandler(handler HandlerFunc) { 105 | s.commandHandler = handler 106 | } 107 | 108 | // Start starts a server from NewUnstartedServer. 109 | func (s *Server) Start() { 110 | if s.addr != "" { 111 | panic("server already started") 112 | } 113 | 114 | s.addr = s.Listener.Addr().String() 115 | s.goServe() 116 | } 117 | 118 | // Close shuts down the Server. 119 | func (s *Server) Close() { 120 | if s.closed { 121 | return 122 | } 123 | 124 | s.closed = true 125 | close(s.quit) 126 | s.Listener.Close() 127 | 128 | // Waiting for server connections. 129 | s.wg.Wait() 130 | 131 | s.mu.Lock() 132 | for c := range s.connections { 133 | // Force-close any connections. 134 | s.closeConn(c) 135 | } 136 | s.mu.Unlock() 137 | } 138 | 139 | // Addr returns IPv4 string Server address. 140 | func (s *Server) Addr() string { 141 | return s.addr 142 | } 143 | 144 | // NewContext returns a Context instance. 145 | func (s *Server) NewContext(conn net.Conn) (*Context, error) { 146 | ctx := Context{server: s, conn: conn, request: &rcon.Packet{}} 147 | 148 | if _, err := ctx.request.ReadFrom(conn); err != nil { 149 | return &ctx, fmt.Errorf("rcontest: %w", err) 150 | } 151 | 152 | return &ctx, nil 153 | } 154 | 155 | // serve handles incoming requests until a stop signal is given with Close. 156 | func (s *Server) serve() { 157 | for { 158 | conn, err := s.Listener.Accept() 159 | if err != nil { 160 | if s.isRunning() { 161 | panic(fmt.Errorf("rcontest: %w", err)) 162 | } 163 | 164 | return 165 | } 166 | 167 | s.wg.Add(1) 168 | 169 | go s.handle(conn) 170 | } 171 | } 172 | 173 | // serve calls serve in goroutine. 174 | func (s *Server) goServe() { 175 | s.wg.Add(1) 176 | 177 | go func() { 178 | defer s.wg.Done() 179 | 180 | s.serve() 181 | }() 182 | } 183 | 184 | // handle handles incoming client conn. 185 | func (s *Server) handle(conn net.Conn) { 186 | s.mu.Lock() 187 | s.connections[conn] = struct{}{} 188 | s.mu.Unlock() 189 | 190 | defer func() { 191 | s.closeConn(conn) 192 | s.wg.Done() 193 | }() 194 | 195 | for { 196 | ctx, err := s.NewContext(conn) 197 | if err != nil { 198 | if !errors.Is(err, io.EOF) { 199 | panic(fmt.Errorf("failed read request: %w", err)) 200 | } 201 | 202 | return 203 | } 204 | 205 | switch ctx.Request().Type { 206 | case rcon.SERVERDATA_AUTH: 207 | if s.Settings.AuthResponseDelay != 0 { 208 | time.Sleep(s.Settings.AuthResponseDelay) 209 | } 210 | 211 | s.authHandler(ctx) 212 | case rcon.SERVERDATA_EXECCOMMAND: 213 | if s.Settings.CommandResponseDelay != 0 { 214 | time.Sleep(s.Settings.CommandResponseDelay) 215 | } 216 | 217 | s.commandHandler(ctx) 218 | } 219 | } 220 | } 221 | 222 | // isRunning returns true if Server is running and false if is not. 223 | func (s *Server) isRunning() bool { 224 | select { 225 | case <-s.quit: 226 | return false 227 | default: 228 | return true 229 | } 230 | } 231 | 232 | // closeConn closes a client conn and removes it from connections map. 233 | func (s *Server) closeConn(conn net.Conn) { 234 | s.mu.Lock() 235 | defer s.mu.Unlock() 236 | 237 | if err := conn.Close(); err != nil { 238 | panic(fmt.Errorf("close conn error: %w", err)) 239 | } 240 | 241 | delete(s.connections, conn) 242 | } 243 | -------------------------------------------------------------------------------- /rcontest/server_test.go: -------------------------------------------------------------------------------- 1 | package rcontest_test 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | "time" 7 | 8 | "github.com/gorcon/rcon" 9 | "github.com/gorcon/rcon/rcontest" 10 | ) 11 | 12 | func TestNewServer(t *testing.T) { 13 | t.Run("with options", func(t *testing.T) { 14 | server := rcontest.NewServer( 15 | rcontest.SetSettings(rcontest.Settings{Password: "password"}), 16 | rcontest.SetAuthHandler(func(c *rcontest.Context) { 17 | if c.Request().Body() == c.Server().Settings.Password { 18 | rcon.NewPacket(rcon.SERVERDATA_AUTH_RESPONSE, c.Request().ID, "").WriteTo(c.Conn()) 19 | } else { 20 | rcon.NewPacket(rcon.SERVERDATA_AUTH_RESPONSE, -1, string([]byte{0x00})).WriteTo(c.Conn()) 21 | } 22 | }), 23 | rcontest.SetCommandHandler(func(c *rcontest.Context) { 24 | rcon.NewPacket(rcon.SERVERDATA_RESPONSE_VALUE, c.Request().ID, "Can I help you?").WriteTo(c.Conn()) 25 | }), 26 | ) 27 | defer server.Close() 28 | 29 | client, err := rcon.Dial(server.Addr(), "password") 30 | if err != nil { 31 | t.Fatal(err) 32 | } 33 | defer client.Close() 34 | 35 | response, err := client.Execute("Can I help you?") 36 | if err != nil { 37 | t.Fatal(err) 38 | } 39 | 40 | if response != "Can I help you?" { 41 | t.Errorf("got %q, want Can I help you?", response) 42 | } 43 | }) 44 | 45 | t.Run("unstarted", func(t *testing.T) { 46 | server := rcontest.NewUnstartedServer() 47 | server.Settings.Password = "password" 48 | server.Settings.AuthResponseDelay = 10 * time.Millisecond 49 | server.Settings.CommandResponseDelay = 10 * time.Millisecond 50 | server.SetCommandHandler(func(c *rcontest.Context) { 51 | rcon.NewPacket(rcon.SERVERDATA_RESPONSE_VALUE, c.Request().ID, "I do it all.").WriteTo(c.Conn()) 52 | }) 53 | server.Start() 54 | defer server.Close() 55 | 56 | client, err := rcon.Dial(server.Addr(), "password") 57 | if err != nil { 58 | t.Fatal(err) 59 | } 60 | defer client.Close() 61 | 62 | response, err := client.Execute("What do you do?") 63 | if err != nil { 64 | t.Fatal(err) 65 | } 66 | 67 | if response != "I do it all." { 68 | t.Errorf("got %q, want I do it all.", response) 69 | } 70 | }) 71 | 72 | t.Run("authentication failed", func(t *testing.T) { 73 | server := rcontest.NewServer() 74 | defer server.Close() 75 | 76 | client, err := rcon.Dial(server.Addr(), "wrong") 77 | if err != nil { 78 | defer client.Close() 79 | } 80 | if !errors.Is(err, rcon.ErrAuthFailed) { 81 | t.Fatal(err) 82 | } 83 | }) 84 | 85 | t.Run("empty handler", func(t *testing.T) { 86 | server := rcontest.NewServer() 87 | defer server.Close() 88 | 89 | client, err := rcon.Dial(server.Addr(), "") 90 | if err != nil { 91 | t.Fatal(err) 92 | } 93 | defer client.Close() 94 | 95 | response, err := client.Execute("whatever") 96 | if err != nil { 97 | t.Fatal(err) 98 | } 99 | 100 | if response != "" { 101 | t.Errorf("got %q, want empty string", response) 102 | } 103 | }) 104 | } 105 | --------------------------------------------------------------------------------