diff --git a/go.mod b/go.mod index 8b32b61..f326998 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/hoshinonyaruko/palworld-go go 1.21.1 +replace github.com/gorcon/rcon => ./rcon + require github.com/gorcon/rcon v1.3.4 // direct require ( diff --git a/rcon/.gitignore b/rcon/.gitignore new file mode 100644 index 0000000..4ec852f --- /dev/null +++ b/rcon/.gitignore @@ -0,0 +1,3 @@ +.idea/ +.vscode/ +vendor/ diff --git a/rcon/.golangci.yml b/rcon/.golangci.yml new file mode 100644 index 0000000..4711510 --- /dev/null +++ b/rcon/.golangci.yml @@ -0,0 +1,194 @@ +run: + skip-dirs: + - vendor/ + skip-files: + - ".*_test.go$" + +output: + format: colored-line-number + print-issued-lines: true + print-linter-name: true + +# SEE: https://golangci-lint.run/usage/configuration/ +linters-settings: + dupl: + # tokens count to trigger issue, 150 by default + threshold: 100 + errcheck: + # report about not checking of errors in type assertions: `a := b.(MyStruct)`; + # default is false: such cases aren't reported by default. + check-type-assertions: false + # report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`; + # default is false: such cases aren't reported by default. + check-blank: false + funlen: + # default is 60 + lines: 60 + # default is 40 + statements: 40 + gocognit: + # minimal code complexity to report, 30 by default (but we recommend 10-20) + min-complexity: 15 + goconst: + # minimal length of string constant, 3 by default + min-len: 3 + # minimal occurrences count to trigger, 3 by default + min-occurrences: 2 + gocritic: + enabled-tags: + - performance + - style + - experimental + disabled-checks: + - paramTypeCombine + # - whyNoLint + # - commentedOutCode + gocyclo: + # minimal code complexity to report, 30 by default (but we recommend 10-20) + min-complexity: 15 + cyclop: + max-complexity: 15 + godox: + keywords: + - "BUG" + - "FIXME" + # - "TODO" + goimports: + # put imports beginning with prefix after 3rd-party packages; + # it's a comma-separated list of prefixes + local-prefixes: github.com/golangci/golangci-lint + golint: + # minimal confidence for issues, default is 0.8 + min-confidence: 0 + gomnd: + settings: + mnd: + # the list of enabled checks, see https://github.com/tommy-muehle/go-mnd/#checks for description. + checks: argument,case,condition,operation,return,assign + govet: + # report about shadowed variables. + check-shadowing: true + depguard: + list-type: blacklist + packages: + # logging is allowed only by logutils.Log, logrus + # is allowed to use only in logutils package + - github.com/Sirupsen/logrus + - gopkg.in/sirupsen/logrus.v0 + - gopkg.in/sirupsen/logrus.v1 + - gopkg.in/Sirupsen/logrus.v0 + - gopkg.in/Sirupsen/logrus.v1 + lll: + line-length: 120 # 120 is default + maligned: + # print struct with more effective memory layout or not, false by default + suggest-new: true + misspell: + locale: US + nakedret: + # make an issue if func has more lines of code than this setting and it has naked returns; default is 30 + max-func-lines: 30 + unparam: + # Inspect exported functions, default is false. Set to true if no external program/library imports your code. + # XXX: if you enable this setting, unparam will report a lot of false-positives in text editors: + # if it's called for subdir of a project it can't find external interfaces. All text editor integrations + # with golangci-lint call it on a directory with the changed file. + check-exported: false + unused: + # treat code as a program (not a library) and report unused exported identifiers; default is false. + # XXX: if you enable this setting, unused will report a lot of false-positives in text editors: + # if it's called for subdir of a project it can't find funcs usages. All text editor integrations + # with golangci-lint call it on a directory with the changed file. + check-exported: false + whitespace: + multi-if: false # Enforces newlines (or comments) after every multi-line if statement + multi-func: false # Enforces newlines (or comments) after every multi-line function signature + wsl: + # If true append is only allowed to be cuddled if appending value is + # matching variables, fields or types on line above. Default is true. + strict-append: true + # Allow calls and assignments to be cuddled as long as the lines have any + # matching variables, fields or types. Default is true. + allow-assign-and-call: true + # Allow multiline assignments to be cuddled. Default is true. + allow-multiline-assign: true + # Allow declarations (var) to be cuddled. + allow-cuddle-declarations: true + # Allow trailing comments in ending of blocks + allow-trailing-comment: true + # Force newlines in end of case at this limit (0 = never). + force-case-trailing-whitespace: 0 + varnamelen: + # The longest distance, in source lines, that is being considered a "small scope." (defaults to 5) + # Variables used in at most this many lines will be ignored. + max-distance: 5 + # The minimum length of a variable's name that is considered "long." (defaults to 3) + # Variable names that are at least this long will be ignored. + min-name-length: 3 + # Check method receivers. (defaults to false) + check-receiver: false + # Check named return values. (defaults to false) + check-return: false + # Check type parameters. (defaults to false) + check-type-param: false + # Ignore "ok" variables that hold the bool return value of a type assertion. (defaults to false) + ignore-type-assert-ok: false + # Ignore "ok" variables that hold the bool return value of a map index. (defaults to false) + ignore-map-index-ok: false + # Ignore "ok" variables that hold the bool return value of a channel receive. (defaults to false) + ignore-chan-recv-ok: false + # Optional list of variable names that should be ignored completely. (defaults to empty list) + ignore-names: + - err + # Optional list of variable declarations that should be ignored completely. (defaults to empty list) + # Entries must be in one of the following forms (see below for examples): + # - for variables, parameters, named return values, method receivers, or type parameters: + # ( can also be a pointer/slice/map/chan/...) + # - for constants: const + ignore-decls: + - t testing.T + - f *foo.Bar + - e error + - i int + - const C + - T any + - m map[string]int + - x int + - y int + - w io.Writer + - r io.Reader + - i int64 + - f *os.File + - m int + - n int64 + - i int32 + - c *Context + +linters: + enable-all: true + disable: + - interfacer # is deprecated (since v1.38.0) + - scopelint # is deprecated (since v1.39.0) + - golint # is deprecated (since v1.41.0) + - maligned # is deprecated (since v1.38.0) + - ifshort # is deprecated (since v1.48.0) + - deadcode # is deprecated (since v1.49.0) + - nosnakecase # is deprecated (since v1.48.1) + - varcheck # is deprecated (since v1.49.0) + - exhaustivestruct # is deprecated (since v1.46.0) + - structcheck # is deprecated (since v1.49.0) + - rowserrcheck # is disabled because of generics + - sqlclosecheck # is disabled because of generics + - structcheck # is disabled because of generics + - wastedassign # is disabled because of generics + - dupword + - gomnd + - wrapcheck + +issues: + exclude: + - "don't use ALL_CAPS in Go names; use CamelCase" # golint + - "ST1003: should not use ALL_CAPS in Go names; use CamelCase instead" # stylecheck + - "shadow: declaration of \"err\"" # govet + - "DefaultSettings`? is a global variable" # gochecknoglobals + - "are|is missing in" # exhaustivestruct # v1.33 diff --git a/rcon/CHANGELOG.md b/rcon/CHANGELOG.md new file mode 100644 index 0000000..739efd6 --- /dev/null +++ b/rcon/CHANGELOG.md @@ -0,0 +1,107 @@ +# Changelog +All notable changes to this project will be documented in this file. + +**ATTN**: This project uses [semantic versioning](http://semver.org/). + +## [Unreleased] + +## [v1.3.4] - 2022-11-12 +### Fixed +- Minor fixes in packet package. + +### Updated +- Updated Golang version to 1.19. +- Updated golangci linter to 1.50.1 version. + +## [v1.3.3] - 2022-05-16 +### Fixed +- Added "response from not rcon server" error on auth request (Re fixed panic: runtime error: makeslice: len out of range in rcon.Dial #5). + +## [v1.3.2] - 2022-05-16 +### Fixed +- Fixed panic: runtime error: makeslice: len out of range in rcon.Dial + +### Updated +- Updated golangci linter to 1.42.1 version + +## [v1.3.1] - 2021-01-06 +### Updated +- Updated golangci linter to 1.33 version + +### Changed +- Changed errors handling - added wrapping. + +## [v1.3.0] - 2020-12-02 +### Fixed +- Fixed wrong number of bytes written in Packet WriteTo function. + +### Added +- Added rcontest Server for mocking RCON connections. + +## [v1.2.4] - 2020-11-14 +### Added +- Added the ability to run tests on a real Project Zomboid server. To do this, set environment variables +`TEST_PZ_SERVER=true`, `TEST_PZ_SERVER_ADDR` and `TEST_PZ_SERVER_PASSWORD` with address and password from Project Zomboid +remote console. +- Added the ability to run tests on a real Rust server. To do this, set environment variables `TEST_RUST_SERVER=true`, +`TEST_RUST_SERVER_ADDR` and `TEST_RUST_SERVER_PASSWORD` with address and password from Rust remote console. +- Added invalid padding test. + +### Changed +- Changed CI workflows and related badges. Integration with Travis-CI was changed to GitHub actions workflow. Golangci-lint +job was joined with tests workflow. + +## [v1.2.3] - 2020-10-20 +### Fixed +- Fixed read/write deadline. The deadline was started from the moment the connection was established and was not updated +after the command was sent. + +## [v1.2.2] - 2020-10-18 +### Added +- Added one more workaround for Rust server. When sent command "Say" there is no response data from server +with packet.ID = SERVERDATA_EXECCOMMAND_ID, only previous console message that command was received with +packet.ID = -1, therefore, forcibly set packet.ID to SERVERDATA_EXECCOMMAND_ID. + +## [v1.2.1] - 2020-10-06 +### Added +- Added authentication failed test. + +### Changed +- Updated Golang version to 1.15. + +## [v1.2.0] - 2020-07-10 +### Added +- Added options to Dial. It is possible to set timeout and deadline settings. + +### Fixed +- Change `SERVERDATA_AUTH_ID` and `SERVERDATA_EXECCOMMAND_ID` from 42 to 0. Conan Exiles has a bug because of which it +always responds 42 regardless of the value of the request ID. This is no longer relevant, so the values have been +changed. + +### Changed +- Renamed `DefaultTimeout` const to `DefaultDeadline` +- Changed default timeouts from 10 seconds to 5 seconds + +## [v1.1.2] - 2020-05-13 +### Added +- Added go modules (go 1.13). +- Added golangci.yml linter config. To run linter use `golangci-lint run` command. +- Added CHANGELOG.md. +- Added more tests. + +## v1.0.0 - 2019-07-27 +### Added +- Initial implementation. + +[Unreleased]: https://github.com/gorcon/rcon/compare/v1.3.4...HEAD +[v1.3.4]: https://github.com/gorcon/rcon/compare/v1.3.3...v1.3.4 +[v1.3.3]: https://github.com/gorcon/rcon/compare/v1.3.2...v1.3.3 +[v1.3.2]: https://github.com/gorcon/rcon/compare/v1.3.1...v1.3.2 +[v1.3.1]: https://github.com/gorcon/rcon/compare/v1.3.0...v1.3.1 +[v1.3.0]: https://github.com/gorcon/rcon/compare/v1.2.4...v1.3.0 +[v1.2.4]: https://github.com/gorcon/rcon/compare/v1.2.3...v1.2.4 +[v1.2.3]: https://github.com/gorcon/rcon/compare/v1.2.2...v1.2.3 +[v1.2.2]: https://github.com/gorcon/rcon/compare/v1.2.1...v1.2.2 +[v1.2.1]: https://github.com/gorcon/rcon/compare/v1.2.0...v1.2.1 +[v1.2.0]: https://github.com/gorcon/rcon/compare/v1.1.2...v1.2.0 +[v1.1.2]: https://github.com/gorcon/rcon/compare/v1.0.0...v1.1.2 diff --git a/rcon/LICENSE b/rcon/LICENSE new file mode 100644 index 0000000..a5db1e2 --- /dev/null +++ b/rcon/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Pavel Korotkiy (outdead) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/rcon/README.md b/rcon/README.md new file mode 100644 index 0000000..a3d8517 --- /dev/null +++ b/rcon/README.md @@ -0,0 +1,68 @@ +# Rcon +[![GitHub Build](https://github.com/gorcon/rcon/workflows/build/badge.svg)](https://github.com/gorcon/rcon/actions) +[![Coverage](https://gocover.io/_badge/github.com/gorcon/rcon?0 "coverage")](https://gocover.io/github.com/gorcon/rcon) +[![Go Report Card](https://goreportcard.com/badge/github.com/gorcon/rcon)](https://goreportcard.com/report/github.com/gorcon/rcon) +[![GoDoc](https://img.shields.io/badge/godoc-reference-blue.svg)](https://godoc.org/github.com/gorcon/rcon) + +Source RCON Protocol implementation in Go. + +## Protocol Specifications +RCON Protocol described in the [valve documentation](https://developer.valvesoftware.com/wiki/Source_RCON_Protocol). + +## Supported Games +* [Project Zomboid](https://store.steampowered.com/app/108600) +* [Conan Exiles](https://store.steampowered.com/app/440900) +* [Rust](https://store.steampowered.com/app/252490) (add +rcon.web 0 to the args when starting the server) +* [ARK: Survival Evolved](https://store.steampowered.com/app/346110) +* [Counter-Strike: Global Offensive](https://store.steampowered.com/app/730) +* [Minecraft](https://www.minecraft.net) + +Open pull request if you have successfully used a package with another game with rcon support and add it to the list. + +## Install +```text +go get github.com/gorcon/rcon +``` + +See [Changelog](CHANGELOG.md) for release details. + +## Usage +```go +package main + +import ( + "log" + "fmt" + + "github.com/gorcon/rcon" +) + +func main() { + conn, err := rcon.Dial("127.0.0.1:16260", "password") + if err != nil { + log.Fatal(err) + } + defer conn.Close() + + response, err := conn.Execute("help") + if err != nil { + log.Fatal(err) + } + + fmt.Println(response) +} +``` + +## Requirements +Go 1.15 or higher + +## Contribute +Contributions are more than welcome! + +If you think that you have found a bug, create an issue and publish the minimum amount of code triggering the bug so +it can be reproduced. + +If you want to fix the bug then you can create a pull request. If possible, write a test that will cover this bug. + +## License +MIT License, see [LICENSE](LICENSE) diff --git a/rcon/go.mod b/rcon/go.mod new file mode 100644 index 0000000..54a54ff --- /dev/null +++ b/rcon/go.mod @@ -0,0 +1,3 @@ +module github.com/gorcon/rcon + +go 1.19 diff --git a/rcon/go.sum b/rcon/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/rcon/option.go b/rcon/option.go new file mode 100644 index 0000000..346435d --- /dev/null +++ b/rcon/option.go @@ -0,0 +1,32 @@ +package rcon + +import "time" + +// Settings contains option to Conn. +type Settings struct { + dialTimeout time.Duration + deadline time.Duration +} + +// DefaultSettings provides default deadline settings to Conn. +var DefaultSettings = Settings{ + dialTimeout: DefaultDialTimeout, + deadline: DefaultDeadline, +} + +// Option allows to inject settings to Settings. +type Option func(s *Settings) + +// SetDialTimeout injects dial Timeout to Settings. +func SetDialTimeout(timeout time.Duration) Option { + return func(s *Settings) { + s.dialTimeout = timeout + } +} + +// SetDeadline injects read/write Timeout to Settings. +func SetDeadline(timeout time.Duration) Option { + return func(s *Settings) { + s.deadline = timeout + } +} diff --git a/rcon/packet.go b/rcon/packet.go new file mode 100644 index 0000000..cea34cc --- /dev/null +++ b/rcon/packet.go @@ -0,0 +1,144 @@ +package rcon + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" +) + +// Packet sizes definitions. +const ( + PacketPaddingSize int32 = 2 // Size of Packet's padding. + PacketHeaderSize int32 = 8 // Size of Packet's header. + + MinPacketSize = PacketPaddingSize + PacketHeaderSize + MaxPacketSize = 4096 + MinPacketSize +) + +// Packet is a rcon packet. Both requests and responses are sent as +// TCP packets. Their payload follows the following basic structure. +type Packet struct { + // The packet size field is a 32-bit little endian integer, representing + // the length of the request in bytes. Note that the packet size field + // itself is not included when determining the size of the packet, + // so the value of this field is always 4 less than the packet's actual + // length. The minimum possible value for packet size is 10. + // The maximum possible value of packet size is 4096. + // If the response is too large to fit into one packet, it will be split + // and sent as multiple packets. + Size int32 + + // The packet id field is a 32-bit little endian integer chosen by the + // client for each request. It may be set to any positive integer. + // When the RemoteServer responds to the request, the response packet + // will have the same packet id as the original request (unless it is + // a failed SERVERDATA_AUTH_RESPONSE packet). + // It need not be unique, but if a unique packet id is assigned, + // it can be used to match incoming responses to their corresponding requests. + ID int32 + + // The packet type field is a 32-bit little endian integer, which indicates + // the purpose of the packet. Its value will always be either 0, 2, or 3, + // depending on which of the following request/response types the packet + // represents: + // SERVERDATA_AUTH = 3, + // SERVERDATA_AUTH_RESPONSE = 2, + // SERVERDATA_EXECCOMMAND = 2, + // SERVERDATA_RESPONSE_VALUE = 0. + Type int32 + + // The packet body field is a null-terminated string encoded in ASCII + // (i.e. ASCIIZ). Depending on the packet type, it may contain either the + // RCON MockPassword for the RemoteServer, the command to be executed, + // or the RemoteServer's response to a request. + body []byte +} + +// NewPacket creates and initializes a new Packet using packetType, +// packetID and body as its initial contents. NewPacket is intended to +// calculate packet size from body length and 10 bytes for rcon headers +// and termination strings. +func NewPacket(packetType int32, packetID int32, body string) *Packet { + size := len([]byte(body)) + int(PacketHeaderSize+PacketPaddingSize) + + return &Packet{ + Size: int32(size), + Type: packetType, + ID: packetID, + body: []byte(body), + } +} + +// Body returns packet bytes body as a string. +func (packet *Packet) Body() string { + return string(packet.body) +} + +// WriteTo implements io.WriterTo for write a packet to w. +func (packet *Packet) WriteTo(w io.Writer) (int64, error) { + buffer := bytes.NewBuffer(make([]byte, 0, packet.Size+4)) + + _ = binary.Write(buffer, binary.LittleEndian, packet.Size) + _ = binary.Write(buffer, binary.LittleEndian, packet.ID) + _ = binary.Write(buffer, binary.LittleEndian, packet.Type) + + // Write command body, null terminated ASCII string and an empty ASCIIZ string. + buffer.Write(append(packet.body, 0x00, 0x00)) + + return buffer.WriteTo(w) +} + +// ReadFrom implements io.ReaderFrom for read a packet from r. +func (packet *Packet) ReadFrom(r io.Reader) (int64, error) { + var n int64 + + if err := binary.Read(r, binary.LittleEndian, &packet.Size); err != nil { + return n, fmt.Errorf("rcon: read packet size: %w", err) + } + + n += 4 + + if packet.Size < MinPacketSize { + return n, ErrResponseTooSmall + } + + if err := binary.Read(r, binary.LittleEndian, &packet.ID); err != nil { + return n, fmt.Errorf("rcon: read packet id: %w", err) + } + + n += 4 + + if err := binary.Read(r, binary.LittleEndian, &packet.Type); err != nil { + return n, fmt.Errorf("rcon: read packet type: %w", err) + } + + n += 4 + + // String can actually include null characters which is the case in + // response to a SERVERDATA_RESPONSE_VALUE packet. + packet.body = make([]byte, packet.Size-PacketHeaderSize) + + var i int32 + for i < packet.Size-PacketHeaderSize { + var m int + var err error + + if m, err = r.Read(packet.body[i:]); err != nil { + return n + int64(m) + int64(i), fmt.Errorf("rcon: %w", err) + } + + i += int32(m) + } + + n += int64(i) + + // Remove null terminated strings from response body. + if !bytes.Equal(packet.body[len(packet.body)-int(PacketPaddingSize):], []byte{0x00, 0x00}) { + return n, ErrInvalidPacketPadding + } + + packet.body = packet.body[0 : len(packet.body)-int(PacketPaddingSize)] + + return n, nil +} diff --git a/rcon/packet_test.go b/rcon/packet_test.go new file mode 100644 index 0000000..bedabce --- /dev/null +++ b/rcon/packet_test.go @@ -0,0 +1,159 @@ +package rcon + +import ( + "bytes" + "encoding/binary" + "errors" + "io" + "testing" +) + +func TestNewPacket(t *testing.T) { + body := []byte("testdata") + packet := NewPacket(SERVERDATA_RESPONSE_VALUE, 42, string(body)) + + if packet.Body() != string(body) { + t.Errorf("%q, want %q", packet.Body(), body) + } + + want := int32(len([]byte(body))) + PacketHeaderSize + PacketPaddingSize + if packet.Size != want { + t.Errorf("got %d, want %d", packet.Size, want) + } +} + +func TestPacket_WriteTo(t *testing.T) { + t.Run("check bytes written", func(t *testing.T) { + body := []byte("testdata") + packet := NewPacket(SERVERDATA_RESPONSE_VALUE, 42, string(body)) + + var buffer bytes.Buffer + n, err := packet.WriteTo(&buffer) + if err != nil { + t.Fatal(err) + } + + wantN := packet.Size + 4 + if n != int64(wantN) { + t.Errorf("got %d, want %d", n, int64(wantN)) + } + }) +} + +func TestPacket_ReadFrom(t *testing.T) { + t.Run("check read", func(t *testing.T) { + body := []byte("testdata") + packetWant := NewPacket(SERVERDATA_RESPONSE_VALUE, 42, string(body)) + + var buffer bytes.Buffer + nWant, err := packetWant.WriteTo(&buffer) + if err != nil { + t.Fatal(err) + } + + packetGot := new(Packet) + nGot, err := packetGot.ReadFrom(&buffer) + if err != nil { + t.Fatal(err) + } + + if nGot != nWant { + t.Fatalf("got %d, want %d", nGot, nWant) + } + + if packetGot.Body() != packetWant.Body() { + t.Fatalf("got %q, want %q", packetGot.body, packetWant.body) + } + }) + + t.Run("EOF", func(t *testing.T) { + var buffer bytes.Buffer + + packetGot := new(Packet) + nGot, err := packetGot.ReadFrom(&buffer) + if !errors.Is(err, io.EOF) { + t.Fatalf("got %q, want %q", err, io.EOF) + } + + if nGot != 0 { + t.Fatalf("got %d, want %d", nGot, 0) + } + }) + + t.Run("response too small", func(t *testing.T) { + var buffer bytes.Buffer + + packetGot := new(Packet) + binary.Write(&buffer, binary.LittleEndian, packetGot.Size) + + _, err := packetGot.ReadFrom(&buffer) + if !errors.Is(err, ErrResponseTooSmall) { + t.Fatalf("got %q, want %q", err, ErrResponseTooSmall) + } + }) + + t.Run("EOF 2", func(t *testing.T) { + var buffer bytes.Buffer + binary.Write(&buffer, binary.LittleEndian, int32(18)) + + packetGot := new(Packet) + nGot, err := packetGot.ReadFrom(&buffer) + if !errors.Is(err, io.EOF) { + t.Fatalf("got %q, want %q", err, io.EOF) + } + + if nGot != 4 { + t.Fatalf("got %d, want %d", nGot, 4) + } + }) + + t.Run("EOF 3", func(t *testing.T) { + var buffer bytes.Buffer + binary.Write(&buffer, binary.LittleEndian, int32(18)) + binary.Write(&buffer, binary.LittleEndian, int32(42)) + + packetGot := new(Packet) + nGot, err := packetGot.ReadFrom(&buffer) + if !errors.Is(err, io.EOF) { + t.Fatalf("got %q, want %q", err, io.EOF) + } + + if nGot != 8 { + t.Fatalf("got %d, want %d", nGot, 8) + } + }) + + t.Run("padding", func(t *testing.T) { + body := []byte("testdata") + packetWant := NewPacket(SERVERDATA_RESPONSE_VALUE, 42, string(body)) + packetWant.Size = 10 + var buffer bytes.Buffer + _, err := packetWant.WriteTo(&buffer) + if err != nil { + t.Fatal(err) + } + + packetGot := new(Packet) + _, err = packetGot.ReadFrom(&buffer) + if !errors.Is(err, ErrInvalidPacketPadding) { + t.Fatalf("got %q, want %q", err, ErrInvalidPacketPadding) + } + }) + + t.Run("EOF 4", func(t *testing.T) { + var buffer bytes.Buffer + binary.Write(&buffer, binary.LittleEndian, int32(18)) + binary.Write(&buffer, binary.LittleEndian, int32(42)) + buffer.Write(append([]byte("testdata"), 0x00, 0x00)) + + packetGot := new(Packet) + nGot, err := packetGot.ReadFrom(&buffer) + if !errors.Is(err, io.EOF) { + t.Fatalf("got %q, want %q", err, io.EOF) + } + + if nGot != 18 { + t.Fatalf("got %d, want %d", nGot, 18) + } + }) +} diff --git a/rcon/rcon.go b/rcon/rcon.go new file mode 100644 index 0000000..4843c43 --- /dev/null +++ b/rcon/rcon.go @@ -0,0 +1,357 @@ +// Package rcon implements Source RCON Protocol which is described in the +// documentation: https://developer.valvesoftware.com/wiki/Source_RCON_Protocol. +package rcon + +import ( + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "net" + "os" + "path/filepath" + "time" +) + +const ( + // DefaultDialTimeout provides default auth timeout to remote server. + DefaultDialTimeout = 5 * time.Second + + // DefaultDeadline provides default deadline to tcp read/write operations. + DefaultDeadline = 5 * time.Second + + // MaxCommandLen is an artificial restriction, but it will help in case of random + // large queries. + MaxCommandLen = 1000 + + // SERVERDATA_AUTH is the first packet sent by the client, + // which is used to authenticate the conn with the server. + SERVERDATA_AUTH int32 = 3 + + // SERVERDATA_AUTH_ID is any positive integer, chosen by the client + // (will be mirrored back in the server's response). + SERVERDATA_AUTH_ID int32 = 0 + + // SERVERDATA_AUTH_RESPONSE packet is a notification of the conn's current auth + // status. When the server receives an auth request, it will respond with an empty + // SERVERDATA_RESPONSE_VALUE, followed immediately by a SERVERDATA_AUTH_RESPONSE + // indicating whether authentication succeeded or failed. Note that the status + // code is returned in the packet id field, so when pairing the response with + // the original auth request, you may need to look at the packet id of the + // preceding SERVERDATA_RESPONSE_VALUE. + // If authentication was successful, the ID assigned by the request. + // If auth failed, -1 (0xFF FF FF FF). + SERVERDATA_AUTH_RESPONSE int32 = 2 + + // SERVERDATA_RESPONSE_VALUE packet is the response to a SERVERDATA_EXECCOMMAND + // request. The ID assigned by the original request. + SERVERDATA_RESPONSE_VALUE int32 = 0 + + // SERVERDATA_EXECCOMMAND packet type represents a command issued to the server + // by a client. The response will vary depending on the command issued. + SERVERDATA_EXECCOMMAND int32 = 2 + + // SERVERDATA_EXECCOMMAND_ID is any positive integer, chosen by the client + // (will be mirrored back in the server's response). + SERVERDATA_EXECCOMMAND_ID int32 = 0 +) + +var ( + // ErrAuthNotRCON is returned when got auth response with negative size. + ErrAuthNotRCON = errors.New("response from not rcon server") + + // ErrInvalidAuthResponse is returned when we didn't get an auth packet + // back for second read try after discard empty SERVERDATA_RESPONSE_VALUE + // from authentication response. + ErrInvalidAuthResponse = errors.New("invalid authentication packet type response") + + // ErrAuthFailed is returned when the package id from authentication + // response is -1. + ErrAuthFailed = errors.New("authentication failed") + + // ErrInvalidPacketID is returned when the package id from server response + // was not mirrored back from request. + ErrInvalidPacketID = errors.New("response for another request") + + // ErrInvalidPacketPadding is returned when the bytes after type field from + // response is not equal to null-terminated ASCII strings. + ErrInvalidPacketPadding = errors.New("invalid response padding") + + // ErrResponseTooSmall is returned when the server response is smaller + // than 10 bytes. + ErrResponseTooSmall = errors.New("response too small") + + // ErrCommandTooLong is returned when executed command length is bigger + // than MaxCommandLen characters. + ErrCommandTooLong = errors.New("command too long") + + // ErrCommandEmpty is returned when executed command length equal 0. + ErrCommandEmpty = errors.New("command too small") + + // ErrMultiErrorOccurred is returned when close connection failed with + // error after auth failed. + ErrMultiErrorOccurred = errors.New("an error occurred while handling another error") +) + +// Conn is source RCON generic stream-oriented network connection. +type Conn struct { + conn net.Conn + settings Settings +} + +// Dial creates a new authorized Conn tcp dialer connection. +func Dial(address string, password string, options ...Option) (*Conn, error) { + settings := DefaultSettings + + for _, option := range options { + option(&settings) + } + + conn, err := net.DialTimeout("tcp", address, settings.dialTimeout) + if err != nil { + // Failed to open TCP connection to the server. + return nil, fmt.Errorf("rcon: %w", err) + } + + client := Conn{conn: conn, settings: settings} + + if err := client.auth(password); err != nil { + // Failed to auth conn with the server. + if err2 := client.Close(); err2 != nil { + return &client, fmt.Errorf("%w: %v. Previous error: %v", ErrMultiErrorOccurred, err2, err) + } + + return &client, fmt.Errorf("rcon: %w", err) + } + + return &client, nil +} + +// Execute sends command type and it string to execute to the remote server, +// creating a packet with a SERVERDATA_EXECCOMMAND_ID for the server to mirror, +// and compiling its payload bytes in the appropriate order. The response body +// is decompiled from bytes into a string for return. +// Execute 根据条件执行命令 +func (c *Conn) Execute(command string, isUseDLL bool) (string, error) { + if isUseDLL { + // 尝试读取palguard.json文件来判断是否使用Base64执行命令 + relativePath := "..\\PalServer\\Pal\\Binaries\\Win64\\palguard.json" + absPath, err := filepath.Abs(relativePath) + if err == nil { + if _, err := os.Stat(absPath); err == nil { + // 如果文件存在,则读取并解析JSON + jsonFile, err := os.ReadFile(absPath) + if err == nil { + var config map[string]interface{} + if json.Unmarshal(jsonFile, &config) == nil { + if rconBase64, ok := config["RCONbase64"].(bool); ok && rconBase64 { + fmt.Printf("rcon ExecuteWithBase64:%v\n", command) + // 如果RCONbase64为true,则使用Base64方式执行命令 + return c.ExecuteWithBase64(command) + } + } + } + } + } + } + + // 原有逻辑 + if command == "" { + return "", ErrCommandEmpty + } + + if len(command) > MaxCommandLen { + return "", ErrCommandTooLong + } + + if err := c.write(SERVERDATA_EXECCOMMAND, SERVERDATA_EXECCOMMAND_ID, command); err != nil { + return "", err + } + + response, err := c.read() + if err != nil { + return "", err + } + + if response.ID != SERVERDATA_EXECCOMMAND_ID { + return "", ErrInvalidPacketID + } + + return response.Body(), nil // 假设response有Body()方法返回响应体 +} + +// ExecuteWithBase64 sends a Base64 encoded command and decodes Base64 encoded response from the remote server. +func (c *Conn) ExecuteWithBase64(command string) (string, error) { + if command == "" { + return "", ErrCommandEmpty + } + + encodedCommand := base64.StdEncoding.EncodeToString([]byte(command)) + + if len(encodedCommand) > MaxCommandLen { + return "", ErrCommandTooLong + } + + if err := c.write(SERVERDATA_EXECCOMMAND, SERVERDATA_EXECCOMMAND_ID, encodedCommand); err != nil { + return "", err + } + + response, err := c.read() + if err != nil { + return "", err + } + + if response.ID != SERVERDATA_EXECCOMMAND_ID { + return "", ErrInvalidPacketID + } + + // 尝试解码响应体 + decodedBody, decodeErr := base64.StdEncoding.DecodeString(response.Body()) + if decodeErr != nil { + // 如果解码失败,返回原始响应体 + return response.Body(), nil + } + + // 如果解码成功,返回解码后的字符串 + return string(decodedBody), nil +} + +// LocalAddr returns the local network address. +func (c *Conn) LocalAddr() net.Addr { + return c.conn.LocalAddr() +} + +// RemoteAddr returns the remote network address. +func (c *Conn) RemoteAddr() net.Addr { + return c.conn.RemoteAddr() +} + +// Close closes the connection. +func (c *Conn) Close() error { + return c.conn.Close() +} + +// auth sends SERVERDATA_AUTH request to the remote server and +// authenticates client for the next requests. +func (c *Conn) auth(password string) error { + if err := c.write(SERVERDATA_AUTH, SERVERDATA_AUTH_ID, password); err != nil { + return err + } + + if c.settings.deadline != 0 { + if err := c.conn.SetReadDeadline(time.Now().Add(c.settings.deadline)); err != nil { + return fmt.Errorf("rcon: %w", err) + } + } + + response, err := c.readHeader() + if err != nil { + return err + } + + size := response.Size - PacketHeaderSize + if size < 0 { + return ErrAuthNotRCON + } + + // When the server receives an auth request, it will respond with an empty + // SERVERDATA_RESPONSE_VALUE, followed immediately by a SERVERDATA_AUTH_RESPONSE + // indicating whether authentication succeeded or failed. + // Some servers doesn't send an empty SERVERDATA_RESPONSE_VALUE packet, so we + // do this case optional. + if response.Type == SERVERDATA_RESPONSE_VALUE { + // Discard empty SERVERDATA_RESPONSE_VALUE from authentication response. + _, _ = c.conn.Read(make([]byte, size)) + + if response, err = c.readHeader(); err != nil { + return err + } + } + + // We must to read response body. + buffer := make([]byte, size) + if _, err := c.conn.Read(buffer); err != nil { + return fmt.Errorf("rcon: %w", err) + } + + if response.Type != SERVERDATA_AUTH_RESPONSE { + return ErrInvalidAuthResponse + } + + if response.ID == -1 { + return ErrAuthFailed + } + + if response.ID != SERVERDATA_AUTH_ID { + return ErrInvalidPacketID + } + + return nil +} + +// write creates packet and writes it to established tcp conn. +func (c *Conn) write(packetType int32, packetID int32, command string) error { + if c.settings.deadline != 0 { + if err := c.conn.SetWriteDeadline(time.Now().Add(c.settings.deadline)); err != nil { + return fmt.Errorf("rcon: %w", err) + } + } + + packet := NewPacket(packetType, packetID, command) + _, err := packet.WriteTo(c.conn) + + return err +} + +// read reads structured binary data from c.conn into packet. +func (c *Conn) read() (*Packet, error) { + if c.settings.deadline != 0 { + if err := c.conn.SetReadDeadline(time.Now().Add(c.settings.deadline)); err != nil { + return nil, fmt.Errorf("rcon: %w", err) + } + } + + packet := &Packet{} + if _, err := packet.ReadFrom(c.conn); err != nil { + return packet, err + } + + // Workaround for Rust server. + // Rust rcon server responses packet with a type of 4 and the next packet + // is valid. It is undocumented, so skip packet and read next. + if packet.Type == 4 { + if _, err := packet.ReadFrom(c.conn); err != nil { + return packet, err + } + + // One more workaround for Rust server. + // When sent command "Say" there is no response data from server with + // packet.ID = SERVERDATA_EXECCOMMAND_ID, only previous console message + // that command was received with packet.ID = -1, therefore, forcibly + // set packet.ID to SERVERDATA_EXECCOMMAND_ID. + if packet.ID == -1 { + packet.ID = SERVERDATA_EXECCOMMAND_ID + } + } + + return packet, nil +} + +// readHeader reads structured binary data without body from c.conn into packet. +func (c *Conn) readHeader() (Packet, error) { + var packet Packet + if err := binary.Read(c.conn, binary.LittleEndian, &packet.Size); err != nil { + return packet, fmt.Errorf("rcon: read packet size: %w", err) + } + + if err := binary.Read(c.conn, binary.LittleEndian, &packet.ID); err != nil { + return packet, fmt.Errorf("rcon: read packet id: %w", err) + } + + if err := binary.Read(c.conn, binary.LittleEndian, &packet.Type); err != nil { + return packet, fmt.Errorf("rcon: read packet type: %w", err) + } + + return packet, nil +} diff --git a/rcon/rcon_test.go b/rcon/rcon_test.go new file mode 100644 index 0000000..b8d62f4 --- /dev/null +++ b/rcon/rcon_test.go @@ -0,0 +1,419 @@ +package rcon_test + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "io" + "os" + "strings" + "testing" + "time" + + "github.com/gorcon/rcon" + "github.com/gorcon/rcon/rcontest" +) + +func authHandler(c *rcontest.Context) { + switch c.Request().Body() { + case "invalid packet type": + rcon.NewPacket(42, c.Request().ID, "").WriteTo(c.Conn()) + case "another": + rcon.NewPacket(rcon.SERVERDATA_AUTH_RESPONSE, 42, "").WriteTo(c.Conn()) + case "makeslice": + size := int32(len([]byte(""))) + + buffer := bytes.NewBuffer(make([]byte, 0, size+4)) + + _ = binary.Write(buffer, binary.LittleEndian, size) + _ = binary.Write(buffer, binary.LittleEndian, c.Request().ID) + _ = binary.Write(buffer, binary.LittleEndian, rcon.SERVERDATA_RESPONSE_VALUE) + + buffer.WriteTo(c.Conn()) + case c.Server().Settings.Password: + rcon.NewPacket(rcon.SERVERDATA_RESPONSE_VALUE, c.Request().ID, "").WriteTo(c.Conn()) + rcon.NewPacket(rcon.SERVERDATA_AUTH_RESPONSE, c.Request().ID, "").WriteTo(c.Conn()) + default: + rcon.NewPacket(rcon.SERVERDATA_AUTH_RESPONSE, -1, string([]byte{0x00})).WriteTo(c.Conn()) + } +} + +func commandHandler(c *rcontest.Context) { + writeWithInvalidPadding := func(conn io.Writer, packet *rcon.Packet) { + buffer := bytes.NewBuffer(make([]byte, 0, packet.Size+4)) + + binary.Write(buffer, binary.LittleEndian, packet.Size) + binary.Write(buffer, binary.LittleEndian, packet.ID) + binary.Write(buffer, binary.LittleEndian, packet.Type) + + // Write command body, null terminated ASCII string and an empty ASCIIZ string. + // Second padding byte is incorrect. + buffer.Write(append([]byte(packet.Body()), 0x00, 0x01)) + + buffer.WriteTo(conn) + } + + switch c.Request().Body() { + case "help": + responseBody := "lorem ipsum dolor sit amet" + rcon.NewPacket(rcon.SERVERDATA_RESPONSE_VALUE, c.Request().ID, responseBody).WriteTo(c.Conn()) + case "rust": + // Write specific Rust package. + rcon.NewPacket(4, c.Request().ID, "").WriteTo(c.Conn()) + + rcon.NewPacket(rcon.SERVERDATA_RESPONSE_VALUE, -1, c.Request().Body()).WriteTo(c.Conn()) + case "padding": + writeWithInvalidPadding(c.Conn(), rcon.NewPacket(rcon.SERVERDATA_RESPONSE_VALUE, c.Request().ID, "")) + case "another": + rcon.NewPacket(rcon.SERVERDATA_RESPONSE_VALUE, 42, "").WriteTo(c.Conn()) + default: + rcon.NewPacket(rcon.SERVERDATA_RESPONSE_VALUE, c.Request().ID, "unknown command").WriteTo(c.Conn()) + } +} + +func TestDial(t *testing.T) { + server := rcontest.NewServer(rcontest.SetSettings(rcontest.Settings{Password: "password"})) + defer server.Close() + + t.Run("connection refused", func(t *testing.T) { + wantErrContains := "connect: connection refused" + + _, err := rcon.Dial("127.0.0.2:12345", "password") + if err == nil || !strings.Contains(err.Error(), wantErrContains) { + t.Errorf("got err %q, want to contain %q", err, wantErrContains) + } + }) + + t.Run("connection timeout", func(t *testing.T) { + server := rcontest.NewServer(rcontest.SetSettings(rcontest.Settings{Password: "password", AuthResponseDelay: 6 * time.Second})) + defer server.Close() + + wantErrContains := "i/o timeout" + + _, err := rcon.Dial(server.Addr(), "", rcon.SetDialTimeout(5*time.Second)) + if err == nil || !strings.Contains(err.Error(), wantErrContains) { + t.Errorf("got err %q, want to contain %q", err, wantErrContains) + } + }) + + t.Run("authentication failed", func(t *testing.T) { + _, err := rcon.Dial(server.Addr(), "wrong") + if !errors.Is(err, rcon.ErrAuthFailed) { + t.Errorf("got err %q, want %q", err, rcon.ErrAuthFailed) + } + }) + + t.Run("invalid packet type", func(t *testing.T) { + server := rcontest.NewServer( + rcontest.SetSettings(rcontest.Settings{Password: "password"}), + rcontest.SetAuthHandler(authHandler), + ) + defer server.Close() + + _, err := rcon.Dial(server.Addr(), "invalid packet type") + if !errors.Is(err, rcon.ErrInvalidAuthResponse) { + t.Errorf("got err %q, want %q", err, rcon.ErrInvalidAuthResponse) + } + }) + + t.Run("invalid response id", func(t *testing.T) { + server := rcontest.NewServer( + rcontest.SetSettings(rcontest.Settings{Password: "password"}), + rcontest.SetAuthHandler(authHandler), + ) + defer server.Close() + + _, err := rcon.Dial(server.Addr(), "another") + if !errors.Is(err, rcon.ErrInvalidPacketID) { + t.Errorf("got err %q, want %q", err, rcon.ErrInvalidPacketID) + } + }) + + t.Run("makeslice", func(t *testing.T) { + server := rcontest.NewServer( + rcontest.SetSettings(rcontest.Settings{Password: "makeslice"}), + rcontest.SetAuthHandler(authHandler), + ) + defer server.Close() + + _, err := rcon.Dial(server.Addr(), "makeslice") + if !errors.Is(err, rcon.ErrAuthNotRCON) { + t.Errorf("got err %q, want %q", err, rcon.ErrAuthNotRCON) + } + }) + + t.Run("auth success", func(t *testing.T) { + conn, err := rcon.Dial(server.Addr(), "password") + if err != nil { + t.Errorf("got err %q, want %v", err, nil) + return + } + + conn.Close() + }) +} + +func TestConn_Execute(t *testing.T) { + server := rcontest.NewUnstartedServer() + server.Settings.Password = "password" + server.SetCommandHandler(commandHandler) + server.Start() + defer server.Close() + + t.Run("incorrect command", func(t *testing.T) { + conn, err := rcon.Dial(server.Addr(), "password") + if err != nil { + t.Fatalf("got err %q, want %v", err, nil) + } + defer conn.Close() + + result, err := conn.Execute("") + if !errors.Is(err, rcon.ErrCommandEmpty) { + t.Errorf("got err %q, want %q", err, rcon.ErrCommandEmpty) + } + + if len(result) != 0 { + t.Fatalf("got result len %d, want %d", len(result), 0) + } + + result, err = conn.Execute(string(make([]byte, 1001))) + if !errors.Is(err, rcon.ErrCommandTooLong) { + t.Errorf("got err %q, want %q", err, rcon.ErrCommandTooLong) + } + + if len(result) != 0 { + t.Fatalf("got result len %d, want %d", len(result), 0) + } + }) + + t.Run("closed network connection 1", func(t *testing.T) { + conn, err := rcon.Dial(server.Addr(), "password", rcon.SetDeadline(0)) + if err != nil { + t.Fatalf("got err %q, want %v", err, nil) + } + conn.Close() + + result, err := conn.Execute("help") + wantErrMsg := fmt.Sprintf("write tcp %s->%s: use of closed network connection", conn.LocalAddr(), conn.RemoteAddr()) + if err == nil || err.Error() != wantErrMsg { + t.Errorf("got err %q, want to contain %q", err, wantErrMsg) + } + + if len(result) != 0 { + t.Fatalf("got result len %d, want %d", len(result), 0) + } + }) + + t.Run("closed network connection 2", func(t *testing.T) { + conn, err := rcon.Dial(server.Addr(), "password") + if err != nil { + t.Fatalf("got err %q, want %v", err, nil) + } + conn.Close() + + result, err := conn.Execute("help") + wantErrMsg := fmt.Sprintf("rcon: set tcp %s: use of closed network connection", conn.LocalAddr()) + if err == nil || err.Error() != wantErrMsg { + t.Errorf("got err %q, want to contain %q", err, wantErrMsg) + } + + if len(result) != 0 { + t.Fatalf("got result len %d, want %d", len(result), 0) + } + }) + + t.Run("read deadline", func(t *testing.T) { + server := rcontest.NewServer(rcontest.SetSettings(rcontest.Settings{Password: "password", CommandResponseDelay: 2 * time.Second})) + defer server.Close() + + conn, err := rcon.Dial(server.Addr(), "password", rcon.SetDeadline(1*time.Second)) + if err != nil { + t.Fatalf("got err %q, want %v", err, nil) + } + defer conn.Close() + + result, err := conn.Execute("deadline") + wantErrMsg := fmt.Sprintf("rcon: read packet size: read tcp %s->%s: i/o timeout", conn.LocalAddr(), conn.RemoteAddr()) + if err == nil || err.Error() != wantErrMsg { + t.Errorf("got err %q, want to contain %q", err, wantErrMsg) + } + + if len(result) != 0 { + t.Fatalf("got result len %d, want %d", len(result), 0) + } + }) + + t.Run("invalid padding", func(t *testing.T) { + conn, err := rcon.Dial(server.Addr(), "password") + if err != nil { + t.Fatalf("got err %q, want %v", err, nil) + } + defer conn.Close() + + result, err := conn.Execute("padding") + if !errors.Is(err, rcon.ErrInvalidPacketPadding) { + t.Errorf("got err %q, want %q", err, rcon.ErrInvalidPacketPadding) + } + + if len(result) != 2 { + t.Fatalf("got result len %d, want %d", len(result), 2) + } + }) + + t.Run("invalid response id", func(t *testing.T) { + conn, err := rcon.Dial(server.Addr(), "password") + if err != nil { + t.Fatalf("got err %q, want %v", err, nil) + } + defer conn.Close() + + result, err := conn.Execute("another") + if !errors.Is(err, rcon.ErrInvalidPacketID) { + t.Errorf("got err %q, want %q", err, rcon.ErrInvalidPacketID) + } + + if len(result) != 0 { + t.Fatalf("got result len %d, want %d", len(result), 0) + } + }) + + t.Run("success help command", func(t *testing.T) { + conn, err := rcon.Dial(server.Addr(), "password") + if err != nil { + t.Fatalf("got err %q, want %v", err, nil) + } + defer conn.Close() + + result, err := conn.Execute("help") + if err != nil { + t.Fatalf("got err %q, want %v", err, nil) + } + + resultWant := "lorem ipsum dolor sit amet" + if result != resultWant { + t.Fatalf("got result %q, want %q", result, resultWant) + } + }) + + t.Run("rust workaround", func(t *testing.T) { + conn, err := rcon.Dial(server.Addr(), "password", rcon.SetDeadline(1*time.Second)) + if err != nil { + t.Fatalf("got err %q, want %v", err, nil) + } + defer conn.Close() + + result, err := conn.Execute("rust") + if err != nil { + t.Fatalf("got err %q, want %v", err, nil) + } + + resultWant := "rust" + if result != resultWant { + t.Fatalf("got result %q, want %q", result, resultWant) + } + }) + + if run := getVar("TEST_PZ_SERVER", "false"); run == "true" { + addr := getVar("TEST_PZ_SERVER_ADDR", "127.0.0.1:16260") + password := getVar("TEST_PZ_SERVER_PASSWORD", "docker") + + t.Run("pz server", func(t *testing.T) { + needle := func() string { + n := `List of server commands : +* 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 +* adduser : Use this command to add a new user to a whitelisted server. Use: /adduser "username" "password" +* addvehicle : Spawn a vehicle. Use: /addvehicle "script" "user or x,y,z", ex /addvehicle "Base.VanAmbulance" "rj" +* addxp : Give XP to a player. Use /addxp "playername" perkname=xp. Example /addxp "rj" Woodwork=2 +* alarm : Sound a building alarm at the Admin's position. (Must be in a room) +* banid : Ban a SteamID. Use /banid SteamID +* 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" +* changeoption : Change a server option. Use: /changeoption optionName "newValue" +* chopper : Place a helicopter event on a random player +* 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 +* createhorde2 : UI_ServerOptionDesc_CreateHorde2 +* 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) +* gunshot : Place a gunshot sound on a random player +* help : Help +* 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) +* kick : Kick a user. Add a -r "reason" to specify a reason for the kick. Use: /kickuser "username" -r "reason" +* lightning : Use /lightning "username", username is optional except from the server console +* noclip : Makes a player pass through walls and structures. Toggles with no value. Use: /noclip "username" -value. Example /noclip "rj" -true (could be -false) +* players : List all connected players +* quit : Save and quit the server +* releasesafehouse : Release a safehouse you own. Use /releasesafehouse +* reloadlua : Reload a Lua script on the server. Use /reloadlua "filename" +* reloadoptions : Reload server options (ServerOptions.ini) and send to clients +* removeuserfromwhitelist : Remove a user from the whitelist. Use: /removeuserfromwhitelist "username" +* removezombies : UI_ServerOptionDesc_RemoveZombies +* replay : Record and play replay for moving player. Use /replay "playername" -record|-play|-stop filename. Example: /replay user1 -record stadion.bin +* save : Save the current world +* servermsg : Broadcast a message to all connected players. Use: /servermsg "My Message" +* setaccesslevel : Set access level of a player. Current levels: Admin, Moderator, Overseer, GM, Observer. Use /setaccesslevel "username" "accesslevel". Example /setaccesslevel "rj" "moderator" +* showoptions : Show the list of current server options and values. +* startrain : Starts raining on the server. Use /startrain "intensity", optional intensity is from 1 to 100 +* startstorm : Starts a storm on the server. Use /startstorm "duration", optional duration is in game hours +* stoprain : Stop raining on the server +* stopweather : Stop weather on the server +* 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" +* teleportto : Teleport to coordinates. Use /teleportto x,y,z. Example /teleportto 10000,11000,0 +* thunder : Use /thunder "username", username is optional except from the server console +* unbanid : Unban a SteamID. Use /unbanid SteamID +* unbanuser : Unban a player. Use /unbanuser "username" +* voiceban : Block voice from user "username". Use /voiceban "username" -value. Example /voiceban "rj" -true (could be -false)` + + n = strings.Replace(n, "List of server commands :", "List of server commands : ", -1) + + return n + }() + + conn, err := rcon.Dial(addr, password) + if err != nil { + t.Fatalf("got err %q, want %v", err, nil) + } + defer conn.Close() + + result, err := conn.Execute("help") + if err != nil { + t.Fatalf("got err %q, want %v", err, nil) + } + + if result != needle { + t.Fatalf("got result %q, want %q", result, needle) + } + }) + } + + if run := getVar("TEST_RUST_SERVER", "false"); run == "true" { + addr := getVar("TEST_RUST_SERVER_ADDR", "127.0.0.1:28016") + password := getVar("TEST_RUST_SERVER_PASSWORD", "docker") + + t.Run("rust server", func(t *testing.T) { + conn, err := rcon.Dial(addr, password) + if err != nil { + t.Fatalf("got err %q, want %v", err, nil) + } + defer conn.Close() + + result, err := conn.Execute("status") + if err != nil { + t.Fatalf("got err %q, want %v", err, nil) + } + + if result == "" { + t.Fatal("got empty result, want value") + } + + fmt.Println(result) + }) + } +} + +// getVar returns environment variable or default value. +func getVar(key string, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + + return fallback +} diff --git a/rcon/rcontest/context.go b/rcon/rcontest/context.go new file mode 100644 index 0000000..87c54a4 --- /dev/null +++ b/rcon/rcontest/context.go @@ -0,0 +1,30 @@ +package rcontest + +import ( + "net" + + "github.com/gorcon/rcon" +) + +// Context represents the context of the current RCON request. It holds request +// and conn objects. +type Context struct { + server *Server + conn net.Conn + request *rcon.Packet +} + +// Server returns the Server instance. +func (c *Context) Server() *Server { + return c.server +} + +// Conn returns current RCON connection. +func (c *Context) Conn() net.Conn { + return c.conn +} + +// Request returns received *rcon.Packet. +func (c *Context) Request() *rcon.Packet { + return c.request +} diff --git a/rcon/rcontest/example_test.go b/rcon/rcontest/example_test.go new file mode 100644 index 0000000..f7012f3 --- /dev/null +++ b/rcon/rcontest/example_test.go @@ -0,0 +1,48 @@ +package rcontest_test + +import ( + "fmt" + "log" + + "github.com/gorcon/rcon" + "github.com/gorcon/rcon/rcontest" +) + +func ExampleServer() { + server := rcontest.NewServer( + rcontest.SetSettings(rcontest.Settings{Password: "password"}), + rcontest.SetCommandHandler(func(c *rcontest.Context) { + switch c.Request().Body() { + case "Hello, server": + rcon.NewPacket(rcon.SERVERDATA_RESPONSE_VALUE, c.Request().ID, "Hello, client").WriteTo(c.Conn()) + default: + rcon.NewPacket(rcon.SERVERDATA_RESPONSE_VALUE, c.Request().ID, "unknown command").WriteTo(c.Conn()) + } + }), + ) + defer server.Close() + + client, err := rcon.Dial(server.Addr(), "password") + if err != nil { + log.Fatal(err) + } + defer client.Close() + + response, err := client.Execute("Hello, server") + if err != nil { + log.Fatal(err) + } + + fmt.Println(response) + + response, err = client.Execute("Hi!") + if err != nil { + log.Fatal(err) + } + + fmt.Println(response) + + // Output: + // Hello, client + // unknown command +} diff --git a/rcon/rcontest/option.go b/rcon/rcontest/option.go new file mode 100644 index 0000000..ac27811 --- /dev/null +++ b/rcon/rcontest/option.go @@ -0,0 +1,25 @@ +package rcontest + +// Option allows to inject Settings to Server. +type Option func(s *Server) + +// SetSettings injects configuration for RCON Server. +func SetSettings(settings Settings) Option { + return func(s *Server) { + s.Settings = settings + } +} + +// SetAuthHandler injects HandlerFunc with authorisation data checking. +func SetAuthHandler(handler HandlerFunc) Option { + return func(s *Server) { + s.SetAuthHandler(handler) + } +} + +// SetCommandHandler injects HandlerFunc with commands processing. +func SetCommandHandler(handler HandlerFunc) Option { + return func(s *Server) { + s.SetCommandHandler(handler) + } +} diff --git a/rcon/rcontest/server.go b/rcon/rcontest/server.go new file mode 100644 index 0000000..a7e77f5 --- /dev/null +++ b/rcon/rcontest/server.go @@ -0,0 +1,242 @@ +// Package rcontest contains RCON server for RCON client testing. +package rcontest + +import ( + "errors" + "fmt" + "io" + "net" + "sync" + "time" + + "github.com/gorcon/rcon" +) + +// Server is an RCON server listening on a system-chosen port on the +// local loopback interface, for use in end-to-end RCON tests. +type Server struct { + Settings Settings + Listener net.Listener + addr string + authHandler HandlerFunc + commandHandler HandlerFunc + connections map[net.Conn]struct{} + quit chan bool + wg sync.WaitGroup + mu sync.Mutex + closed bool +} + +// Settings contains configuration for RCON Server. +type Settings struct { + Password string + AuthResponseDelay time.Duration + CommandResponseDelay time.Duration +} + +// HandlerFunc defines a function to serve RCON requests. +type HandlerFunc func(c *Context) + +// AuthHandler checks authorisation data and responses with +// SERVERDATA_AUTH_RESPONSE packet. +func AuthHandler(c *Context) { + if c.Request().Body() == c.Server().Settings.Password { + // First write SERVERDATA_RESPONSE_VALUE packet with empty body. + _, _ = rcon.NewPacket(rcon.SERVERDATA_RESPONSE_VALUE, c.Request().ID, "").WriteTo(c.Conn()) + + // Than write SERVERDATA_AUTH_RESPONSE packet to allow authHandler success. + _, _ = rcon.NewPacket(rcon.SERVERDATA_AUTH_RESPONSE, rcon.SERVERDATA_AUTH_ID, "").WriteTo(c.Conn()) + } else { + // If authentication was failed, the ID must be assigned to -1. + _, _ = rcon.NewPacket(rcon.SERVERDATA_AUTH_RESPONSE, -1, string([]byte{0x00})).WriteTo(c.Conn()) + } +} + +// EmptyHandler responses with empty body. Is used when start RCON Server with nil +// commandHandler. +func EmptyHandler(c *Context) { + _, _ = rcon.NewPacket(rcon.SERVERDATA_RESPONSE_VALUE, c.Request().ID, "").WriteTo(c.Conn()) +} + +func newLocalListener() net.Listener { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + panic(fmt.Sprintf("rcontest: failed to listen on a port: %v", err)) + } + + return l +} + +// NewServer returns a running RCON Server or nil if an error occurred. +// The caller should call Close when finished, to shut it down. +func NewServer(options ...Option) *Server { + server := NewUnstartedServer(options...) + server.Start() + + return server +} + +// NewUnstartedServer returns a new Server but doesn't start it. +// After changing its configuration, the caller should call Start. +// The caller should call Close when finished, to shut it down. +func NewUnstartedServer(options ...Option) *Server { + server := Server{ + Listener: newLocalListener(), + authHandler: AuthHandler, + commandHandler: EmptyHandler, + connections: make(map[net.Conn]struct{}), + quit: make(chan bool), + } + + for _, option := range options { + option(&server) + } + + return &server +} + +// SetAuthHandler injects HandlerFunc with authorisation data checking. +func (s *Server) SetAuthHandler(handler HandlerFunc) { + s.authHandler = handler +} + +// SetCommandHandler injects HandlerFunc with commands processing. +func (s *Server) SetCommandHandler(handler HandlerFunc) { + s.commandHandler = handler +} + +// Start starts a server from NewUnstartedServer. +func (s *Server) Start() { + if s.addr != "" { + panic("server already started") + } + + s.addr = s.Listener.Addr().String() + s.goServe() +} + +// Close shuts down the Server. +func (s *Server) Close() { + if s.closed { + return + } + + s.closed = true + close(s.quit) + s.Listener.Close() + + // Waiting for server connections. + s.wg.Wait() + + s.mu.Lock() + for c := range s.connections { + // Force-close any connections. + s.closeConn(c) + } + s.mu.Unlock() +} + +// Addr returns IPv4 string Server address. +func (s *Server) Addr() string { + return s.addr +} + +// NewContext returns a Context instance. +func (s *Server) NewContext(conn net.Conn) (*Context, error) { + ctx := Context{server: s, conn: conn, request: &rcon.Packet{}} + + if _, err := ctx.request.ReadFrom(conn); err != nil { + return &ctx, fmt.Errorf("rcontest: %w", err) + } + + return &ctx, nil +} + +// serve handles incoming requests until a stop signal is given with Close. +func (s *Server) serve() { + for { + conn, err := s.Listener.Accept() + if err != nil { + if s.isRunning() { + panic(fmt.Errorf("rcontest: %w", err)) + } + + return + } + + s.wg.Add(1) + + go s.handle(conn) + } +} + +// serve calls serve in goroutine. +func (s *Server) goServe() { + s.wg.Add(1) + + go func() { + defer s.wg.Done() + + s.serve() + }() +} + +// handle handles incoming client conn. +func (s *Server) handle(conn net.Conn) { + s.mu.Lock() + s.connections[conn] = struct{}{} + s.mu.Unlock() + + defer func() { + s.closeConn(conn) + s.wg.Done() + }() + + for { + ctx, err := s.NewContext(conn) + if err != nil { + if !errors.Is(err, io.EOF) { + panic(fmt.Errorf("failed read request: %w", err)) + } + + return + } + + switch ctx.Request().Type { + case rcon.SERVERDATA_AUTH: + if s.Settings.AuthResponseDelay != 0 { + time.Sleep(s.Settings.AuthResponseDelay) + } + + s.authHandler(ctx) + case rcon.SERVERDATA_EXECCOMMAND: + if s.Settings.CommandResponseDelay != 0 { + time.Sleep(s.Settings.CommandResponseDelay) + } + + s.commandHandler(ctx) + } + } +} + +// isRunning returns true if Server is running and false if is not. +func (s *Server) isRunning() bool { + select { + case <-s.quit: + return false + default: + return true + } +} + +// closeConn closes a client conn and removes it from connections map. +func (s *Server) closeConn(conn net.Conn) { + s.mu.Lock() + defer s.mu.Unlock() + + if err := conn.Close(); err != nil { + panic(fmt.Errorf("close conn error: %w", err)) + } + + delete(s.connections, conn) +} diff --git a/rcon/rcontest/server_test.go b/rcon/rcontest/server_test.go new file mode 100644 index 0000000..444e6d3 --- /dev/null +++ b/rcon/rcontest/server_test.go @@ -0,0 +1,104 @@ +package rcontest_test + +import ( + "errors" + "testing" + "time" + + "github.com/gorcon/rcon" + "github.com/gorcon/rcon/rcontest" +) + +func TestNewServer(t *testing.T) { + t.Run("with options", func(t *testing.T) { + server := rcontest.NewServer( + rcontest.SetSettings(rcontest.Settings{Password: "password"}), + rcontest.SetAuthHandler(func(c *rcontest.Context) { + if c.Request().Body() == c.Server().Settings.Password { + rcon.NewPacket(rcon.SERVERDATA_AUTH_RESPONSE, c.Request().ID, "").WriteTo(c.Conn()) + } else { + rcon.NewPacket(rcon.SERVERDATA_AUTH_RESPONSE, -1, string([]byte{0x00})).WriteTo(c.Conn()) + } + }), + rcontest.SetCommandHandler(func(c *rcontest.Context) { + rcon.NewPacket(rcon.SERVERDATA_RESPONSE_VALUE, c.Request().ID, "Can I help you?").WriteTo(c.Conn()) + }), + ) + defer server.Close() + + client, err := rcon.Dial(server.Addr(), "password") + if err != nil { + t.Fatal(err) + } + defer client.Close() + + response, err := client.Execute("Can I help you?") + if err != nil { + t.Fatal(err) + } + + if response != "Can I help you?" { + t.Errorf("got %q, want Can I help you?", response) + } + }) + + t.Run("unstarted", func(t *testing.T) { + server := rcontest.NewUnstartedServer() + server.Settings.Password = "password" + server.Settings.AuthResponseDelay = 10 * time.Millisecond + server.Settings.CommandResponseDelay = 10 * time.Millisecond + server.SetCommandHandler(func(c *rcontest.Context) { + rcon.NewPacket(rcon.SERVERDATA_RESPONSE_VALUE, c.Request().ID, "I do it all.").WriteTo(c.Conn()) + }) + server.Start() + defer server.Close() + + client, err := rcon.Dial(server.Addr(), "password") + if err != nil { + t.Fatal(err) + } + defer client.Close() + + response, err := client.Execute("What do you do?") + if err != nil { + t.Fatal(err) + } + + if response != "I do it all." { + t.Errorf("got %q, want I do it all.", response) + } + }) + + t.Run("authentication failed", func(t *testing.T) { + server := rcontest.NewServer() + defer server.Close() + + client, err := rcon.Dial(server.Addr(), "wrong") + if err != nil { + defer client.Close() + } + if !errors.Is(err, rcon.ErrAuthFailed) { + t.Fatal(err) + } + }) + + t.Run("empty handler", func(t *testing.T) { + server := rcontest.NewServer() + defer server.Close() + + client, err := rcon.Dial(server.Addr(), "") + if err != nil { + t.Fatal(err) + } + defer client.Close() + + response, err := client.Execute("whatever") + if err != nil { + t.Fatal(err) + } + + if response != "" { + t.Errorf("got %q, want empty string", response) + } + }) +} diff --git a/webui/dist2/index.html b/webui/dist2/index.html index 7425c29..02092f6 100644 --- a/webui/dist2/index.html +++ b/webui/dist2/index.html @@ -1,18 +1,18 @@ - - - - - - - - Palworld Server Configuration Generator - - - - - -
- - - + + + + + + + + Palworld Server Configuration Generator + + + + + +
+ + + \ No newline at end of file