11 Commits

Author SHA1 Message Date
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
9 changed files with 153 additions and 91 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,26 @@ Before any major/minor/patch bump all unit tests will be run to verify they pass
- [x] - [x]
# [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.

View File

@@ -93,15 +93,15 @@ rcon, err := q3rcon.New(
## Command line ## Command line
Pass `host`, `port` and `password` as flags, for example: Pass `host`, `port` and `rconpass` as flags, for example:
``` ```
q3rcon -h=localhost -p=30000 -P="rconpassword" "mapname" q3rcon -h=localhost -p=30000 -r="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.
@@ -110,7 +110,7 @@ Arguments following the flags will be sent as rcon commands. You may send multip
Pass `interactive (-i shorthand)` flag to enable interactive mode, for example: Pass `interactive (-i shorthand)` flag to enable interactive mode, for example:
``` ```
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.

View File

@@ -27,7 +27,7 @@ func main() {
var ( var (
host string host string
port int port int
password string rconpass string
loglevel int loglevel int
) )
@@ -35,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", "", "rcon password") flag.StringVar(&rconpass, "rconpass", "", "rcon password")
flag.StringVar(&password, "P", "", "rcon password (shorthand)") flag.StringVar(&rconpass, "r", "", "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")
@@ -49,7 +49,7 @@ func main() {
log.SetLevel(log.Level(loglevel)) log.SetLevel(log.Level(loglevel))
} }
rcon, err := connectRcon(host, port, password) rcon, err := connectRcon(host, port, rconpass)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@@ -92,7 +92,7 @@ func interactiveMode(rcon *q3rcon.Rcon, input io.Reader) error {
scanner := bufio.NewScanner(input) scanner := bufio.NewScanner(input)
for scanner.Scan() { for scanner.Scan() {
cmd := scanner.Text() cmd := scanner.Text()
if strings.ToUpper(cmd) == "Q" { if strings.EqualFold(cmd, "Q") {
return nil return nil
} }

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) {
@@ -28,8 +24,7 @@ func New(host string, port int) (UDPConn, error) {
log.Infof("Outgoing address %s", conn.RemoteAddr()) log.Infof("Outgoing address %s", conn.RemoteAddr())
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{}) rlen, _, err := c.conn.ReadFromUDP(buf)
var sb strings.Builder if err != nil {
buf := make([]byte, 2048) return 0, err
for {
select {
case <-done:
resp <- sb.String()
return
default:
rlen, _, err := c.conn.ReadFromUDP(buf)
if err != nil {
e, ok := err.(net.Error)
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,37 +1,19 @@
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)
// 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
}
}
type Rcon struct { type Rcon struct {
conn conn.UDPConn conn conn.UDPConn
@@ -41,8 +23,6 @@ type Rcon struct {
loginTimeout time.Duration 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) {
@@ -56,9 +36,10 @@ 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, loginTimeout: 5 * time.Second,
defaultTimeout: 20 * time.Millisecond, defaultTimeout: 20 * time.Millisecond,
timeouts: make(map[string]time.Duration), timeouts: make(map[string]time.Duration),
@@ -68,14 +49,14 @@ func New(host string, port int, password string, options ...Option) (*Rcon, erro
o(r) o(r)
} }
if err = r.Login(); err != nil { if err = r.login(); 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(r.loginTimeout) timeout := time.After(r.loginTimeout)
for { for {
select { select {
@@ -99,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])
}
}
}
} }
} }