15 Commits

Author SHA1 Message Date
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
10 changed files with 169 additions and 93 deletions

2
.gitignore vendored
View File

@@ -25,4 +25,4 @@ go.work.sum
# env file # env file
.env .env
cmd/aeiou cmd/codrcon

View File

@@ -11,9 +11,20 @@ Before any major/minor/patch bump all unit tests will be run to verify they pass
- [x] - [x]
# [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.

View File

@@ -52,9 +52,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:
@@ -103,8 +115,17 @@ q3rcon -h=localhost -p=30000 -P="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` ## 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. 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 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
Log level may be set by passing the `-l` flag with a number from 0 up to 6 where Log level may be set by passing the `-l` flag with a number from 0 up to 6 where
0 = Panic, 1 = Fatal, 2 = Error, 3 = Warning, 4 = Info, 5 = Debug, 6 = Trace 0 = Panic, 1 = Fatal, 2 = Error, 3 = Warning, 4 = Info, 5 = Debug, 6 = Trace
[status]: ./img/status.png
[mapname]: ./img/mapname.png

View File

@@ -2,6 +2,7 @@ package main
import ( import (
"bufio" "bufio"
"errors"
"flag" "flag"
"fmt" "fmt"
"io" "io"
@@ -16,6 +17,12 @@ import (
var interactive bool var interactive bool
func exit(err error) {
_, _ = fmt.Fprintf(os.Stderr, "Error: %s\n", err)
flag.Usage()
os.Exit(1)
}
func main() { func main() {
var ( var (
host string host string
@@ -28,8 +35,8 @@ func main() {
flag.StringVar(&host, "h", "localhost", "hostname of the server (shorthand)") flag.StringVar(&host, "h", "localhost", "hostname of the server (shorthand)")
flag.IntVar(&port, "port", 28960, "port of the server") flag.IntVar(&port, "port", 28960, "port of the server")
flag.IntVar(&port, "p", 28960, "port of the server (shorthand)") flag.IntVar(&port, "p", 28960, "port of the server (shorthand)")
flag.StringVar(&password, "password", "", "hostname of the server") flag.StringVar(&password, "password", "", "rcon password")
flag.StringVar(&password, "P", "", "hostname of the server (shorthand)") flag.StringVar(&password, "P", "", "rcon password (shorthand)")
flag.BoolVar(&interactive, "interactive", false, "run in interactive mode") flag.BoolVar(&interactive, "interactive", false, "run in interactive mode")
flag.BoolVar(&interactive, "i", false, "run in interactive mode") flag.BoolVar(&interactive, "i", false, "run in interactive mode")
@@ -57,6 +64,11 @@ func main() {
return return
} }
if len(flag.Args()) == 0 {
err = errors.New("no rcon commands passed")
exit(err)
}
for _, arg := range flag.Args() { for _, arg := range flag.Args() {
resp, err := rcon.Send(arg) resp, err := rcon.Send(arg)
if err != nil { if err != nil {
@@ -68,8 +80,7 @@ func main() {
} }
func connectRcon(host string, port int, password string) (*q3rcon.Rcon, error) { func connectRcon(host string, port int, password string) (*q3rcon.Rcon, error) {
rcon, err := q3rcon.New( rcon, err := q3rcon.New(host, port, password)
host, port, password)
if err != nil { if err != nil {
return nil, err return nil, err
} }

BIN
img/mapname.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

BIN
img/status.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 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"
log "github.com/sirupsen/logrus" 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,16 +1,23 @@
package packet package packet
import "fmt" import (
"bytes"
"fmt"
)
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)),
} }
} }
@@ -19,7 +26,8 @@ func (r Request) Header() []byte {
} }
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))
return r.buf.Bytes()
} }

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"
log "github.com/sirupsen/logrus"
"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,24 +80,63 @@ 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) listen(timeout time.Duration, respChan chan<- string, errChan chan<- error) {
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])
}
}
}
} }
} }