6 Commits

Author SHA1 Message Date
b1161e1e97 unexport request, response methods 2026-02-19 00:25:47 +00:00
f74fbedacc print input prompt on error (for next command). 2026-02-19 00:23:03 +00:00
3f45588afb remove internal packages, move everything into q3rcon
{request}.buf initialised with 0 length.

encoder, decoder interfaces added.
2026-02-19 00:22:32 +00:00
caffd65cb3 upd CHANGELOG 2026-02-18 10:45:27 +00:00
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
10 changed files with 120 additions and 94 deletions

View File

@@ -11,7 +11,7 @@ Before any major/minor/patch bump all unit tests will be run to verify they pass
- [x] - [x]
# [0.5.0] - 2026-02-17 # [0.5.1] - 2026-02-18
### Added ### Added
@@ -21,6 +21,7 @@ Before any major/minor/patch bump all unit tests will be run to verify they pass
- The CLI now supports `--long` and `-short` style flags. Several examples in README. - The CLI now supports `--long` and `-short` style flags. Several examples in README.
- `--help` output has been improved. - `--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 # [0.4.1] - 2026-02-15

View File

@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"regexp"
"runtime/debug" "runtime/debug"
"strings" "strings"
"time" "time"
@@ -188,7 +189,7 @@ func runCommands(client *q3rcon.Rcon, commands []string) {
log.Error(err) log.Error(err)
continue continue
} }
fmt.Print(resp) fmt.Print(removeColourCodes(resp))
} }
} }
@@ -204,9 +205,10 @@ func interactiveMode(client *q3rcon.Rcon, input io.Reader) error {
resp, err := client.Send(cmd) resp, err := client.Send(cmd)
if err != nil { if err != nil {
log.Error(err) log.Error(err)
fmt.Print(">> ")
continue continue
} }
fmt.Printf("%s>> ", resp) fmt.Printf("%s>> ", removeColourCodes(resp))
} }
if scanner.Err() != nil { if scanner.Err() != nil {
@@ -214,3 +216,10 @@ func interactiveMode(client *q3rcon.Rcon, input io.Reader) error {
} }
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, "")
}

View File

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

1
go.mod
View File

@@ -5,7 +5,6 @@ go 1.25.0
require ( require (
github.com/charmbracelet/log v0.4.2 github.com/charmbracelet/log v0.4.2
github.com/peterbourgon/ff/v4 v4.0.0-beta.1 github.com/peterbourgon/ff/v4 v4.0.0-beta.1
github.com/sirupsen/logrus v1.9.3
) )
require ( require (

8
go.sum
View File

@@ -12,7 +12,6 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0G
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 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 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
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 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/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
@@ -34,23 +33,16 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 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 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
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/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 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/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 h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 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 h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 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.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,36 +0,0 @@
package packet
import (
"bytes"
"fmt"
log "github.com/sirupsen/logrus"
)
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))
log.Tracef("Encoded request: %s", r.buf.String())
return r.buf.Bytes()
}

View File

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

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

35
request.go Normal file
View File

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

21
response.go Normal file
View File

@@ -0,0 +1,21 @@
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):])
}