176 lines
4.8 KiB
Go

// Package main implements a command-line application for a Quake 3 RCON proxy server.
package main
import (
"context"
"fmt"
"os"
"runtime/debug"
"strconv"
"strings"
"time"
"github.com/charmbracelet/log"
"github.com/urfave/cli/v3"
udpproxy "github.com/onyx-and-iris/q3rcon-proxy"
)
var version string // Version holds the application version, set at build time using ldflags.
// versionFromBuild retrieves the version information from the build metadata.
func versionFromBuild() string {
if version != "" {
return version
}
info, ok := debug.ReadBuildInfo()
if !ok {
return "(unable to read version)"
}
return strings.Split(info.Main.Version, "-")[0]
}
// proxyConfig holds the configuration for a single UDP proxy server.
type proxyConfig struct {
proxyHost string
targetHost string
portsMapping []string
sessionTimeout int
}
func main() {
cli.VersionPrinter = func(cmd *cli.Command) {
fmt.Printf("q3rcon-proxy version: %s\n", cmd.Root().Version)
}
cmd := &cli.Command{
Name: "q3rcon-proxy",
Usage: "A Quake 3 RCON proxy server",
Version: versionFromBuild(),
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.StringSliceFlag{
Name: "port-map",
Usage: "Ports mapping in the format proxyPort:targetPort (e.g., 27950:27960)",
Sources: cli.EnvVars("Q3RCON_PORT_MAP"),
Required: true,
Action: func(_ context.Context, cmd *cli.Command, v []string) error {
// Validate the ports mapping format and values
for _, mapping := range v {
src, dst := func(m string) (string, string) {
parts := strings.Split(m, ":")
if len(parts) != 2 {
return "", ""
}
return parts[0], parts[1]
}(mapping)
if src == "" || dst == "" {
return fmt.Errorf(
"invalid ports mapping: %s (expected format: proxyPort:targetPort)",
mapping,
)
}
n, err := strconv.Atoi(src)
if err != nil || n <= 0 || n > 65535 {
return fmt.Errorf("invalid proxy port: %s", src)
}
n, err = strconv.Atoi(dst)
if err != nil || n <= 0 || n > 65535 {
return fmt.Errorf("invalid target port: %s", dst)
}
if src == dst {
return fmt.Errorf(
"proxy port and target port cannot be the same: %s",
src,
)
}
log.Debugf(
"Validated ports mapping: proxy port %s -> target port %s",
src,
dst,
)
}
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"))
if err != nil {
return ctx, fmt.Errorf("invalid log level: %w", err)
}
log.SetLevel(logLevel)
return ctx, nil
},
Action: func(_ context.Context, cmd *cli.Command) error {
errChan := make(chan error)
for _, mapping := range cmd.StringSlice("port-map") {
cfg := proxyConfig{
proxyHost: cmd.String("proxy-host"),
targetHost: cmd.String("target-host"),
portsMapping: strings.Split(mapping, ":"),
sessionTimeout: cmd.Int("session-timeout"),
}
go launchProxy(cfg, errChan)
}
// Under normal circumstances, the main goroutine will block here.
// If we receive an error we will log it and exit
return <-errChan
},
}
if err := cmd.Run(context.Background(), os.Args); err != nil {
log.Fatal(err)
}
}
// launchProxy initialises 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]
hostAddr := fmt.Sprintf("%s:%s", cfg.proxyHost, proxyPort)
proxyAddr := fmt.Sprintf("%s:%s", cfg.targetHost, targetPort)
server, err := udpproxy.New(
hostAddr, proxyAddr,
udpproxy.WithSessionTimeout(time.Duration(cfg.sessionTimeout)*time.Minute))
if err != nil {
errChan <- fmt.Errorf("failed to create proxy: %w", err)
return
}
log.Infof("q3rcon-proxy initialised: [proxy] (%s) [target] (%s)", hostAddr, proxyAddr)
errChan <- server.ListenAndServe()
}