Compare commits

..

No commits in common. "dev" and "v1.6.0" have entirely different histories.
dev ... v1.6.0

15 changed files with 100 additions and 196 deletions

View File

@ -1,6 +1,6 @@
*
!cmd/
!*.go
!pkg/
!go.mod
!go.sum

4
.vscode/launch.json vendored
View File

@ -9,9 +9,9 @@
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/cmd/q3rcon-proxy/",
"program": "${workspaceFolder}/cmd/q3rcon-proxy/main.go",
"env": {
"Q3RCON_PORTS_MAPPING": "28961:28960",
"Q3RCON_PROXY": "28961:28960",
}
}
]

View File

@ -11,18 +11,6 @@ Before any major/minor/patch bump all unit tests will be run to verify they pass
- [x]
## [1.7.0] - 2025-06-05
### Added
- Taskfile added for running and building project.
- The binary may be passed CLI flags as well as environment variables.
### Changed
- CLI component rewritten with urfave/cli.
- env var `Q3RCON_TARGET_PORTS` renamed to `Q3RCON_PORTS_MAPPING`
## [1.4.0] - 2024-11-29
### Added

View File

@ -8,36 +8,17 @@ Unfortunately the Q3Rcon engine ties the rcon port to the game servers public po
### Use
#### Flags
Run one or multiple rcon proxies by setting an environment variable `Q3RCON_TARGET_PORTS`
for example:
```bash
#!/usr/bin/env bash
/usr/local/bin/q3rcon-proxy \
--proxy-host=0.0.0.0 \
--target-host=localhost \
--ports-mapping=28961:28960 \
--session-timeout=20 \
--loglevel=debug
export Q3RCON_TARGET_PORTS="20000:28960;20001:28961;20002:28962"
```
#### Environment Variables
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.
Each of the flags has a corresponding environment variable:
- `Q3RCON_PROXY_HOST`: The host the proxy server sits on.
- `Q3RCON_TARGET_HOST`: The host the game servers sit on.
- `Q3RCON_PORTS_MAPPING`: A mapping as a string with `source:target` pairs delimited by `;`.
- `Q3RCON_SESSION_TIMEOUT`: Timeout in seconds for each udp session.
- `Q3RCON_LOGLEVEL`: The application's logging level (see [Logging][logging]).
Multiple rcon proxies may be configured by setting *--ports-mapping/Q3RCON_PORTS_MAPPING* like so:
```console
export Q3RCON_PORTS_MAPPING="20000:28960;20001:28961;20002:28962"
```
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.
### Logging
@ -67,4 +48,3 @@ If not set it will default to `info`.
For a compatible rcon client also written in Go consider checking out the [Q3 Rcon][q3rcon] package.
[q3rcon]: https://github.com/onyx-and-iris/q3rcon
[logging]: https://github.com/onyx-and-iris/q3rcon-proxy/tree/dev?tab=readme-ov-file#logging

View File

@ -58,14 +58,3 @@ tasks:
desc: Clean the build artifacts
cmds:
- '{{.SHELL}} rm -r {{.BIN_DIR}}'
run:
desc: Run the q3rcon-proxy project
cmds:
- |
go run ./cmd/{{.PROGRAM}} \
--proxy-host=0.0.0.0 \
--target-host=localhost \
--ports-mapping=28961:28960 \
--session-timeout=20 \
--loglevel=debug

View File

@ -1,132 +1,75 @@
package main
import (
"context"
"fmt"
"os"
"strconv"
"strings"
"time"
udpproxy "github.com/onyx-and-iris/q3rcon-proxy"
log "github.com/sirupsen/logrus"
"github.com/urfave/cli/v3"
"github.com/onyx-and-iris/q3rcon-proxy/pkg/udpproxy"
)
// proxyConfig holds the configuration for a single UDP proxy server.
type proxyConfig struct {
proxyHost string
targetHost string
portsMapping []string
sessionTimeout int
}
func main() {
cmd := &cli.Command{
Name: "q3rcon-proxy",
Usage: "A Quake 3 RCON proxy server",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "proxy-host",
Value: "0.0.0.0",
Usage: "Proxy host address",
Sources: cli.EnvVars("Q3RCON_PROXY_HOST"),
},
&cli.StringFlag{
Name: "target-host",
Value: "localhost",
Usage: "Target host address",
Sources: cli.EnvVars("Q3RCON_TARGET_HOST"),
},
&cli.StringFlag{
Name: "ports-mapping",
Usage: "Proxy and target ports (proxy:target)",
Sources: cli.EnvVars("Q3RCON_PORTS_MAPPING"),
Required: true,
Action: func(ctx context.Context, cmd *cli.Command, v string) error {
// Validate the ports mapping
for mapping := range strings.SplitSeq(v, ";") {
ports := strings.Split(mapping, ":")
if len(ports) != 2 {
return fmt.Errorf("invalid ports mapping: %s", mapping)
loglevel := os.Getenv("Q3RCON_LOGLEVEL")
if loglevel == "" {
loglevel = "info"
}
proxyPort, err := strconv.Atoi(ports[0])
if err != nil || proxyPort < 1 || proxyPort > 65535 {
return fmt.Errorf("invalid proxy port: %s", ports[0])
}
targetPort, err := strconv.Atoi(ports[1])
if err != nil || targetPort < 1 || targetPort > 65535 {
return fmt.Errorf("invalid target port: %s", ports[1])
}
if proxyPort == targetPort {
return fmt.Errorf("proxy and target ports cannot be the same: %s", mapping)
}
}
return nil
},
},
&cli.IntFlag{
Name: "session-timeout",
Value: 20,
Usage: "Session timeout in minutes",
Sources: cli.EnvVars("Q3RCON_SESSION_TIMEOUT"),
},
&cli.StringFlag{
Name: "loglevel",
Value: "info",
Usage: "Log level (trace, debug, info, warn, error, fatal, panic)",
Sources: cli.EnvVars("Q3RCON_LOGLEVEL"),
},
},
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
logLevel, err := log.ParseLevel(cmd.String("loglevel"))
level, err := log.ParseLevel(loglevel)
if err != nil {
return ctx, fmt.Errorf("invalid log level: %w", err)
fmt.Fprintf(os.Stderr, "Invalid log level: %s\n", loglevel)
os.Exit(1)
}
log.SetLevel(logLevel)
return ctx, nil
},
Action: func(_ context.Context, cmd *cli.Command) error {
errChan := make(chan error)
log.SetLevel(level)
for mapping := range strings.SplitSeq(cmd.String("ports-mapping"), ";") {
cfg := proxyConfig{
proxyHost: cmd.String("proxy-host"),
targetHost: cmd.String("target-host"),
portsMapping: strings.Split(mapping, ":"),
sessionTimeout: cmd.Int("session-timeout"),
proxyHost := os.Getenv("Q3RCON_PROXY_HOST")
if proxyHost == "" {
proxyHost = "0.0.0.0"
}
go launchProxy(cfg, errChan)
targetHost := os.Getenv("Q3RCON_TARGET_HOST")
if targetHost == "" {
targetHost = "127.0.0.1"
}
// Under normal circumstances, the main goroutine will block here.
// If we receive an error we will log it and exit
return <-errChan
},
proxies := os.Getenv("Q3RCON_TARGET_PORTS")
if proxies == "" {
log.Fatal("env Q3RCON_TARGET_PORTS required")
}
log.Fatal(cmd.Run(context.Background(), os.Args))
sessionTimeout, err := getEnvInt("Q3RCON_SESSION_TIMEOUT")
if err != nil {
log.Fatalf("unable to parse Q3RCON_SESSION_TIMEOUT: %s", err.Error())
}
if sessionTimeout == 0 {
sessionTimeout = 20
}
// launchProxy initializes the UDP proxy server with the given configuration.
// It listens on the specified proxy host and port, and forwards traffic to the target host and port.
// server.ListenAndServe blocks until the server is stopped or an error occurs.
func launchProxy(cfg proxyConfig, errChan chan<- error) {
proxyPort, targetPort := cfg.portsMapping[0], cfg.portsMapping[1]
for _, proxy := range strings.Split(proxies, ";") {
go start(proxyHost, targetHost, proxy, sessionTimeout)
}
hostAddr := fmt.Sprintf("%s:%s", cfg.proxyHost, proxyPort)
proxyAddr := fmt.Sprintf("%s:%s", cfg.targetHost, targetPort)
<-make(chan struct{})
}
server, err := udpproxy.New(
func start(proxyHost, targetHost, ports string, sessionTimeout int) {
proxyPort, targetPort := func() (string, string) {
x := strings.Split(ports, ":")
return x[0], x[1]
}()
hostAddr := fmt.Sprintf("%s:%s", proxyHost, proxyPort)
proxyAddr := fmt.Sprintf("%s:%s", targetHost, targetPort)
c, err := udpproxy.New(
hostAddr, proxyAddr,
udpproxy.WithSessionTimeout(time.Duration(cfg.sessionTimeout)*time.Minute))
udpproxy.WithSessionTimeout(time.Duration(sessionTimeout)*time.Minute))
if err != nil {
errChan <- fmt.Errorf("failed to create proxy: %w", err)
return
log.Fatal(err)
}
log.Printf("q3rcon-proxy initialized: [proxy] (%s) [target] (%s)", hostAddr, proxyAddr)
errChan <- server.ListenAndServe()
log.Fatal(c.ListenAndServe())
}

18
cmd/q3rcon-proxy/util.go Normal file
View File

@ -0,0 +1,18 @@
package main
import (
"os"
"strconv"
)
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
}

View File

@ -6,9 +6,9 @@ After=network.target
[Service]
Type=simple
User=gameservers
Environment="Q3RCON_PORTS_MAPPING=20000:28960;20001:28961;20002:28962"
Environment="Q3RCON_PROXY=20000:28960;20001:28961;20002:28962"
Environment="Q3RCON_HOST=0.0.0.0"
Environment="Q3RCON_LOGLEVEL=info"
Environment="Q3RCON_DEBUG=0"
ExecStart=/usr/local/bin/q3rcon-proxy
Restart=always

7
go.mod
View File

@ -4,9 +4,6 @@ go 1.24.0
toolchain go1.24.1
require (
github.com/sirupsen/logrus v1.9.3
github.com/urfave/cli/v3 v3.3.3
)
require github.com/sirupsen/logrus v1.9.3
require golang.org/x/sys v0.33.0 // indirect
require golang.org/x/sys v0.31.0 // indirect

12
go.sum
View File

@ -6,15 +6,11 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
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=
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/urfave/cli/v3 v3.3.3 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I=
github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
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=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -1,22 +0,0 @@
package udpproxy
import (
"time"
log "github.com/sirupsen/logrus"
)
// Option is a functional option type that allows us to configure the Client.
type Option func(*Client)
// WithSessionTimeout is a functional option to set the session timeout
func WithSessionTimeout(timeout time.Duration) Option {
return func(c *Client) {
if timeout < time.Minute {
log.Warnf("cannot set stale session timeout to less than 1 minute.. defaulting to 20 minutes")
return
}
c.sessionTimeout = timeout
}
}

View File

@ -7,6 +7,21 @@ import (
log "github.com/sirupsen/logrus"
)
// Option is a functional option type that allows us to configure the Client.
type Option func(*Client)
// WithSessionTimeout is a functional option to set the session timeout
func WithSessionTimeout(timeout time.Duration) Option {
return func(c *Client) {
if timeout < time.Minute {
log.Warnf("cannot set stale session timeout to less than 1 minute.. defaulting to 20 minutes")
return
}
c.sessionTimeout = timeout
}
}
type Client struct {
laddr *net.UDPAddr
raddr *net.UDPAddr