Compare commits

..

No commits in common. "main" and "v0.2.0" have entirely different histories.
main ... v0.2.0

19 changed files with 188 additions and 700 deletions

View File

@ -1,30 +0,0 @@
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

1
.gitignore vendored
View File

@ -24,7 +24,6 @@ go.work.sum
# env file
.env
.envrc
# Added by goreleaser init:
dist/

View File

@ -1,142 +0,0 @@
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

View File

@ -22,7 +22,6 @@ builds:
goos:
- linux
- windows
- darwin
goarch:
- amd64
@ -47,7 +46,6 @@ changelog:
exclude:
- '^docs:'
- '^test:'
- '^chore:'
release:
footer: >-

View File

@ -11,50 +11,6 @@ Before any major/minor/patch bump all unit tests will be run to verify they pass
- [x]
# [0.5.3] - 2026-02-26
### Added
- Spinner for long running CLI commands.
# [0.5.1] - 2026-02-18
### 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.
- Colour codes have been removed from CLI output. This makes the responses easier to read.
# [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

View File

@ -17,15 +17,9 @@ 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.
---
## Use
## Package
#### Use
```console
go get github.com/onyx-and-iris/q3rcon
```
`go get github.com/onyx-and-iris/q3rcon`
```go
package main
@ -97,24 +91,12 @@ rcon, err := q3rcon.New(
q3rcon.WithTimeouts(timeouts))
```
---
## Command line
### Install
Pass `host`, `port` and `rconpass` as flags, for example:
```console
go install github.com/onyx-and-iris/q3rcon/cmd/q3rcon@latest
```
### Configuration
#### Flags
Pass `--host`, `--port` and `--rconpass` as flags, for example:
```console
q3rcon --host=localhost --port=30000 --rconpass="rconpassword" "mapname"
q3rcon -h=localhost -p=30000 -r="rconpassword" "mapname"
```
- `host` defaults to "localhost"
@ -123,65 +105,34 @@ q3rcon --host=localhost --port=30000 --rconpass="rconpassword" "mapname"
Arguments following the flags will be sent as rcon commands. You may send multiple arguments.
#### Environment Variables
example .envrc:
```bash
#!/usr/bin/env bash
export Q3RCON_HOST="localhost"
export Q3RCON_PORT=28960
export Q3RCON_RCONPASS="rconpassword"
```
### Interactive mode
#### Interactive mode
Pass `interactive (-i shorthand)` flag to enable interactive mode, for example:
```console
q3rcon -H=localhost -p=30000 -r="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.
---
## Your own implementation
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.
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. I could have increased the default timeouts but that would add unnecessary delay for most requests, so I decided to leave those details to the users of the package.
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].
---
Since you can include the q3rcon package into your own package you can easily make your own modifications, for example, I added [colour to the terminal][status] and [reformatted some of the responses][mapname].
## Logging
The `--loglevel` flag allows you to control the verbosity of the application's logging output.
Log level may be set by passing the `-l` flag with a number from 0 up to 6 where
Acceptable values for this flag are:
0 = Panic, 1 = Fatal, 2 = Error, 3 = Warning, 4 = Info, 5 = Debug, 6 = Trace
- `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.
[status]: ./img/status.png
[mapname]: ./img/mapname.png
## 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

View File

@ -7,9 +7,8 @@ vars:
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)'
GIT_COMMIT:
sh: git log -n 1 --format=%h
tasks:
default:
@ -23,7 +22,6 @@ tasks:
cmds:
- task: build-windows
- task: build-linux
- task: build-macos
vet:
desc: Vet the code
@ -39,20 +37,12 @@ tasks:
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
- GOOS=windows GOARCH=amd64 go build -o {{.WINDOWS}} -ldflags="-X main.Version={{.GIT_COMMIT}}" ./cmd/{{.PROGRAM}}/
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
- GOOS=linux GOARCH=amd64 go build -o {{.LINUX}} -ldflags="-X main.Version={{.GIT_COMMIT}}" ./cmd/{{.PROGRAM}}/
test:
desc: Run tests

View File

@ -2,225 +2,116 @@ package main
import (
"bufio"
"errors"
"flag"
"fmt"
"io"
"os"
"regexp"
"runtime/debug"
"slices"
"strings"
"time"
"github.com/charmbracelet/log"
"github.com/chelnak/ysmrr"
"github.com/peterbourgon/ff/v4"
"github.com/peterbourgon/ff/v4/ffhelp"
"github.com/onyx-and-iris/q3rcon"
log "github.com/sirupsen/logrus"
)
var version string // Version will be set at build time
type Flags struct {
Host string
Port int
Rconpass string
Interactive bool
LogLevel string
Version bool
func exitOnError(err error) {
_, _ = fmt.Fprintf(os.Stderr, "Error: %s\n", err)
os.Exit(1)
}
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,
)
}
if len(f.Rconpass) < 8 {
return fmt.Errorf(
"invalid rcon password, got: (%s) expected: at least 8 characters",
f.Rconpass,
)
}
return nil
}
var reColourCodes = regexp.MustCompile(`\^[0-9]`)
func main() {
var exitCode int
var (
host string
port int
rconpass string
interactive bool
loglevel int
)
// Defer exit with the final exit code
defer func() {
if exitCode != 0 {
os.Exit(exitCode)
}
}()
flag.StringVar(&host, "host", "localhost", "hostname of the gameserver")
flag.StringVar(&host, "h", "localhost", "hostname of the gameserver (shorthand)")
flag.IntVar(&port, "port", 28960, "port on which the gameserver resides, default is 28960")
flag.IntVar(&port, "p", 28960, "port on which the gameserver resides, default is 28960 (shorthand)")
flag.StringVar(&rconpass, "rconpass", os.Getenv("RCON_PASS"), "rcon password of the gameserver")
flag.StringVar(&rconpass, "r", os.Getenv("RCON_PASS"), "rcon password of the gameserver (shorthand)")
closer, err := run()
if closer != nil {
defer closer()
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))
}
if port < 1024 || port > 65535 {
exitOnError(fmt.Errorf("invalid port value, got: (%d) expected: in range 1024-65535", port))
}
if len(rconpass) < 8 {
exitOnError(fmt.Errorf("invalid rcon password, got: (%s) expected: at least 8 characters", rconpass))
}
rcon, err := connectRcon(host, port, rconpass)
if err != nil {
log.Error(err)
exitCode = 1
exitOnError(err)
}
defer rcon.Close()
if !interactive {
runCommands(flag.Args(), rcon)
return
}
fmt.Printf("Enter 'Q' to exit.\n>> ")
err = interactiveMode(rcon, os.Stdin)
if err != nil {
exitOnError(err)
}
}
type context struct {
client *q3rcon.Rcon
timeouts map[string]time.Duration
in io.Reader
sm ysmrr.SpinnerManager
}
// 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 {
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
}
level, err := log.ParseLevel(flags.LogLevel)
if err != nil {
return nil, fmt.Errorf("invalid log level: %s", flags.LogLevel)
}
log.SetLevel(level)
timeouts := map[string]time.Duration{
"map": time.Second,
"map_rotate": time.Second,
"map_restart": time.Second,
}
log.Debugf("using timeouts: %v", timeouts)
client, closer, err := connectRcon(flags.Host, flags.Port, flags.Rconpass, timeouts)
if err != nil {
return nil, fmt.Errorf("failed to connect to rcon: %w", err)
}
sm := ysmrr.NewSpinnerManager()
sm.AddSpinner("")
ctx := &context{
client: client,
timeouts: timeouts,
in: os.Stdin,
sm: sm,
}
if flags.Interactive {
fmt.Printf("Enter 'Q' to exit.\n>> ")
err := interactiveMode(ctx)
if err != nil {
return closer, fmt.Errorf("interactive mode error: %w", err)
}
return closer, nil
}
commands := fs.GetArgs()
// runCommands runs the commands given in the flag.Args slice.
// If no commands are given, it defaults to running the "status" command.
func runCommands(commands []string, rcon *q3rcon.Rcon) {
if len(commands) == 0 {
log.Debug("no commands provided, defaulting to 'status'")
commands = append(commands, "status")
}
runCommands(ctx, 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,
timeouts map[string]time.Duration,
) (*q3rcon.Rcon, func(), error) {
client, err := q3rcon.New(host, port, password, q3rcon.WithTimeouts(timeouts))
if err != nil {
return nil, nil, err
}
closer := func() {
if err := client.Close(); err != nil {
for _, cmd := range commands {
resp, err := rcon.Send(cmd)
if err != nil {
log.Error(err)
continue
}
fmt.Print(resp)
}
return client, closer, nil
}
// interactiveMode continuously reads from input until a quit signal is given.
func interactiveMode(ctx *context) error {
scanner := bufio.NewScanner(ctx.in)
func interactiveMode(rcon *q3rcon.Rcon, input io.Reader) error {
scanner := bufio.NewScanner(input)
for scanner.Scan() {
cmd := scanner.Text()
if strings.EqualFold(cmd, "Q") {
return nil
}
if err := runCommand(ctx, cmd); err != nil {
fmt.Printf("Error: %v\n", err)
resp, err := rcon.Send(cmd)
if err != nil {
log.Error(err)
continue
}
fmt.Printf(">> ")
fmt.Printf("%s>> ", resp)
}
if scanner.Err() != nil {
@ -228,32 +119,3 @@ func interactiveMode(ctx *context) error {
}
return nil
}
// runCommands executes a list of commands sequentially and prints any errors encountered.
func runCommands(ctx *context, commands []string) {
for _, cmd := range commands {
if err := runCommand(ctx, cmd); err != nil {
fmt.Printf("Error: %v\n", err)
}
}
}
// runCommand sends a command to the RCON client and prints the response.
// If the command is in the timeouts map, it starts a spinner until the response is received.
func runCommand(ctx *context, cmd string) error {
before, _, _ := strings.Cut(cmd, " ")
_, ok := ctx.timeouts[before]
if ok {
ctx.sm.Start()
}
resp, err := ctx.client.Send(cmd)
if err != nil {
return fmt.Errorf("failed to run command '%s': %w", cmd, err)
}
if ctx.sm.Running() {
ctx.sm.Stop()
}
fmt.Print(reColourCodes.ReplaceAllString(resp, ""))
return nil
}

29
go.mod
View File

@ -1,30 +1,7 @@
module github.com/onyx-and-iris/q3rcon
go 1.25.0
go 1.23.0
require (
github.com/charmbracelet/log v0.4.2
github.com/chelnak/ysmrr v0.6.0
github.com/peterbourgon/ff/v4 v4.0.0-beta.1
)
require github.com/sirupsen/logrus v1.9.3
require (
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/fatih/color v1.18.0 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // 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.41.0 // indirect
golang.org/x/term v0.40.0 // indirect
)
require golang.org/x/sys v0.25.0 // indirect

64
go.sum
View File

@ -1,56 +1,16 @@
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/chelnak/ysmrr v0.6.0 h1:kMhO0oI02tl/9szvxrOE0yeImtrK4KQhER0oXu1K/iM=
github.com/chelnak/ysmrr v0.6.0/go.mod h1:56JSrmQgb7/7xoMvuD87h3PE/qW6K1+BQcrgWtVLTUo=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
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-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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -1,33 +1,34 @@
package q3rcon
package conn
import (
"fmt"
"net"
"time"
"github.com/charmbracelet/log"
log "github.com/sirupsen/logrus"
)
type UDPConn struct {
conn *net.UDPConn
}
func newUDPConn(host string, port int) (*UDPConn, error) {
udpAddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(host, fmt.Sprintf("%d", port)))
func New(host string, port int) (UDPConn, error) {
udpAddr, err := net.ResolveUDPAddr("udp4", fmt.Sprintf("%s:%d", host, port))
if err != nil {
return nil, err
return UDPConn{}, err
}
conn, err := net.DialUDP("udp4", nil, udpAddr)
if err != nil {
return nil, err
return UDPConn{}, err
}
log.Infof("Outgoing address %s", conn.RemoteAddr())
return &UDPConn{
return UDPConn{
conn: conn,
}, nil
}
func (c *UDPConn) Write(buf []byte) (int, error) {
func (c UDPConn) Write(buf []byte) (int, error) {
n, err := c.conn.Write(buf)
if err != nil {
return 0, err
@ -36,7 +37,8 @@ func (c *UDPConn) Write(buf []byte) (int, error) {
return n, nil
}
func (c *UDPConn) Read(buf []byte) (int, error) {
func (c UDPConn) ReadUntil(timeout time.Time, buf []byte) (int, error) {
c.conn.SetReadDeadline(timeout)
rlen, _, err := c.conn.ReadFromUDP(buf)
if err != nil {
return 0, err
@ -44,7 +46,7 @@ func (c *UDPConn) Read(buf []byte) (int, error) {
return rlen, nil
}
func (c *UDPConn) Close() error {
func (c UDPConn) Close() error {
err := c.conn.Close()
if err != nil {
return err

View File

@ -0,0 +1,33 @@
package packet
import (
"bytes"
"fmt"
)
const bufSz = 512
type Request struct {
magic []byte
password string
buf *bytes.Buffer
}
func NewRequest(password string) Request {
return Request{
magic: []byte{'\xff', '\xff', '\xff', '\xff'},
password: password,
buf: bytes.NewBuffer(make([]byte, bufSz)),
}
}
func (r Request) Header() []byte {
return append(r.magic, []byte("rcon")...)
}
func (r Request) Encode(cmd string) []byte {
r.buf.Reset()
r.buf.Write(r.Header())
r.buf.WriteString(fmt.Sprintf(" %s %s", r.password, cmd))
return r.buf.Bytes()
}

View File

@ -0,0 +1,13 @@
package packet
type Response struct {
magic []byte
}
func NewResponse() Response {
return Response{magic: []byte{'\xff', '\xff', '\xff', '\xff'}}
}
func (r Response) Header() []byte {
return append(r.magic, []byte("print\n")...)
}

View File

@ -5,26 +5,24 @@ BIN_DIR := bin
WINDOWS=$(BIN_DIR)/$(PROGRAM)_windows_amd64.exe
LINUX=$(BIN_DIR)/$(PROGRAM)_linux_amd64
MACOS=$(BIN_DIR)/$(PROGRAM)_darwin_amd64
VERSION=$(shell git describe --tags $(shell git rev-list --tags --max-count=1))
VERSION=$(shell git log -n 1 --format=%h)
.DEFAULT_GOAL := build
.PHONY: fmt vet build windows linux macos test clean
.PHONY: fmt vet build windows linux test clean
fmt:
$(GO) fmt ./...
vet: fmt
$(GO) vet ./...
build: vet windows linux macos | $(BIN_DIR)
build: vet windows linux | $(BIN_DIR)
@echo version: $(VERSION)
windows: $(WINDOWS)
linux: $(LINUX)
macos: $(MACOS)
$(WINDOWS):
env GOOS=windows GOARCH=amd64 go build -v -o $(WINDOWS) -ldflags="-s -w -X main.version=$(VERSION)" ./cmd/$(PROGRAM)/
@ -32,9 +30,6 @@ $(WINDOWS):
$(LINUX):
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:
$(GO) test ./...

View File

@ -1,31 +1,24 @@
package q3rcon
import (
"bytes"
"errors"
"fmt"
"io"
"net"
"strings"
"time"
"github.com/charmbracelet/log"
log "github.com/sirupsen/logrus"
"github.com/onyx-and-iris/q3rcon/internal/conn"
"github.com/onyx-and-iris/q3rcon/internal/packet"
)
const respBufSiz = 2048
type encoder interface {
encode(cmd string) ([]byte, error)
}
type decoder interface {
isValid(buf []byte) bool
decode(buf []byte) string
}
type Rcon struct {
conn io.ReadWriteCloser
request encoder
response decoder
conn conn.UDPConn
request packet.Request
response packet.Response
loginTimeout time.Duration
defaultTimeout time.Duration
@ -37,15 +30,15 @@ func New(host string, port int, password string, options ...Option) (*Rcon, erro
return nil, errors.New("no password provided")
}
conn, err := newUDPConn(host, port)
conn, err := conn.New(host, port)
if err != nil {
return nil, fmt.Errorf("error creating UDP connection: %w", err)
return nil, err
}
r := &Rcon{
conn: conn,
request: newRequest(password),
response: newResponse(),
request: packet.NewRequest(password),
response: packet.NewResponse(),
loginTimeout: 5 * time.Second,
defaultTimeout: 20 * time.Millisecond,
@ -57,7 +50,7 @@ func New(host string, port int, password string, options ...Option) (*Rcon, erro
}
if err = r.login(); err != nil {
return nil, fmt.Errorf("error logging in: %w", err)
return nil, err
}
return r, nil
@ -72,7 +65,7 @@ func (r Rcon) login() error {
default:
resp, err := r.Send("login")
if err != nil {
return fmt.Errorf("error sending login command: %w", err)
return err
}
if resp == "" {
continue
@ -101,14 +94,9 @@ func (r Rcon) Send(cmdWithArgs string) (string, error) {
go r.listen(timeout, respChan, errChan)
encodedCmd, err := r.request.encode(cmdWithArgs)
_, err := r.conn.Write(r.request.Encode(cmdWithArgs))
if err != nil {
return "", fmt.Errorf("error encoding command: %w", err)
}
_, err = r.conn.Write(encodedCmd)
if err != nil {
return "", fmt.Errorf("error writing command to connection: %w", err)
return "", err
}
select {
@ -130,17 +118,7 @@ func (r Rcon) listen(timeout time.Duration, respChan chan<- string, errChan chan
respChan <- sb.String()
return
default:
c, ok := r.conn.(*UDPConn)
if !ok {
errChan <- errors.New("connection is not a UDPConn")
return
}
err := c.conn.SetReadDeadline(time.Now().Add(timeout))
if err != nil {
errChan <- fmt.Errorf("error setting read deadline: %w", err)
return
}
rlen, err := r.conn.Read(respBuf)
rlen, err := r.conn.ReadUntil(time.Now().Add(timeout), respBuf)
if err != nil {
e, ok := err.(net.Error)
if ok {
@ -153,13 +131,15 @@ func (r Rcon) listen(timeout time.Duration, respChan chan<- string, errChan chan
}
}
if r.response.isValid(respBuf[:rlen]) {
sb.WriteString(r.response.decode(respBuf[:rlen]))
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()
func (r Rcon) Close() {
r.conn.Close()
}

View File

@ -1,35 +0,0 @@
package q3rcon
import (
"bytes"
"errors"
"fmt"
)
const (
bufSz = 1024
requestHeader = "\xff\xff\xff\xffrcon"
)
type request struct {
password string
buf *bytes.Buffer
}
func newRequest(password string) request {
return request{
password: password,
buf: bytes.NewBuffer(make([]byte, 0, bufSz)),
}
}
func (r request) encode(cmd string) ([]byte, error) {
if cmd == "" {
return nil, errors.New("command cannot be empty")
}
r.buf.Reset()
r.buf.WriteString(requestHeader)
r.buf.WriteString(fmt.Sprintf(" %s %s", r.password, cmd))
return r.buf.Bytes(), nil
}

View File

@ -1,21 +0,0 @@
package q3rcon
import "bytes"
const (
responseHeader = "\xff\xff\xff\xffprint\n"
)
type response struct{}
func newResponse() response {
return response{}
}
func (r response) isValid(buf []byte) bool {
return len(buf) > len(responseHeader) && bytes.HasPrefix(buf, []byte(responseHeader))
}
func (r response) decode(buf []byte) string {
return string(buf[len(responseHeader):])
}