26 Commits
main ... v1.0.3

Author SHA1 Message Date
3a8758a4d7 remove string casts
compare byte slices
2024-04-11 19:24:43 +01:00
2c8ae43303 split 3 times, log last part 2024-04-03 23:58:58 +01:00
0935fc1190 rename isBadRconRequest to isBadRconResponse 2024-04-03 20:59:37 +01:00
3e039824de log bad rcon requests at info level
include client ip in log
2024-04-03 20:54:10 +01:00
0c054377ba move validator methods into Validator struct
embed Validator into Session

unexport Session
2024-03-26 10:47:53 +00:00
5ac3f805e2 add example debian unit file 2024-03-24 12:31:04 +00:00
ab548d669a no need to call String() explicitly here 2024-03-24 12:29:48 +00:00
107f914d3b upd changelog 2024-03-21 08:42:58 +00:00
0fdf9d10c3 upd special thanks with link to lilproxy 2024-03-21 08:23:35 +00:00
690fabddaf no need to split and rejoin here. 2024-03-21 07:35:07 +00:00
f6f0044a84 Only stringify rcon responses if debug enabled. 2024-03-20 21:49:05 +00:00
725e6cfb3d isQueryResponsePacket() and isValidResponsePacket() added
fixes bug relaying query responses
2024-03-20 21:03:37 +00:00
ca33a6a390 log invalid response packets 2024-03-20 18:10:15 +00:00
dfcdb6a96a move packet header check methods into Session
log invalid packets at error level
2024-03-20 18:02:55 +00:00
15e4cf6e42 remove global var debug 2024-03-20 17:10:00 +00:00
f2fd9354f0 add isRconPacket(), isQueryPacket() and isResponsePacket() methods
response packets now logged at Debug level
2024-03-20 17:05:02 +00:00
94a683fb3f Q3RCON_DEBUG env var added 2024-03-20 17:03:35 +00:00
7138515904 ensure we're only logging rcon
(no query packets)
2024-03-08 21:42:39 +00:00
a01df2e905 only log client IP (remove port)
log game server address
2024-03-08 02:42:01 +00:00
d8015c921c add logrus dependency 2024-03-08 02:29:40 +00:00
a513c7ecc6 add new env var Q3RCON_HOST 2024-03-08 02:29:27 +00:00
6f49cc5b63 log rcon commands level INFO 2024-03-08 02:29:01 +00:00
88c90f1447 upd dockerfile 2024-01-29 11:53:36 +00:00
561b1ca654 Update README.md
expand readme
2024-01-28 03:34:28 +00:00
a3219e63c2 tests removed due to q3rcon changes 2024-01-27 23:12:26 +00:00
ceb87e14fd reword 2024-01-27 18:20:57 +00:00
11 changed files with 229 additions and 124 deletions

View File

@@ -11,12 +11,47 @@ Before any major/minor/patch bump all unit tests will be run to verify they pass
- [x] - [x]
## [0.6.0] - 2024-03-21
### Added
- new environment variable `Q3RCON_DEBUG` for enabling debug logging. Defaults to 0.
- rcon responses are now logged at debug level
- invalid responses (rcon and query) now logged
### Changed
- All packet header checking methods moved into Session struct.
### Fixed
- a bug causing the proxy not to send back query responses
## [0.3.0] - 2024-03-08
### Added
- outgoing rcon requests now logged at info level
- new environment variable `Q3RCON_HOST` for specifying which ip to bind the proxy to. Defaults to `0.0.0.0`.
### Changed
- now using [logrus][logrus] package for logging.
### Fixed
- a `slice bounds out of range` error due to query packets being logged.
## [0.1.0] - 2024-01-27 ## [0.1.0] - 2024-01-27
- ignore any packets whose header does match a q3 rcon/query packet. ### Added
- only forward packets if the header matches q3 rcon/query.
## [0.0.1] - 2024-01-27 ## [0.0.1] - 2024-01-27
### Added ### Added
- All source files for lilproxy including full commit history. - All source files for lilproxy including full commit history.
[logrus]: https://github.com/sirupsen/logrus

View File

@@ -1,12 +1,14 @@
FROM golang:alpine FROM golang:1.21
WORKDIR /dist WORKDIR /usr/src/app
COPY . . # pre-copy/cache go.mod for pre-downloading dependencies and only redownloading them in subsequent builds if they change
COPY go.mod go.sum ./
# build binary and place into /usr/local/bin
RUN go mod download && go mod verify RUN go mod download && go mod verify
RUN go build -v -o /usr/local/bin/q3rcon-proxy ./cmd/q3rcon-proxy
# build binary and place into /usr/local/bin/
COPY . .
RUN go build -v -o /usr/local/bin/q3rcon-proxy ./cmd/q3rcon-proxy/
# Command to run when starting the container # Command to run when starting the container
ENTRYPOINT [ "q3rcon-proxy" ] ENTRYPOINT [ "q3rcon-proxy" ]

View File

@@ -12,17 +12,17 @@ for example:
export Q3RCON_PROXY="20000:28960;20001:28961;20002:28962" export Q3RCON_PROXY="20000:28960;20001:28961;20002:28962"
``` ```
This would run 3 proxy servers listening on ports `20000`, `20001` and `20002` that redirect rcon requests to game servers on ports `28960`, `28961` and `28962` respectively. This would configure q3rcon-proxy to run 3 proxy servers listening on ports `20000`, `20001` and `20002` that redirect rcon requests to game servers on ports `28960`, `28961` and `28962` respectively.
Then just run the binary which you can compile yourself, download from `Releases` or use the included Dockerfile.
### Why ### Why
Avoid sending plaintext rcon requests (that include the password) to public ports. Instead send them to whitelisted ports. Avoid sending plaintext rcon commands to the public game server port. In general I would advise anyone using rcon remotely to use a secured connection but perhaps you've passed rcon to a clan friend who doesn't know about secured connections. Now you can instruct them to use rcon only through a whitelisted port.
Gives you the option to disable remote rcon entirely and have the server accept requests only from localhost.
### Special Thanks ### Special Thanks
[Dylan][user_link] For writing this proxy. [Dylan][user_link] For writing [lilproxy][lilproxy_url].
[lilproxy_url]: https://github.com/dgparker/lilproxy [lilproxy_url]: https://github.com/dgparker/lilproxy
[user_link]: https://github.com/dgparker [user_link]: https://github.com/dgparker

View File

@@ -2,10 +2,12 @@ package main
import ( import (
"fmt" "fmt"
"log"
"os" "os"
"strconv"
"strings" "strings"
log "github.com/sirupsen/logrus"
"github.com/onyx-and-iris/q3rcon-proxy/pkg/udpproxy" "github.com/onyx-and-iris/q3rcon-proxy/pkg/udpproxy"
) )
@@ -15,22 +17,57 @@ func start(proxy string) {
return x[0], x[1] return x[0], x[1]
}() }()
c, err := udpproxy.New(fmt.Sprintf("0.0.0.0:%s", port), fmt.Sprintf("127.0.0.1:%s", target)) c, err := udpproxy.New(fmt.Sprintf("%s:%s", host, port), fmt.Sprintf("127.0.0.1:%s", target))
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
log.Printf("q3rcon-proxy initialized: [proxy] (0.0.0.0:%s) [target] (127.0.0.1:%s)", port, target) log.Printf("q3rcon-proxy initialized: [proxy] (%s:%s) [target] (127.0.0.1:%s)", host, port, target)
log.Fatal(c.ListenAndServe()) log.Fatal(c.ListenAndServe())
} }
func main() { var (
proxies := os.Getenv("Q3RCON_PROXY") proxies, host string
)
func getenvInt(key string) (int, error) {
s := os.Getenv(key)
if s == "" {
return 0, nil
}
v, err := strconv.Atoi(s)
if err != nil {
return 0, err
}
return v, nil
}
func init() {
proxies = os.Getenv("Q3RCON_PROXY")
if proxies == "" { if proxies == "" {
log.Fatal("env Q3RCON_PROXY required") log.Fatal("env Q3RCON_PROXY required")
} }
host = os.Getenv("Q3RCON_HOST")
if host == "" {
host = "0.0.0.0"
}
debug, err := getenvInt("Q3RCON_DEBUG")
if err != nil {
log.Fatal(err)
}
if debug == 1 {
log.SetLevel(log.DebugLevel)
} else {
log.SetLevel(log.InfoLevel)
}
}
func main() {
for _, proxy := range strings.Split(proxies, ";") { for _, proxy := range strings.Split(proxies, ";") {
go start(proxy) go start(proxy)
} }

18
debian/q3rcon-proxy.service vendored Normal file
View File

@@ -0,0 +1,18 @@
[Unit]
Description=Q3Rcon Proxy Service
Wants=network.target
After=network.target
[Service]
Type=simple
User=gameservers
Environment="Q3RCON_PROXY=20000:28960;20001:28961;20002:28962"
Environment="Q3RCON_HOST=0.0.0.0"
Environment="Q3RCON_DEBUG=0"
ExecStart=/usr/local/bin/q3rcon-proxy
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target

4
go.mod
View File

@@ -1,3 +1,7 @@
module github.com/onyx-and-iris/q3rcon-proxy module github.com/onyx-and-iris/q3rcon-proxy
go 1.18 go 1.18
require github.com/sirupsen/logrus v1.9.3
require golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect

15
go.sum
View File

@@ -0,0 +1,15 @@
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/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 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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=

View File

@@ -1,29 +1,35 @@
package udpproxy package udpproxy
import ( import (
"log" "errors"
"net" "net"
"strings"
"time" "time"
log "github.com/sirupsen/logrus"
) )
type Session struct { type session struct {
serverConn *net.UDPConn serverConn *net.UDPConn
proxyConn *net.UDPConn proxyConn *net.UDPConn
caddr *net.UDPAddr caddr *net.UDPAddr
updateTime time.Time updateTime time.Time
validator
} }
func createSession(caddr *net.UDPAddr, raddr *net.UDPAddr, proxyConn *net.UDPConn) (*Session, error) { func newSession(caddr *net.UDPAddr, raddr *net.UDPAddr, proxyConn *net.UDPConn) (*session, error) {
serverConn, err := net.DialUDP("udp", nil, raddr) serverConn, err := net.DialUDP("udp", nil, raddr)
if err != nil { if err != nil {
return nil, err return nil, err
} }
session := &Session{ session := &session{
serverConn: serverConn, serverConn: serverConn,
proxyConn: proxyConn, proxyConn: proxyConn,
caddr: caddr, caddr: caddr,
updateTime: time.Now(), updateTime: time.Now(),
validator: newValidator(),
} }
go session.listen() go session.listen()
@@ -31,12 +37,12 @@ func createSession(caddr *net.UDPAddr, raddr *net.UDPAddr, proxyConn *net.UDPCon
return session, nil return session, nil
} }
func (s *Session) listen() error { func (s *session) listen() error {
for { for {
buf := make([]byte, 2048) buf := make([]byte, 2048)
n, err := s.serverConn.Read(buf) n, err := s.serverConn.Read(buf)
if err != nil { if err != nil {
log.Println(err) log.Error(err)
continue continue
} }
@@ -44,24 +50,49 @@ func (s *Session) listen() error {
} }
} }
func (s *Session) proxyFrom(buf []byte) error { func (s *session) proxyFrom(buf []byte) error {
if !s.isValidResponsePacket(buf) {
err := errors.New("not a rcon or query response packet")
log.Error(err.Error())
return err
}
s.updateTime = time.Now() s.updateTime = time.Now()
_, err := s.proxyConn.WriteToUDP(buf, s.caddr) _, err := s.proxyConn.WriteToUDP(buf, s.caddr)
if err != nil { if err != nil {
log.Println(err) log.Error(err)
return err return err
} }
if s.isRconResponsePacket(buf) {
if s.isBadRconResponse(buf) {
log.Infof("Response: Bad rcon from %s", s.caddr.IP)
} else {
log.Debugf("Response: %s", string(buf[10:]))
}
}
return nil return nil
} }
func (s *Session) proxyTo(buf []byte) error { func (s *session) proxyTo(buf []byte) error {
if !s.isValidRequestPacket(buf) {
err := errors.New("not a rcon or query request packet")
log.Error(err.Error())
return err
}
s.updateTime = time.Now() s.updateTime = time.Now()
_, err := s.serverConn.Write(buf) _, err := s.serverConn.Write(buf)
if err != nil { if err != nil {
log.Println(err) log.Error(err)
return err return err
} }
if s.isRconRequestPacket(buf) {
parts := strings.SplitN(string(buf), " ", 3)
log.Infof("From [%s] To [%s] Command: %s", s.caddr.IP, s.serverConn.RemoteAddr(), parts[len(parts)-1])
}
return nil return nil
} }

View File

@@ -1,10 +1,11 @@
package udpproxy package udpproxy
import ( import (
"log"
"net" "net"
"sync" "sync"
"time" "time"
log "github.com/sirupsen/logrus"
) )
type Client struct { type Client struct {
@@ -14,7 +15,7 @@ type Client struct {
proxyConn *net.UDPConn proxyConn *net.UDPConn
mutex sync.RWMutex mutex sync.RWMutex
sessions map[string]*Session sessions map[string]*session
} }
func New(port, target string) (*Client, error) { func New(port, target string) (*Client, error) {
@@ -32,14 +33,10 @@ func New(port, target string) (*Client, error) {
laddr: laddr, laddr: laddr,
raddr: raddr, raddr: raddr,
mutex: sync.RWMutex{}, mutex: sync.RWMutex{},
sessions: map[string]*Session{}, sessions: map[string]*session{},
}, nil }, nil
} }
func (c *Client) isValidPacket(header []byte) bool {
return string(header[:8]) == "\xff\xff\xff\xffrcon" || string(header[:13]) == "\xff\xff\xff\xffgetstatus" || string(header[:11]) == "\xff\xff\xff\xffgetinfo"
}
func (c *Client) ListenAndServe() error { func (c *Client) ListenAndServe() error {
var err error var err error
c.proxyConn, err = net.ListenUDP("udp", c.laddr) c.proxyConn, err = net.ListenUDP("udp", c.laddr)
@@ -53,18 +50,14 @@ func (c *Client) ListenAndServe() error {
buf := make([]byte, 2048) buf := make([]byte, 2048)
n, caddr, err := c.proxyConn.ReadFromUDP(buf) n, caddr, err := c.proxyConn.ReadFromUDP(buf)
if err != nil { if err != nil {
log.Println(err) log.Error(err)
}
if !c.isValidPacket(buf[:16]) {
continue
} }
session, found := c.sessions[caddr.String()] session, found := c.sessions[caddr.String()]
if !found { if !found {
session, err = createSession(caddr, c.raddr, c.proxyConn) session, err = newSession(caddr, c.raddr, c.proxyConn)
if err != nil { if err != nil {
log.Println(err) log.Error(err)
continue continue
} }

View File

@@ -1,83 +0,0 @@
package udpproxy
import (
"log"
"net"
"testing"
"time"
)
func TestSendAndReceive(t *testing.T) {
go runLilProxy()
go runUDPServer()
paddr, err := net.ResolveUDPAddr("udp", "localhost:9000")
if err != nil {
t.Fatal(err)
}
conn, err := net.DialUDP("udp", nil, paddr)
if err != nil {
t.Fatal(err)
}
go func() {
for {
buf := make([]byte, 2048)
_, _, err = conn.ReadFromUDP(buf)
if err != nil {
log.Fatal(err)
}
log.Printf("response received: %s", string(buf))
}
}()
for {
time.Sleep(1 * time.Second)
_, err = conn.Write([]byte("hi\n"))
if err != nil {
log.Fatal(err)
}
}
}
func runLilProxy() {
port := ":9000"
target := "localhost:9001"
c, err := New(port, target)
if err != nil {
log.Fatal(err)
}
log.Fatal(c.ListenAndServe())
}
func runUDPServer() {
taddr, err := net.ResolveUDPAddr("udp", ":9001")
if err != nil {
log.Fatal(err)
}
conn, err := net.ListenUDP("udp", taddr)
if err != nil {
log.Fatal(err)
}
for {
buf := make([]byte, 2048)
_, caddr, err := conn.ReadFromUDP(buf)
if err != nil {
log.Fatal(err)
}
log.Printf("request received: %s", string(buf))
_, err = conn.WriteToUDP([]byte("bye\n"), caddr)
if err != nil {
log.Fatal(err)
}
}
}

53
pkg/udpproxy/validator.go Normal file
View File

@@ -0,0 +1,53 @@
package udpproxy
import "bytes"
type validator struct {
rconRequestHeader []byte
getstatusRequestHeader []byte
getinfoRequestHeader []byte
rconResponseHeader []byte
getstatusResponseHeader []byte
getinfoResponseHeader []byte
badRconIdentifier []byte
}
func newValidator() validator {
return validator{
rconRequestHeader: []byte("\xff\xff\xff\xffrcon"),
getstatusRequestHeader: []byte("\xff\xff\xff\xffgetstatus"),
getinfoRequestHeader: []byte("\xff\xff\xff\xffgetinfo"),
rconResponseHeader: []byte("\xff\xff\xff\xffprint\n"),
getstatusResponseHeader: []byte("\xff\xff\xff\xffstatusResponse\n"),
getinfoResponseHeader: []byte("\xff\xff\xff\xffinfoResponse\n"),
badRconIdentifier: []byte("Bad rcon"),
}
}
func (v *validator) isRconRequestPacket(buf []byte) bool {
return bytes.Equal(buf[:len(v.rconRequestHeader)], v.rconRequestHeader)
}
func (v *validator) isQueryRequestPacket(buf []byte) bool {
return bytes.Equal(buf[:len(v.getstatusRequestHeader)], v.getstatusRequestHeader) || bytes.Equal(buf[:len(v.getinfoRequestHeader)], v.getinfoRequestHeader)
}
func (v *validator) isValidRequestPacket(buf []byte) bool {
return v.isRconRequestPacket(buf) || v.isQueryRequestPacket(buf)
}
func (v *validator) isRconResponsePacket(buf []byte) bool {
return bytes.Equal(buf[:len(v.rconResponseHeader)], v.rconResponseHeader)
}
func (v *validator) isQueryResponsePacket(buf []byte) bool {
return bytes.Equal(buf[:len(v.getstatusResponseHeader)], v.getstatusResponseHeader) || bytes.Equal(buf[:len(v.getinfoResponseHeader)], v.getinfoResponseHeader)
}
func (v *validator) isValidResponsePacket(buf []byte) bool {
return v.isRconResponsePacket(buf) || v.isQueryResponsePacket(buf)
}
func (v *validator) isBadRconResponse(buf []byte) bool {
return bytes.Equal(buf[len(v.rconResponseHeader):len(v.rconResponseHeader)+len(v.badRconIdentifier)], v.badRconIdentifier)
}