commit 2461a0116cda403d9d7213b7ab9a077c3b29133e Author: onyx-and-iris Date: Mon Nov 4 13:33:53 2024 +0000 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c7ba0b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib +bin/ + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env + +cmd/aeiou \ No newline at end of file diff --git a/cmd/q3rcon/main.go b/cmd/q3rcon/main.go new file mode 100644 index 0000000..23d3f59 --- /dev/null +++ b/cmd/q3rcon/main.go @@ -0,0 +1,99 @@ +package main + +import ( + "bufio" + "flag" + "fmt" + "io" + "os" + "slices" + "strings" + + "github.com/onyx-and-iris/q3rcon" + + log "github.com/sirupsen/logrus" +) + +var interactive bool + +func main() { + var ( + host string + port int + password string + loglevel int + ) + + 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 err != nil { + log.Fatal(err) + } + 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 + } + + resp, err := rcon.Send(cmd) + if err != nil { + log.Error(err) + continue + } + fmt.Printf("%s>> ", resp) + } + if scanner.Err() != nil { + return scanner.Err() + } + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4173232 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module github.com/onyx-and-iris/q3rcon + +go 1.23.0 + +require ( + github.com/fatih/color v1.18.0 + github.com/sirupsen/logrus v1.9.3 +) + +require ( + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/sys v0.25.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3b57f37 --- /dev/null +++ b/go.sum @@ -0,0 +1,25 @@ +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/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +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/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +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/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.0.0-20220811171246-fbc7d0a398ab/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.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= diff --git a/internal/conn/conn.go b/internal/conn/conn.go new file mode 100644 index 0000000..0b622be --- /dev/null +++ b/internal/conn/conn.go @@ -0,0 +1,85 @@ +package conn + +import ( + "bytes" + "fmt" + "net" + "strings" + "time" + + "github.com/onyx-and-iris/q3rcon/internal/packet" + log "github.com/sirupsen/logrus" +) + +type UDPConn struct { + conn *net.UDPConn + response packet.Response +} + +func New(host string, port int) (UDPConn, error) { + udpAddr, err := net.ResolveUDPAddr("udp4", fmt.Sprintf("%s:%d", host, port)) + if err != nil { + return UDPConn{}, err + } + conn, err := net.DialUDP("udp4", nil, udpAddr) + if err != nil { + return UDPConn{}, err + } + log.Infof("Outgoing address %s", conn.RemoteAddr()) + + return UDPConn{ + conn: conn, + response: packet.NewResponse(), + }, nil +} + +func (c UDPConn) Write(buf []byte) (int, error) { + n, err := c.conn.Write(buf) + if err != nil { + return 0, err + } + + return n, nil +} + +func (c UDPConn) Listen(timeout time.Duration, resp chan<- string) { + c.conn.SetReadDeadline(time.Now().Add(timeout)) + ch := make(chan struct{}) + var sb strings.Builder + buf := make([]byte, 2048) + + for { + select { + case <-ch: + 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(ch) + } else { + log.Error(e) + } + } + } + if rlen == 0 { + continue + } + + if bytes.HasPrefix(buf, c.response.Header()) { + sb.Write(buf[len(c.response.Header()):rlen]) + } + } + } +} + +func (c UDPConn) Close() error { + err := c.conn.Close() + if err != nil { + return err + } + return nil +} diff --git a/internal/packet/request.go b/internal/packet/request.go new file mode 100644 index 0000000..557c94f --- /dev/null +++ b/internal/packet/request.go @@ -0,0 +1,25 @@ +package packet + +import "fmt" + +type Request struct { + magic []byte + password string +} + +func NewRequest(password string) Request { + return Request{ + magic: []byte{'\xff', '\xff', '\xff', '\xff'}, + password: password, + } +} + +func (r Request) Header() []byte { + return append(r.magic, []byte("rcon")...) +} + +func (r Request) Encode(cmd string) []byte { + datagram := r.Header() + datagram = append(datagram, fmt.Sprintf(" %s %s", r.password, cmd)...) + return datagram +} diff --git a/internal/packet/response.go b/internal/packet/response.go new file mode 100644 index 0000000..3a1a935 --- /dev/null +++ b/internal/packet/response.go @@ -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")...) +} diff --git a/makefile b/makefile new file mode 100644 index 0000000..853b7b7 --- /dev/null +++ b/makefile @@ -0,0 +1,40 @@ +program = q3rcon + +GO = @go +BIN_DIR := bin + +WINDOWS=$(BIN_DIR)/$(program)_windows_amd64.exe +LINUX=$(BIN_DIR)/$(program)_linux_amd64 +VERSION=$(shell git describe --tags --always --long --dirty) + +.DEFAULT_GOAL := build + +.PHONY: fmt vet build windows linux test clean +fmt: + $(GO) fmt ./... + +vet: fmt + $(GO) vet ./... + +build: vet windows linux | $(BIN_DIR) + @echo version: $(VERSION) + +windows: $(WINDOWS) + +linux: $(LINUX) + + +$(WINDOWS): + env GOOS=windows GOARCH=amd64 go build -v -o $(WINDOWS) -ldflags="-s -w -X main.version=$(VERSION)" ./cmd/q3rcon/ + +$(LINUX): + env GOOS=linux GOARCH=amd64 go build -v -o $(LINUX) -ldflags="-s -w -X main.version=$(VERSION)" ./cmd/q3rcon/ + +test: + $(GO) test ./... + +$(BIN_DIR): + @mkdir -p $@ + +clean: + @rm -rv $(BIN_DIR) \ No newline at end of file diff --git a/q3rcon.go b/q3rcon.go new file mode 100644 index 0000000..3b6ca4a --- /dev/null +++ b/q3rcon.go @@ -0,0 +1,112 @@ +package q3rcon + +import ( + "errors" + "strings" + "time" + + "github.com/onyx-and-iris/q3rcon/internal/conn" + "github.com/onyx-and-iris/q3rcon/internal/packet" + log "github.com/sirupsen/logrus" +) + +// Option is a functional option type that allows us to configure the VbanTxt. +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 { + conn conn.UDPConn + request packet.Request + response packet.Response + + defaultTimeout time.Duration + timeouts map[string]time.Duration + + resp chan string +} + +func New(host string, port int, password string, options ...Option) (*Rcon, error) { + if password == "" { + return nil, errors.New("no password provided") + } + + conn, err := conn.New(host, port) + if err != nil { + return nil, err + } + + r := &Rcon{ + conn: conn, + request: packet.NewRequest(password), + resp: make(chan string, 1), + defaultTimeout: 20 * time.Millisecond, + timeouts: make(map[string]time.Duration), + } + + for _, o := range options { + o(r) + } + + err = r.Login() + if err != nil { + return nil, err + } + + return r, nil +} + +func (r Rcon) Login() error { + timeout := time.After(2 * time.Second) + for { + select { + case <-timeout: + return errors.New("timeout logging in") + default: + resp, err := r.Send("login") + if err != nil { + return err + } + if resp == "" { + continue + } + + if strings.Contains(resp, "Bad rcon") { + return errors.New("bad rcon password provided") + } else { + return nil + } + } + } +} + +func (r Rcon) Send(cmd string) (string, error) { + timeout, ok := r.timeouts[cmd] + if !ok { + timeout = r.defaultTimeout + } + + go r.conn.Listen(timeout, r.resp) + _, err := r.conn.Write(r.request.Encode(cmd)) + if err != nil { + return "", err + } + log.Tracef("Sending '%s'", cmd) + + return strings.TrimPrefix(<-r.resp, string(r.response.Header())), nil +} + +func (r Rcon) Close() { + r.conn.Close() +}