58 Commits

Author SHA1 Message Date
6b33882c0c remove colour codes from output strings 2026-02-18 10:42:04 +00:00
abb33742aa replace logrus with charm/log 2026-02-18 10:41:42 +00:00
51e8ac85be use fs.GetArgs() 2026-02-17 16:41:42 +00:00
79d53f34da reword 2026-02-17 16:37:25 +00:00
7c5a3523bf fix install command 2026-02-17 16:34:09 +00:00
44a528e31d add v0.5.0 to CHANGELOG 2026-02-17 16:30:14 +00:00
48b23321e5 fix help usage 2026-02-17 16:26:31 +00:00
d05ed91473 fix NAME desc in help output 2026-02-17 16:13:50 +00:00
5c28c4e8b7 upd date 2026-02-17 16:10:57 +00:00
53f30981fd bump version in CHANGELOG
add note about CLI env vars
2026-02-17 16:10:09 +00:00
fce6fa43fc Reimplement the CLI with flags first package.
CLI configuration may now be managed with env vars.

improved CLI help output
2026-02-17 16:09:53 +00:00
0b9546ee0e update README 2026-02-17 16:06:58 +00:00
a55de6fe50 upd host shortflag to -H
upd readme
2026-02-17 10:25:16 +00:00
e4f3366a67 rewrite versionFromBuild() 2026-02-16 01:26:12 +00:00
0fe373d1d1 replace logrus with charmbracelet/log 2026-02-16 01:13:05 +00:00
e0668d11d9 upd go version in go.mod 2026-02-16 01:11:48 +00:00
f3853f6a4d rename taskfile version var 2026-02-16 01:04:08 +00:00
c4e2dacee9 add --version flag
ensure version is injected at build time in makefile/taskfile
2026-02-16 00:55:17 +00:00
3be7ddb36b ensure we return closer from run() 2026-02-16 00:11:50 +00:00
c22b07808f upd imgs 2026-02-15 19:37:41 +00:00
c015770c2c add v0.4.0 to CHANGELOG 2026-02-15 18:35:52 +00:00
cd15e89837 add macos target to goreleaser config 2026-02-15 18:31:25 +00:00
c3e8013c4f make the example implementation a little more useful with a timeouts map. 2026-02-15 18:29:14 +00:00
89dd2d2eb1 replace exitOnError with deferred exit function.
this ensures the closer() cleanup function is always called
2026-02-15 18:24:59 +00:00
c04301562e add macos target to Taskfile/makefile 2026-02-15 17:21:47 +00:00
e25104091d add golangci-lint config + action 2026-02-15 17:21:05 +00:00
c478598112 add chore: to changelog filter 2026-01-28 21:44:22 +00:00
github-actions[bot]
b224f2dc43 chore: auto-update Go modules 2025-07-14 00:21:53 +00:00
github-actions[bot]
c4b587ee65 chore: auto-update Go modules 2025-05-12 00:20:44 +00:00
github-actions[bot]
8f252951ff chore: auto-update Go modules 2025-04-07 00:19:10 +00:00
35ffa55fb9 -loglevel now of string type
Some checks failed
Auto-Update Go Modules / update-go-modules (push) Has been cancelled
update Logging section in README.

upd CHANGELOG
2025-04-05 22:46:58 +01:00
github-actions[bot]
19f5ec4a76 chore: auto-update Go modules
Some checks failed
Auto-Update Go Modules / update-go-modules (push) Has been cancelled
2025-03-10 00:15:16 +00:00
github-actions[bot]
5173f32fde chore: auto-update Go modules
Some checks failed
Auto-Update Go Modules / update-go-modules (push) Has been cancelled
2025-02-24 00:17:41 +00:00
7782e7f8bf add update and release actions
Some checks failed
Auto-Update Go Modules / update-go-modules (push) Has been cancelled
2025-02-17 13:31:09 +00:00
09f316e5f4 run through formatter 2025-02-07 23:25:29 +00:00
430e9be1f7 reorder tasks 2025-02-03 18:18:10 +00:00
a27809643a add taskfile 2025-02-03 18:16:38 +00:00
485978956c send 'status' if not in interactive and no commands passed
remove interactive global var.
2025-02-03 17:26:42 +00:00
c51dba5ead add link to q3rcon-proxy 2024-11-29 04:25:56 +00:00
0bc19a718b add section 0.1.0 to CHANGELOG 2024-11-29 01:09:30 +00:00
adebc61b98 change -P flag to -r for setting rcon password
update README
2024-11-29 01:07:05 +00:00
51ff562ac4 use EqualFold 2024-11-25 19:08:02 +00:00
313d96fffa Create LICENSE 2024-11-25 03:43:02 +00:00
cbc8ee5543 add section 0.0.3 to CHANGELOG 2024-11-24 13:18:49 +00:00
3e1088d625 {Rcon}.login no longer exported.
split the cmd from its args when checking timeout map.

remove final TrimPrefix, it's already handled in {Rcon}.listen()
2024-11-24 13:13:07 +00:00
65ab17b9c9 add whitespace for readability 2024-11-22 13:20:27 +00:00
d180c455a3 fix typo 2024-11-09 18:59:01 +00:00
b20bca0c77 Request now uses reusable buffer 2024-11-08 15:31:30 +00:00
7d787324a7 move option functions into option.go 2024-11-08 15:31:13 +00:00
8c1bc0f04c move listen() into Rcon
rename channel variables
2024-11-08 15:30:47 +00:00
dd1d530d44 add img/ 2024-11-04 22:01:01 +00:00
f2752f5609 print usage message if not in interactive mode, and no rcon commands passed 2024-11-04 21:23:45 +00:00
05ee3f1912 reword 2024-11-04 17:46:11 +00:00
a7702c8050 add functional option WithLoginTimeout
use it to set the login timeout
2024-11-04 16:13:31 +00:00
08af9d3388 fix -p flag usage message 2024-11-04 15:30:03 +00:00
46d3e3fa4a left align example snippets 2024-11-04 15:10:12 +00:00
07ffd98e7a remove Logging highlight 2024-11-04 15:05:57 +00:00
bc373e72ae add your own implementation section 2024-11-04 15:05:13 +00:00
21 changed files with 895 additions and 183 deletions

30
.github/workflows/golang-ci.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: CI
on:
push:
branches: ['main']
paths:
- '**.go'
pull_request:
branches: ['main']
paths:
- '**.go'
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
timeout-minutes: 3
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.6.0
args: --config .golangci.yml

31
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,31 @@
name: goreleaser
on:
push:
tags:
- 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
-
name: Set up Go
uses: actions/setup-go@v5
-
name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

30
.github/workflows/update-go-modules.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: Auto-Update Go Modules
on:
schedule:
- cron: "0 0 * * 1" # Runs every Monday at midnight
jobs:
update-go-modules:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: stable
- name: Update Dependencies
run: |
go get -u ./...
go mod tidy
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add go.mod go.sum
git commit -m "chore: auto-update Go modules"
git push

6
.gitignore vendored
View File

@@ -24,5 +24,9 @@ go.work.sum
# env file # env file
.env .env
.envrc
cmd/aeiou # Added by goreleaser init:
dist/
cmd/codrcon

142
.golangci.yml Normal file
View File

@@ -0,0 +1,142 @@
version: '2'
run:
timeout: 3m
tests: true
go: '1.24'
linters:
disable: [errcheck, errorlint, godot, revive, staticcheck]
enable:
# Default enabled linters
- errcheck # Check for unchecked errors
- govet # Go's built-in vetting tool
- ineffassign # Detect ineffectual assignments
- staticcheck # Advanced static analysis
- unused # Check for unused code
# Additional useful linters
- misspell # Detect common misspellings
- unparam # Check for unused function parameters
- gosec # Security checks
- asciicheck # Check for non-ASCII characters
- errname # Check error variable names
- godot # Check for missing periods in comments
- revive # Highly configurable linter for style and correctness
- gocritic # Detect code issues and suggest improvements
- gocyclo # Check for cyclomatic complexity
- dupl # Check for code duplication
- predeclared # Check for shadowing of predeclared identifiers
- copyloopvar # Check for loop variable capture in goroutines
- errorlint # Check for common mistakes in error handling
- goconst # Check for repeated strings that could be constants
- gosmopolitan # Check for non-portable code
settings:
misspell:
locale: UK
errcheck:
check-type-assertions: true
check-blank: true
exclude-functions:
- fmt.Fprintf
- fmt.Fprintln
- fmt.Printf
- fmt.Println
- fmt.Errorf
revive:
severity: warning
rules:
# Code quality and style
- name: exported
arguments:
- 'checkPrivateReceivers'
- 'sayRepetitiveInsteadOfStutters'
- name: var-naming
- name: package-comments
- name: range-val-in-closure
- name: time-naming
- name: context-as-argument
- name: context-keys-type
- name: dot-imports
- name: empty-block
- name: error-return
- name: error-strings
- name: error-naming
- name: if-return
- name: increment-decrement
- name: indent-error-flow
- name: receiver-naming
- name: redefines-builtin-id
- name: superfluous-else
- name: unexported-return
- name: unreachable-code
- name: unused-parameter
- name: var-declaration
- name: blank-imports
- name: range
# Disabled rules (can be enabled if needed)
# - name: line-length-limit
# arguments: [120]
# - name: function-length
# arguments: [50, 0]
# - name: cyclomatic
# arguments: [10]
gosec:
excludes:
- G104 # Duplicated errcheck checks
- G115 # integer overflow conversion int -> uint32
exclusions:
warn-unused: false
rules:
# Exclude some linters from running on tests files.
- path: _test\.go
linters:
- gocyclo
- errcheck
- dupl
- gosec
paths:
- vendor
# Formatters configuration
formatters:
# Enable specific formatters
enable:
- gofumpt # Stricter gofmt alternative
- goimports # Organizes imports
- gci # Controls import order/grouping
- golines # Enforces line length
# Formatter-specific settings
settings:
goimports:
local-prefixes: [github.com/onyx-and-iris/q3rcon]
gci:
# Define import sections order
sections:
- standard # Standard library
- default # Everything else
- prefix(github.com/onyx-and-iris/q3rcon) # Current module
gofumpt:
extra-rules: true # Enable additional formatting rules
exclusions:
warn-unused: true
paths:
- vendor
issues:
# Limit the number of same issues reported to avoid spam
max-same-issues: 50
# Limit the number of issues per linter to keep output manageable
max-issues-per-linter: 100

57
.goreleaser.yaml Normal file
View File

@@ -0,0 +1,57 @@
# This is an example .goreleaser.yml file with some sensible defaults.
# Make sure to check the documentation at https://goreleaser.com
# The lines below are called `modelines`. See `:help modeline`
# Feel free to remove those if you don't want/need to use them.
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
version: 2
before:
hooks:
# You may remove this if you don't use go modules.
- go mod tidy
# you may remove this if you don't need go generate
- go generate ./...
builds:
- main: ./cmd/q3rcon/
env:
- CGO_ENABLED=0
goos:
- linux
- windows
- darwin
goarch:
- amd64
archives:
- formats: ['tar.gz']
# this name template makes the OS and Arch compatible with the results of `uname`.
name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
# use zip for windows archives
format_overrides:
- goos: windows
formats: ['zip']
changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'
- '^chore:'
release:
footer: >-
---
Released by [GoReleaser](https://github.com/goreleaser/goreleaser).

View File

@@ -11,9 +11,63 @@ Before any major/minor/patch bump all unit tests will be run to verify they pass
- [x] - [x]
# [0.5.0] - 2026-02-17
### Added
- CLI configuration can be managed through env vars, see [Environment Variables](https://github.com/onyx-and-iris/q3rcon?tab=readme-ov-file#environment-variables) under Configuration in README.
### Changed
- The CLI now supports `--long` and `-short` style flags. Several examples in README.
- `--help` output has been improved.
# [0.4.1] - 2026-02-15
### Added
- macos build to releases
### Changed
- exitOnError() removed in favour of a [deferred exit function](https://github.com/onyx-and-iris/q3rcon/blob/cd15e8983726177d6edd985a8bf3d7f4e0d7f346/cmd/q3rcon/main.go#L21), this ensures the closer() cleanup function is always called.
- the included CLI now uses a [timeouts map](https://github.com/onyx-and-iris/q3rcon/blob/cd15e8983726177d6edd985a8bf3d7f4e0d7f346/cmd/q3rcon/main.go#L109).
- even though this is only an example implementation it should still be basically usable.
# [0.3.0] - 2025-04-05
### Changed
- `-loglevel` flag is now of type string. It accepts any one of trace, debug, info, warn, error, fatal or panic.
- It defaults to warn.
# [0.2.0] - 2025-02-03
### Added
- If neither the interactive flag is passed or any command line arguments then a *rcon status* command will be run.
# [0.1.0] - 2024-11-29
### Changed
- `-P` flag changed to `-r` for setting rcon password. This is to disambiguate it from the port (-p) flag.
# [0.0.3] - 2024-11-24
### Changed
- {Rcon}.login is no longer exported since it's called internally by the constructor.
- When checking the timeouts map the cmd is split from its arguments. This allows setting a timeout value for all `map mp_` for example.
### Added
- Timeout values for commands in the timeouts map are now logged at Debug level.
# [0.0.1] - 2024-11-04 # [0.0.1] - 2024-11-04
### Added ### Added
- Initial release, package implements Rcon using the Q3 procotocl. - Initial release, package implements Rcon using the Q3 protocol.
- A basic CLI implementation accepting configuration flags. - A basic CLI implementation accepting configuration flags.

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Onyx and Iris
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.

101
README.md
View File

@@ -17,9 +17,15 @@ Quake3 Rcon works by firing UDP packets to the game server port, responses may b
Rcon itself is insecure and each packet includes the password so I don't suggest using it remotely. If you have direct access to the server then SSH in first, then use this tool locally. Rcon itself is insecure and each packet includes the password so I don't suggest using it remotely. If you have direct access to the server then SSH in first, then use this tool locally.
## Use ---
`go get github.com/onyx-and-iris/q3rcon` ## Package
#### Use
```console
go get github.com/onyx-and-iris/q3rcon
```
```go ```go
package main package main
@@ -52,9 +58,21 @@ func main() {
} }
``` ```
#### `WithLoginTimeout(timeout time.Duration)`
If the server was just started or is currently performing a long operation like map rotating then it's possible to receive empty rcon responses. In which case you'll want to retry login. Use this functional option to set the max timeout for logins, it defaults to 5 seconds.
For example:
```go
rcon, err := q3rcon.New(
host, port, password,
q3rcon.WithLoginTimeout(2*time.Second))
```
#### `WithDefaultTimeout(timeout time.Duration)` #### `WithDefaultTimeout(timeout time.Duration)`
You may want to change the default timeout if some of your responses are getting mixed together (stability can depend on connection to the server). For example, on LAN I can leave the default at 20ms, when connecting remotely I normally increase this to 50ms. You may want to change the default timeout if some of your responses are getting mixed together (stability can depend on connection to the server). For example, over localhost I can leave the default at 20ms, when connecting remotely I normally increase this to 50ms.
For example: For example:
@@ -79,32 +97,91 @@ Perhaps there are some requests that take a long time to receive a response but
q3rcon.WithTimeouts(timeouts)) q3rcon.WithTimeouts(timeouts))
``` ```
---
## Command line ## Command line
Pass `host`, `port` and `password` as flags, for example: ### Install
```console
go install github.com/onyx-and-iris/q3rcon/cmd/q3rcon@latest
``` ```
q3rcon -h=localhost -p=30000 -P="rconpassword" "mapname"
### Configuration
#### Flags
Pass `--host`, `--port` and `--rconpass` as flags, for example:
```console
q3rcon --host=localhost --port=30000 --rconpass="rconpassword" "mapname"
``` ```
- `host` defaults to "localhost" - `host` defaults to "localhost"
- `port` defaults to 28960 - `port` defaults to 28960
- `password` defaults to "" - `rconpass` defaults to ""
Arguments following the flags will be sent as rcon commands. You may send multiple arguments. Arguments following the flags will be sent as rcon commands. You may send multiple arguments.
#### Interactive mode #### Environment Variables
example .envrc:
```bash
#!/usr/bin/env bash
export Q3RCON_HOST="localhost"
export Q3RCON_PORT=28960
export Q3RCON_RCONPASS="rconpassword"
```
### Interactive mode
Pass `interactive (-i shorthand)` flag to enable interactive mode, for example: Pass `interactive (-i shorthand)` flag to enable interactive mode, for example:
``` ```console
q3rcon -h=localhost -p=30000 -P="rconpassword" -i q3rcon -H=localhost -p=30000 -r="rconpassword" -i
``` ```
If interactive mode is enabled, any arguments sent on the command line will be ignored. If interactive mode is enabled, any arguments sent on the command line will be ignored.
## `Logging` ---
Log level may be set by passing the `-l` flag with a number from 0 up to 6 where ## Your own implementation
0 = Panic, 1 = Fatal, 2 = Error, 3 = Warning, 4 = Info, 5 = Debug, 6 = Trace The included CLI is a generic implementation, while it can be used out of the box you may find that some requests result in fragmented responses. The solution is to implement your own version, adjusting the timings with the functional options as detailed above.
Since you can include the q3rcon package into your own CLI/package you can easily make your own modifications, for example, I added [colour to the terminal][status] and [tabulated some of the responses][mapname].
---
## Logging
The `--loglevel` flag allows you to control the verbosity of the application's logging output.
Acceptable values for this flag are:
- `trace`
- `debug`
- `info`
- `warn`
- `error`
- `fatal`
- `panic`
For example, to set the log level to `debug`, you can use:
```console
q3rcon -H=localhost -p=28960 -r="rconpassword" -l=debug -i
```
The default log level is `warn` if the flag is not specified.
## Further Notes
This rcon client is fully compatible with the [Q3 Rcon Proxy][q3rcon-proxy] package.
[status]: ./img/status.png
[mapname]: ./img/mapname.png
[q3rcon-proxy]: https://github.com/onyx-and-iris/q3rcon-proxy/tree/dev

65
Taskfile.yml Normal file
View File

@@ -0,0 +1,65 @@
version: '3'
vars:
PROGRAM: q3rcon
SHELL: '{{if eq .OS "Windows_NT"}}powershell{{end}}'
BIN_DIR: bin
WINDOWS: '{{.BIN_DIR}}/{{.PROGRAM}}_windows_amd64.exe'
LINUX: '{{.BIN_DIR}}/{{.PROGRAM}}_linux_amd64'
MACOS: '{{.BIN_DIR}}/{{.PROGRAM}}_darwin_amd64'
VERSION:
sh: 'git describe --tags $(git rev-list --tags --max-count=1)'
tasks:
default:
desc: Build the q3rcon project
cmds:
- task: build
build:
desc: Build the q3rcon project
deps: [vet]
cmds:
- task: build-windows
- task: build-linux
- task: build-macos
vet:
desc: Vet the code
deps: [fmt]
cmds:
- go vet ./...
fmt:
desc: Fmt the code
cmds:
- go fmt ./...
build-windows:
desc: Build the q3rcon project for Windows
cmds:
- GOOS=windows GOARCH=amd64 go build -o {{.WINDOWS}} -ldflags="-X main.version={{.VERSION}}" ./cmd/{{.PROGRAM}}/
internal: true
build-linux:
desc: Build the q3rcon project for Linux
cmds:
- GOOS=linux GOARCH=amd64 go build -o {{.LINUX}} -ldflags="-X main.version={{.VERSION}}" ./cmd/{{.PROGRAM}}/
internal: true
build-macos:
desc: Build the q3rcon project for macOS
cmds:
- GOOS=darwin GOARCH=amd64 go build -o {{.MACOS}} -ldflags="-X main.version={{.VERSION}}" ./cmd/{{.PROGRAM}}/
internal: true
test:
desc: Run tests
cmds:
- go test ./...
clean:
desc: Clean the build artifacts
cmds:
- '{{.SHELL}} rm -r {{.BIN_DIR}}'

View File

@@ -2,98 +2,223 @@ package main
import ( import (
"bufio" "bufio"
"flag" "errors"
"fmt" "fmt"
"io" "io"
"os" "os"
"slices" "regexp"
"runtime/debug"
"strings" "strings"
"time"
"github.com/charmbracelet/log"
"github.com/peterbourgon/ff/v4"
"github.com/peterbourgon/ff/v4/ffhelp"
"github.com/onyx-and-iris/q3rcon" "github.com/onyx-and-iris/q3rcon"
log "github.com/sirupsen/logrus"
) )
var interactive bool var version string // Version will be set at build time
func main() { type Flags struct {
var ( Host string
host string Port int
port int Rconpass string
password string Interactive bool
loglevel int LogLevel string
Version bool
}
func (f Flags) Validate() error {
if f.Port < 1024 || f.Port > 65535 {
return fmt.Errorf(
"invalid port value, got: (%d) expected: in range 1024-65535",
f.Port,
) )
flag.StringVar(&host, "host", "localhost", "hostname of the server")
flag.StringVar(&host, "h", "localhost", "hostname of the server (shorthand)")
flag.IntVar(&port, "port", 28960, "port of the server")
flag.IntVar(&port, "p", 28960, "port of the server (shorthand)")
flag.StringVar(&password, "password", "", "hostname of the server")
flag.StringVar(&password, "P", "", "hostname of the server (shorthand)")
flag.BoolVar(&interactive, "interactive", false, "run in interactive mode")
flag.BoolVar(&interactive, "i", false, "run in interactive mode")
flag.IntVar(&loglevel, "loglevel", int(log.WarnLevel), "log level")
flag.IntVar(&loglevel, "l", int(log.WarnLevel), "log level (shorthand)")
flag.Parse()
if slices.Contains(log.AllLevels, log.Level(loglevel)) {
log.SetLevel(log.Level(loglevel))
} }
rcon, err := connectRcon(host, port, password) if len(f.Rconpass) < 8 {
if err != nil { return fmt.Errorf(
log.Fatal(err) "invalid rcon password, got: (%s) expected: at least 8 characters",
} f.Rconpass,
defer rcon.Close() )
if interactive {
fmt.Printf("Enter 'Q' to exit.\n>> ")
err := interactiveMode(rcon, os.Stdin)
if err != nil {
log.Fatal(err)
}
return
} }
for _, arg := range flag.Args() {
resp, err := rcon.Send(arg)
if err != nil {
log.Error(err)
continue
}
fmt.Print(resp)
}
}
func connectRcon(host string, port int, password string) (*q3rcon.Rcon, error) {
rcon, err := q3rcon.New(
host, port, password)
if err != nil {
return nil, err
}
return rcon, nil
}
// interactiveMode continuously reads from input until a quit signal is given.
func interactiveMode(rcon *q3rcon.Rcon, input io.Reader) error {
scanner := bufio.NewScanner(input)
for scanner.Scan() {
cmd := scanner.Text()
if strings.ToUpper(cmd) == "Q" {
return nil return nil
} }
resp, err := rcon.Send(cmd) func main() {
var exitCode int
// Defer exit with the final exit code
defer func() {
if exitCode != 0 {
os.Exit(exitCode)
}
}()
closer, err := run()
if closer != nil {
defer closer()
}
if err != nil {
log.Error(err)
exitCode = 1
}
}
// run executes the main logic of the application and returns a cleanup function and an error if any.
func run() (func(), error) {
var flags Flags
fs := ff.NewFlagSet("q3rcon - A command-line RCON client for Q3 Rcon compatible game servers")
fs.StringVar(&flags.Host, 'H', "host", "localhost", "hostname of the gameserver")
fs.IntVar(
&flags.Port,
'p',
"port",
28960,
"port on which the gameserver resides, default is 28960",
)
fs.StringVar(
&flags.Rconpass,
'r',
"rconpass",
"",
"rcon password of the gameserver",
)
fs.BoolVar(&flags.Interactive, 'i', "interactive", "run in interactive mode")
fs.StringVar(
&flags.LogLevel,
'l',
"loglevel",
"info",
"Log level (debug, info, warn, error, fatal, panic)",
)
fs.BoolVar(&flags.Version, 'v', "version", "print version information and exit")
err := ff.Parse(fs, os.Args[1:],
ff.WithEnvVarPrefix("Q3RCON"),
)
switch {
case errors.Is(err, ff.ErrHelp):
fmt.Fprintf(os.Stderr, "%s\n", ffhelp.Flags(fs, "q3rcon [flags] <rcon commands>"))
return nil, nil
case err != nil:
return nil, fmt.Errorf("failed to parse flags: %w", err)
}
if flags.Version {
fmt.Printf("q3rcon version: %s\n", versionFromBuild())
return nil, nil
}
if err := flags.Validate(); err != nil {
return nil, err
}
level, err := log.ParseLevel(flags.LogLevel)
if err != nil {
return nil, fmt.Errorf("invalid log level: %s", flags.LogLevel)
}
log.SetLevel(level)
client, closer, err := connectRcon(flags.Host, flags.Port, flags.Rconpass)
if err != nil {
return nil, fmt.Errorf("failed to connect to rcon: %w", err)
}
if flags.Interactive {
fmt.Printf("Enter 'Q' to exit.\n>> ")
err := interactiveMode(client, os.Stdin)
if err != nil {
return closer, fmt.Errorf("interactive mode error: %w", err)
}
return closer, nil
}
commands := fs.GetArgs()
if len(commands) == 0 {
log.Debug("no commands provided, defaulting to 'status'")
commands = append(commands, "status")
}
runCommands(client, commands)
return closer, nil
}
// versionFromBuild retrieves the version information from the build metadata.
func versionFromBuild() string {
if version != "" {
return version
}
info, ok := debug.ReadBuildInfo()
if !ok {
return "(unable to read version)"
}
return strings.Split(info.Main.Version, "-")[0]
}
func connectRcon(host string, port int, password string) (*q3rcon.Rcon, func(), error) {
client, err := q3rcon.New(host, port, password, q3rcon.WithTimeouts(map[string]time.Duration{
"map": time.Second,
"map_rotate": time.Second,
"map_restart": time.Second,
}))
if err != nil {
return nil, nil, err
}
closer := func() {
if err := client.Close(); err != nil {
log.Error(err)
}
}
return client, closer, nil
}
// runCommands runs the commands given in the flag.Args slice.
// If no commands are given, it defaults to running the "status" command.
func runCommands(client *q3rcon.Rcon, commands []string) {
for _, cmd := range commands {
resp, err := client.Send(cmd)
if err != nil { if err != nil {
log.Error(err) log.Error(err)
continue continue
} }
fmt.Printf("%s>> ", resp) fmt.Print(removeColourCodes(resp))
} }
}
// interactiveMode continuously reads from input until a quit signal is given.
func interactiveMode(client *q3rcon.Rcon, input io.Reader) error {
scanner := bufio.NewScanner(input)
for scanner.Scan() {
cmd := scanner.Text()
if strings.EqualFold(cmd, "Q") {
return nil
}
resp, err := client.Send(cmd)
if err != nil {
log.Error(err)
continue
}
fmt.Printf("%s>> ", removeColourCodes(resp))
}
if scanner.Err() != nil { if scanner.Err() != nil {
return scanner.Err() return scanner.Err()
} }
return nil return nil
} }
var colourCodeRegex = regexp.MustCompile(`\^[0-9]`)
// removeColourCodes removes '\^[0-9]' colour codes from the input string.
func removeColourCodes(s string) string {
return colourCodeRegex.ReplaceAllString(s, "")
}

22
go.mod
View File

@@ -1,14 +1,26 @@
module github.com/onyx-and-iris/q3rcon module github.com/onyx-and-iris/q3rcon
go 1.23.0 go 1.25.0
require ( require (
github.com/fatih/color v1.18.0 github.com/charmbracelet/log v0.4.2
github.com/sirupsen/logrus v1.9.3 github.com/peterbourgon/ff/v4 v4.0.0-beta.1
) )
require ( require (
github.com/mattn/go-colorable v0.1.13 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
golang.org/x/sys v0.25.0 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/sys v0.34.0 // indirect
) )

59
go.sum
View File

@@ -1,25 +1,48 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0=
github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/peterbourgon/ff/v4 v4.0.0-beta.1 h1:hV8qRu3V7YfiSMsBSfPfdcznAvPQd3jI5zDddSrDoUc=
github.com/peterbourgon/ff/v4 v4.0.0-beta.1/go.mod h1:onQJUKipvCyFmZ1rIYwFAh1BhPOvftb1uhvSI7krNLc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

BIN
img/mapname.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
img/status.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -1,19 +1,15 @@
package conn package conn
import ( import (
"bytes"
"fmt" "fmt"
"net" "net"
"strings"
"time" "time"
"github.com/onyx-and-iris/q3rcon/internal/packet" "github.com/charmbracelet/log"
log "github.com/sirupsen/logrus"
) )
type UDPConn struct { type UDPConn struct {
conn *net.UDPConn conn *net.UDPConn
response packet.Response
} }
func New(host string, port int) (UDPConn, error) { func New(host string, port int) (UDPConn, error) {
@@ -29,7 +25,6 @@ func New(host string, port int) (UDPConn, error) {
return UDPConn{ return UDPConn{
conn: conn, conn: conn,
response: packet.NewResponse(),
}, nil }, nil
} }
@@ -42,39 +37,13 @@ func (c UDPConn) Write(buf []byte) (int, error) {
return n, nil return n, nil
} }
func (c UDPConn) Listen(timeout time.Duration, resp chan<- string, errChan chan<- error) { func (c UDPConn) ReadUntil(timeout time.Time, buf []byte) (int, error) {
c.conn.SetReadDeadline(time.Now().Add(timeout)) c.conn.SetReadDeadline(timeout)
done := make(chan struct{})
var sb strings.Builder
buf := make([]byte, 2048)
for {
select {
case <-done:
resp <- sb.String()
return
default:
rlen, _, err := c.conn.ReadFromUDP(buf) rlen, _, err := c.conn.ReadFromUDP(buf)
if err != nil { if err != nil {
e, ok := err.(net.Error) return 0, err
if ok {
if e.Timeout() {
close(done)
} else {
errChan <- e
return
}
}
}
if rlen < len(c.response.Header()) {
continue
}
if bytes.HasPrefix(buf, c.response.Header()) {
sb.Write(buf[len(c.response.Header()):rlen])
}
}
} }
return rlen, nil
} }
func (c UDPConn) Close() error { func (c UDPConn) Close() error {

View File

@@ -1,25 +1,36 @@
package packet package packet
import "fmt" import (
"bytes"
"fmt"
"github.com/charmbracelet/log"
)
const bufSz = 512
type Request struct { type Request struct {
magic []byte magic []byte
password string password string
buf *bytes.Buffer
} }
func NewRequest(password string) Request { func NewRequest(password string) Request {
return Request{ return Request{
magic: []byte{'\xff', '\xff', '\xff', '\xff'}, magic: []byte{'\xff', '\xff', '\xff', '\xff'},
password: password, password: password,
buf: bytes.NewBuffer(make([]byte, bufSz)),
} }
} }
func (r Request) Header() []byte { func (r Request) Header() []byte {
return append(r.magic, []byte("rcon")...) return append(r.magic, "rcon"...)
} }
func (r Request) Encode(cmd string) []byte { func (r Request) Encode(cmd string) []byte {
datagram := r.Header() r.buf.Reset()
datagram = append(datagram, fmt.Sprintf(" %s %s", r.password, cmd)...) r.buf.Write(r.Header())
return datagram r.buf.WriteString(fmt.Sprintf(" %s %s", r.password, cmd))
log.Debugf("Encoded request: %s", r.buf.String())
return r.buf.Bytes()
} }

View File

@@ -9,5 +9,5 @@ func NewResponse() Response {
} }
func (r Response) Header() []byte { func (r Response) Header() []byte {
return append(r.magic, []byte("print\n")...) return append(r.magic, "print\n"...)
} }

View File

@@ -1,34 +1,39 @@
program = q3rcon PROGRAM = q3rcon
GO = @go GO = @go
BIN_DIR := bin BIN_DIR := bin
WINDOWS=$(BIN_DIR)/$(program)_windows_amd64.exe WINDOWS=$(BIN_DIR)/$(PROGRAM)_windows_amd64.exe
LINUX=$(BIN_DIR)/$(program)_linux_amd64 LINUX=$(BIN_DIR)/$(PROGRAM)_linux_amd64
VERSION=$(shell git describe --tags --always --long --dirty) MACOS=$(BIN_DIR)/$(PROGRAM)_darwin_amd64
VERSION=$(shell git describe --tags $(shell git rev-list --tags --max-count=1))
.DEFAULT_GOAL := build .DEFAULT_GOAL := build
.PHONY: fmt vet build windows linux test clean .PHONY: fmt vet build windows linux macos test clean
fmt: fmt:
$(GO) fmt ./... $(GO) fmt ./...
vet: fmt vet: fmt
$(GO) vet ./... $(GO) vet ./...
build: vet windows linux | $(BIN_DIR) build: vet windows linux macos | $(BIN_DIR)
@echo version: $(VERSION) @echo version: $(VERSION)
windows: $(WINDOWS) windows: $(WINDOWS)
linux: $(LINUX) linux: $(LINUX)
macos: $(MACOS)
$(WINDOWS): $(WINDOWS):
env GOOS=windows GOARCH=amd64 go build -v -o $(WINDOWS) -ldflags="-s -w -X main.version=$(VERSION)" ./cmd/q3rcon/ env GOOS=windows GOARCH=amd64 go build -v -o $(WINDOWS) -ldflags="-s -w -X main.version=$(VERSION)" ./cmd/$(PROGRAM)/
$(LINUX): $(LINUX):
env GOOS=linux GOARCH=amd64 go build -v -o $(LINUX) -ldflags="-s -w -X main.version=$(VERSION)" ./cmd/q3rcon/ env GOOS=linux GOARCH=amd64 go build -v -o $(LINUX) -ldflags="-s -w -X main.version=$(VERSION)" ./cmd/$(PROGRAM)/
$(MACOS):
env GOOS=darwin GOARCH=amd64 go build -v -o $(MACOS) -ldflags="-s -w -X main.version=$(VERSION)" ./cmd/$(PROGRAM)/
test: test:
$(GO) test ./... $(GO) test ./...

27
option.go Normal file
View File

@@ -0,0 +1,27 @@
package q3rcon
import "time"
// Option is a functional option type that allows us to configure the VbanTxt.
type Option func(*Rcon)
// WithLoginTimeout is a functional option to set the login timeout
func WithLoginTimeout(timeout time.Duration) Option {
return func(rcon *Rcon) {
rcon.loginTimeout = timeout
}
}
// WithDefaultTimeout is a functional option to set the default response timeout
func WithDefaultTimeout(timeout time.Duration) Option {
return func(rcon *Rcon) {
rcon.defaultTimeout = timeout
}
}
// WithTimeouts is a functional option to set the timeouts for responses per command
func WithTimeouts(timeouts map[string]time.Duration) Option {
return func(rcon *Rcon) {
rcon.timeouts = timeouts
}
}

View File

@@ -1,39 +1,28 @@
package q3rcon package q3rcon
import ( import (
"bytes"
"errors" "errors"
"net"
"strings" "strings"
"time" "time"
"github.com/charmbracelet/log"
"github.com/onyx-and-iris/q3rcon/internal/conn" "github.com/onyx-and-iris/q3rcon/internal/conn"
"github.com/onyx-and-iris/q3rcon/internal/packet" "github.com/onyx-and-iris/q3rcon/internal/packet"
) )
// Option is a functional option type that allows us to configure the VbanTxt. const respBufSiz = 2048
type Option func(*Rcon)
func WithDefaultTimeout(timeout time.Duration) Option {
return func(rcon *Rcon) {
rcon.defaultTimeout = timeout
}
}
// WithTimeouts is a functional option to set the timeouts for responses
func WithTimeouts(timeouts map[string]time.Duration) Option {
return func(rcon *Rcon) {
rcon.timeouts = timeouts
}
}
type Rcon struct { type Rcon struct {
conn conn.UDPConn conn conn.UDPConn
request packet.Request request packet.Request
response packet.Response response packet.Response
loginTimeout time.Duration
defaultTimeout time.Duration defaultTimeout time.Duration
timeouts map[string]time.Duration timeouts map[string]time.Duration
resp chan string
} }
func New(host string, port int, password string, options ...Option) (*Rcon, error) { func New(host string, port int, password string, options ...Option) (*Rcon, error) {
@@ -49,7 +38,9 @@ func New(host string, port int, password string, options ...Option) (*Rcon, erro
r := &Rcon{ r := &Rcon{
conn: conn, conn: conn,
request: packet.NewRequest(password), request: packet.NewRequest(password),
resp: make(chan string), response: packet.NewResponse(),
loginTimeout: 5 * time.Second,
defaultTimeout: 20 * time.Millisecond, defaultTimeout: 20 * time.Millisecond,
timeouts: make(map[string]time.Duration), timeouts: make(map[string]time.Duration),
} }
@@ -58,16 +49,15 @@ func New(host string, port int, password string, options ...Option) (*Rcon, erro
o(r) o(r)
} }
err = r.Login() if err = r.login(); err != nil {
if err != nil {
return nil, err return nil, err
} }
return r, nil return r, nil
} }
func (r Rcon) Login() error { func (r Rcon) login() error {
timeout := time.After(2 * time.Second) timeout := time.After(r.loginTimeout)
for { for {
select { select {
case <-timeout: case <-timeout:
@@ -90,27 +80,66 @@ func (r Rcon) Login() error {
} }
} }
func (r Rcon) Send(cmd string) (string, error) { func (r Rcon) Send(cmdWithArgs string) (string, error) {
cmd, _, _ := strings.Cut(string(cmdWithArgs), " ")
timeout, ok := r.timeouts[cmd] timeout, ok := r.timeouts[cmd]
if !ok { if !ok {
timeout = r.defaultTimeout timeout = r.defaultTimeout
} else {
log.Debugf("%s in timeouts map, using timeout %v", cmd, timeout)
} }
e := make(chan error) respChan := make(chan string)
go r.conn.Listen(timeout, r.resp, e) errChan := make(chan error)
_, err := r.conn.Write(r.request.Encode(cmd))
go r.listen(timeout, respChan, errChan)
_, err := r.conn.Write(r.request.Encode(cmdWithArgs))
if err != nil { if err != nil {
return "", err return "", err
} }
select { select {
case err := <-e: case err := <-errChan:
return "", err return "", err
case resp := <-r.resp: case resp := <-respChan:
return strings.TrimPrefix(resp, string(r.response.Header())), nil return resp, nil
} }
} }
func (r Rcon) Close() { func (r Rcon) listen(timeout time.Duration, respChan chan<- string, errChan chan<- error) {
r.conn.Close() done := make(chan struct{})
respBuf := make([]byte, respBufSiz)
var sb strings.Builder
for {
select {
case <-done:
respChan <- sb.String()
return
default:
rlen, err := r.conn.ReadUntil(time.Now().Add(timeout), respBuf)
if err != nil {
e, ok := err.(net.Error)
if ok {
if e.Timeout() {
close(done)
} else {
errChan <- e
return
}
}
}
if rlen > len(r.response.Header()) {
if bytes.HasPrefix(respBuf, r.response.Header()) {
sb.Write(respBuf[len(r.response.Header()):rlen])
}
}
}
}
}
func (r Rcon) Close() error {
return r.conn.Close()
} }