96 Commits
main ... v1.7.3

Author SHA1 Message Date
6bffa14240 upd go.mod go.sum 2026-02-16 10:30:50 +00:00
74493e6f61 add 1.7.3 to CHANGELOG 2026-02-16 10:29:34 +00:00
51a0065e79 swap out logrus for charm log 2026-02-16 10:29:27 +00:00
f07a45c81b add macos target to goreleaser config 2026-02-16 10:28:52 +00:00
a70fdecdbb add --version flag
ensure version is injected at build time in Taskfile/makefile
2026-02-16 01:49:28 +00:00
fc6ac8fa4e enable godot, misspell linters 2026-02-15 17:14:34 +00:00
f6ea67b88c enable revive linter 2026-02-15 16:02:07 +00:00
d18834b290 add macos target to makefile/taskfile 2026-02-15 15:56:13 +00:00
46874f2cf5 format fixes 2026-02-15 15:54:02 +00:00
1c5e18294e migrate golangci config to version 2
add golang-ci workflow
2026-02-15 15:53:35 +00:00
github-actions[bot]
331bb61882 chore: auto-update Go modules 2026-02-09 00:15:45 +00:00
3db79314ce add chore: to changelog filter 2026-01-28 21:43:49 +00:00
github-actions[bot]
ecfbd8771b chore: auto-update Go modules 2026-01-19 00:11:57 +00:00
github-actions[bot]
991e063390 chore: auto-update Go modules 2026-01-12 00:11:52 +00:00
github-actions[bot]
2e5ba6d6fc chore: auto-update Go modules 2025-12-15 00:11:45 +00:00
github-actions[bot]
7455aeed0f chore: auto-update Go modules 2025-11-17 00:11:01 +00:00
github-actions[bot]
5490f13175 chore: auto-update Go modules 2025-11-10 00:10:37 +00:00
github-actions[bot]
539a9f00a6 chore: auto-update Go modules 2025-10-27 00:10:55 +00:00
github-actions[bot]
f66cb52cce chore: auto-update Go modules 2025-10-13 00:10:25 +00:00
github-actions[bot]
a7ab564de4 chore: auto-update Go modules 2025-09-08 00:10:24 +00:00
github-actions[bot]
34c003694d chore: auto-update Go modules 2025-08-18 00:11:20 +00:00
github-actions[bot]
3ecce5bb66 chore: auto-update Go modules 2025-08-11 00:11:41 +00:00
github-actions[bot]
4d42600ecd chore: auto-update Go modules 2025-07-14 00:11:30 +00:00
github-actions[bot]
0116d3dbe1 chore: auto-update Go modules 2025-06-16 00:10:41 +00:00
github-actions[bot]
7897348669 chore: auto-update Go modules 2025-05-12 00:10:39 +00:00
6489d1e556 return value straight from errChan 2025-05-12 00:01:00 +01:00
d0e3f5863a rename initProxy to launchProxy
remove double log on error

add some explanatory comments.
2025-05-07 16:16:35 +01:00
a00796254d move the functional option into option.go 2025-05-07 16:15:32 +01:00
80ef18f705 upd .dockerignore 2025-05-07 15:56:44 +01:00
40d79063f5 add validation logic to ports-mapping flag 2025-05-07 15:41:34 +01:00
02e73a21c0 move udpproxy files into repo root 2025-05-07 11:38:24 +01:00
5e399b8590 set loglevel in Before function 2025-05-07 00:30:47 +01:00
28baa2ed00 upd launch config 2025-05-07 00:06:01 +01:00
58c95879cc upd debian service example 2025-05-06 23:57:48 +01:00
af0a72eb67 remove redundant function getEnvInt 2025-05-06 23:52:50 +01:00
0d85129d31 add section 1.7.0 to readme 2025-05-06 23:44:30 +01:00
bf7bd4f5b8 add run task 2025-05-06 23:43:54 +01:00
a13d3d7da3 rewrite cli component with urfave/cli 2025-05-06 23:43:47 +01:00
a0c1f15ef0 fix example 2025-05-06 23:42:32 +01:00
e0dc2c02d9 fix link 2025-05-06 23:40:46 +01:00
514688b9ce typo 2025-05-06 23:39:34 +01:00
43183717f6 upd readme 2025-05-06 23:38:31 +01:00
github-actions[bot]
464fbec85f chore: auto-update Go modules 2025-04-07 00:10:03 +00:00
fd51761ab5 update loglevel to str env var 2025-04-06 00:01:36 +01:00
16e5e9c010 upd vars 2025-03-16 01:34:01 +00:00
916d2a2d86 add docker tasks 2025-03-16 01:04:16 +00:00
github-actions[bot]
1bbe3102ce chore: auto-update Go modules 2025-03-10 00:07:55 +00:00
github-actions[bot]
f1762cf3ad chore: auto-update Go modules 2025-02-24 00:09:17 +00:00
386aab510e add update and release actions 2025-02-17 13:37:33 +00:00
c8fd7a3f22 run through formatter 2025-02-07 23:19:05 +00:00
db1c45a25b add taskfile 2025-02-03 18:25:39 +00:00
a1ecf85cbb updated changelog with dates. 2024-11-29 15:25:56 +00:00
6c83d6ad2c add multi platform targets to makefile 2024-11-29 04:34:12 +00:00
c2266ac9d9 target host is now configurable.
stale session timeout default value increased
2024-11-29 04:33:47 +00:00
b0a6ba8180 add link to q3rcon client 2024-11-29 04:29:18 +00:00
9b4a05c0f4 add .dockerignore 2024-11-12 10:12:36 +00:00
bfe31c28c8 no need to export sessionCache methods
rename upsert to insert since we only call it if there isn't a session.
2024-10-23 17:22:03 +01:00
abc1ea9d3f rename Q3RCON_STALE_SESSION_TIMEOUT to Q3RCON_SESSION_TIMEOUT 2024-10-23 16:45:05 +01:00
82ca15f70e add sessionCache, use it to store sessions. 2024-10-23 16:44:35 +01:00
8cb5bc03c5 upd comment 2024-10-19 22:01:47 +01:00
939d419438 use multi-stage build 2024-10-19 21:59:24 +01:00
c42df03858 read from env var Q3RCON_STALE_SESSION_TIMEOUT 2024-10-19 21:19:33 +01:00
64c1df645d add WithStaleTimeout Option function.
add a trace level log when a session gets deleted.
2024-10-19 21:19:03 +01:00
51f22f480b add Logging section to README. 2024-09-28 15:37:38 +01:00
3d1c8f90f3 remove init function\
remove global vars proxies, host
2024-09-28 15:36:24 +01:00
2fc0f7a74f log challenge requests 2024-09-28 15:36:04 +01:00
6cd90ae1dd reuse single buffers 2024-09-28 00:38:55 +01:00
826756eb6e rename comparand arg 2024-04-14 19:13:56 +01:00
05fb7bdd0f add compare helper method
split up long conditionals
2024-04-14 08:27:30 +01:00
e8f213fded easier to read? 2024-04-11 23:40:25 +01:00
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
27 changed files with 1193 additions and 293 deletions

6
.dockerignore Normal file
View File

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

30
.github/workflows/golang-ci.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: CI
on:
push:
branches: ['main']
paths:
- '**.go'
pull_request:
branches: ['main']
paths:
- '**.go'
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
timeout-minutes: 3
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.6.0
args: --config .golangci.yml

31
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,31 @@
name: goreleaser
on:
push:
tags:
- 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
-
name: Set up Go
uses: actions/setup-go@v5
-
name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

30
.github/workflows/update-go-modules.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: Auto-Update Go Modules
on:
schedule:
- cron: "0 0 * * 1" # Runs every Monday at midnight
jobs:
update-go-modules:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: stable
- name: Update Dependencies
run: |
go get -u ./...
go mod tidy
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add go.mod go.sum
git commit -m "chore: auto-update Go modules"
git push

10
.gitignore vendored
View File

@@ -1,3 +1,5 @@
# Auto-generated .gitignore by gignore: github.com/onyx-and-iris/gignore
### Go ###
# If you prefer the allow list template instead of the deny list, see community template: # 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 # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
# #
@@ -7,6 +9,7 @@
*.dll *.dll
*.so *.so
*.dylib *.dylib
bin/
# Test binary, built with `go test -c` # Test binary, built with `go test -c`
*.test *.test
@@ -19,3 +22,10 @@
# Go workspace file # Go workspace file
go.work go.work
# End of gignore: github.com/onyx-and-iris/gignore
# Added by goreleaser init:
dist/
# env files
.envrc

142
.golangci.yml Normal file
View File

@@ -0,0 +1,142 @@
version: '2'
run:
timeout: 3m
tests: true
go: '1.24'
linters:
disable: [errcheck]
enable:
# Default enabled linters
- errcheck # Check for unchecked errors
- govet # Go's built-in vetting tool
- ineffassign # Detect ineffectual assignments
- staticcheck # Advanced static analysis
- unused # Check for unused code
# Additional useful linters
- misspell # Detect common misspellings
- unparam # Check for unused function parameters
- gosec # Security checks
- asciicheck # Check for non-ASCII characters
- errname # Check error variable names
- godot # Check for missing periods in comments
- revive # Highly configurable linter for style and correctness
- gocritic # Detect code issues and suggest improvements
- gocyclo # Check for cyclomatic complexity
- dupl # Check for code duplication
- predeclared # Check for shadowing of predeclared identifiers
- copyloopvar # Check for loop variable capture in goroutines
- errorlint # Check for common mistakes in error handling
- goconst # Check for repeated strings that could be constants
- gosmopolitan # Check for non-portable code
settings:
misspell:
locale: UK
errcheck:
check-type-assertions: true
check-blank: true
exclude-functions:
- fmt.Fprintf
- fmt.Fprintln
- fmt.Printf
- fmt.Println
- fmt.Errorf
revive:
severity: warning
rules:
# Code quality and style
- name: exported
arguments:
- 'checkPrivateReceivers'
- 'sayRepetitiveInsteadOfStutters'
- name: var-naming
- name: package-comments
- name: range-val-in-closure
- name: time-naming
- name: context-as-argument
- name: context-keys-type
- name: dot-imports
- name: empty-block
- name: error-return
- name: error-strings
- name: error-naming
- name: if-return
- name: increment-decrement
- name: indent-error-flow
- name: receiver-naming
- name: redefines-builtin-id
- name: superfluous-else
- name: unexported-return
- name: unreachable-code
- name: unused-parameter
- name: var-declaration
- name: blank-imports
- name: range
# Disabled rules (can be enabled if needed)
# - name: line-length-limit
# arguments: [120]
# - name: function-length
# arguments: [50, 0]
# - name: cyclomatic
# arguments: [10]
gosec:
excludes:
- G104 # Duplicated errcheck checks
- G115 # integer overflow conversion int -> uint32
exclusions:
warn-unused: false
rules:
# Exclude some linters from running on tests files.
- path: _test\.go
linters:
- gocyclo
- errcheck
- dupl
- gosec
paths:
- vendor
# Formatters configuration
formatters:
# Enable specific formatters
enable:
- gofumpt # Stricter gofmt alternative
- goimports # Organizes imports
- gci # Controls import order/grouping
- golines # Enforces line length
# Formatter-specific settings
settings:
goimports:
local-prefixes: [github.com/onyx-and-iris/q3rcon-proxy]
gci:
# Define import sections order
sections:
- standard # Standard library
- default # Everything else
- prefix(github.com/onyx-and-iris/q3rcon-proxy) # Current module
gofumpt:
extra-rules: true # Enable additional formatting rules
exclusions:
warn-unused: true
paths:
- vendor
issues:
# Limit the number of same issues reported to avoid spam
max-same-issues: 50
# Limit the number of issues per linter to keep output manageable
max-issues-per-linter: 100

57
.goreleaser.yaml Normal file
View File

@@ -0,0 +1,57 @@
# This is an example .goreleaser.yml file with some sensible defaults.
# Make sure to check the documentation at https://goreleaser.com
# The lines below are called `modelines`. See `:help modeline`
# Feel free to remove those if you don't want/need to use them.
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
version: 2
before:
hooks:
# You may remove this if you don't use go modules.
- go mod tidy
# you may remove this if you don't need go generate
- go generate ./...
builds:
- main: ./cmd/q3rcon-proxy/
env:
- CGO_ENABLED=0
goos:
- linux
- windows
- darwin
goarch:
- amd64
archives:
- formats: ['tar.gz']
# this name template makes the OS and Arch compatible with the results of `uname`.
name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
# use zip for windows archives
format_overrides:
- goos: windows
formats: ['zip']
changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'
- '^chore:'
release:
footer: >-
---
Released by [GoReleaser](https://github.com/goreleaser/goreleaser).

4
.vscode/launch.json vendored
View File

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

View File

@@ -11,12 +11,102 @@ Before any major/minor/patch bump all unit tests will be run to verify they pass
- [x] - [x]
## [1.7.3] - 2026-02-26
### Added
- *--version/-v* flag for printing the version of the CLI.
### Changed
- now using [charmbracelet/log][charmlog] package for logging.
## [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
- new environment variable `Q3RCON_TARGET_HOST` for setting the host the gameserver is on.
### Changed
- environment variable `Q3RCON_HOST` renamed to `Q3RCON_PROXY_HOST`
- environment variable `Q3RCON_PROXY` renamed to `Q3RCON_TARGET_PORTS`.
- default session timeout changed from 5 to 20 minutes.
## [1.3.0] - 2024-10-23
### Added
- Add sessionCache for tracking sessions.
- Functional option `WithStaleTimeout` renamed to `WithSessionTimeout`
## [1.2.0] - 2024-10-19
### Added
- optional function `WithStaleTimeout`, use it to configure the session timeout value.
- it defaults to 5 minutes.
## [1.1.0] - 2024-09-28
### Added
- connection (challenge) requests are now logged.
## [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
[charmlog]: https://github.com/charmbracelet/log

View File

@@ -1,12 +0,0 @@
FROM golang:alpine
WORKDIR /dist
COPY . .
# build binary and place into /usr/local/bin
RUN go mod download && go mod verify
RUN go build -v -o /usr/local/bin/q3rcon-proxy ./cmd/q3rcon-proxy
# Command to run when starting the container
ENTRYPOINT [ "q3rcon-proxy" ]

View File

@@ -1,2 +1,46 @@
go-build: PROGRAM = q3rcon-proxy
go build ./cmd/q3rcon-proxy/
GO = @go
BIN_DIR := bin
WINDOWS=$(BIN_DIR)/$(PROGRAM)_windows_amd64.exe
LINUX=$(BIN_DIR)/$(PROGRAM)_linux_amd64
MACOS=$(BIN_DIR)/$(PROGRAM)_darwin_amd64
VERSION=$(shell git describe --tags $(shell git rev-list --tags --max-count=1))
.DEFAULT_GOAL := build
.PHONY: fmt vet build windows linux macos test clean
fmt:
$(GO) fmt ./...
vet: fmt
$(GO) vet ./...
build: vet windows linux macos | $(BIN_DIR)
@echo version: $(VERSION)
windows: $(WINDOWS)
linux: $(LINUX)
macos: $(MACOS)
$(WINDOWS):
env GOOS=windows GOARCH=amd64 go build -v -o $(WINDOWS) -ldflags="-s -w -X main.version=$(VERSION)" ./cmd/$(PROGRAM)/
$(LINUX):
env GOOS=linux GOARCH=amd64 go build -v -o $(LINUX) -ldflags="-s -w -X main.version=$(VERSION)" ./cmd/$(PROGRAM)/
$(MACOS):
env GOOS=darwin GOARCH=amd64 go build -v -o $(MACOS) -ldflags="-s -w -X main.version=$(VERSION)" ./cmd/$(PROGRAM)/
test:
$(GO) test ./...
$(BIN_DIR):
@mkdir -p $@
clean:
@rm -rv $(BIN_DIR)

View File

@@ -2,27 +2,69 @@
A modification of [lilproxy][lilproxy_url] that forwards only Q3 rcon/query packets. Useful for separating the rcon port from the game server port. A modification of [lilproxy][lilproxy_url] that forwards only Q3 rcon/query packets. Useful for separating the rcon port from the game server port.
### Use
Run one or multiple rcon proxies by setting an environment variable `Q3RCON_PROXY`
for example:
```bash
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.
### Why ### Why
Avoid sending plaintext rcon requests (that include the password) to public ports. Instead send them to whitelisted ports. Unfortunately the Q3Rcon engine ties the rcon port to the game servers public port used for client connections. This proxy will allow you to run rcon through a separate whitelisted port.
Gives you the option to disable remote rcon entirely and have the server accept requests only from localhost. ### Use
#### Flags
```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
```
#### Environment Variables
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.
### Logging
Set the log level with environment variable `Q3RCON_LOGLEVEL`.
Acceptable values are:
- `trace`
- `debug`
- `info`
- `warn`
- `error`
- `fatal`
- `panic`
If not set it will default to `info`.
### 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
### Further Notes
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

79
Taskfile.yml Normal file
View File

@@ -0,0 +1,79 @@
version: '3'
includes:
docker: ./docker/Taskfile.docker.yml
vars:
PROGRAM: q3rcon-proxy
SHELL: '{{if eq .OS "Windows_NT"}}powershell{{end}}'
BIN_DIR: bin
WINDOWS: '{{.BIN_DIR}}/{{.PROGRAM}}_windows_amd64.exe'
LINUX: '{{.BIN_DIR}}/{{.PROGRAM}}_linux_amd64'
MACOS: '{{.BIN_DIR}}/{{.PROGRAM}}_darwin_amd64'
VERSION:
sh: 'git describe --tags $(git rev-list --tags --max-count=1)'
tasks:
default:
desc: Build the q3rcon-proxy project
cmds:
- task: build
build:
desc: Build the q3rcon-proxy project
deps: [vet]
cmds:
- task: build-windows
- task: build-linux
- task: build-macos
vet:
desc: Vet the code
deps: [fmt]
cmds:
- go vet ./...
fmt:
desc: Fmt the code
cmds:
- go fmt ./...
build-windows:
desc: Build the q3rcon-proxy project for Windows
cmds:
- GOOS=windows GOARCH=amd64 go build -o {{.WINDOWS}} -ldflags="-X main.version={{.VERSION}}" ./cmd/{{.PROGRAM}}/
internal: true
build-linux:
desc: Build the q3rcon-proxy project for Linux
cmds:
- GOOS=linux GOARCH=amd64 go build -o {{.LINUX}} -ldflags="-X main.version={{.VERSION}}" ./cmd/{{.PROGRAM}}/
internal: true
build-macos:
desc: Build the q3rcon-proxy project for macOS
cmds:
- GOOS=darwin GOARCH=amd64 go build -o {{.MACOS}} -ldflags="-X main.version={{.VERSION}}" ./cmd/{{.PROGRAM}}/
internal: true
test:
desc: Run tests
cmds:
- go test ./...
clean:
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,39 +1,160 @@
// Package main implements a command-line application for a Quake 3 RCON proxy server.
package main package main
import ( import (
"context"
"fmt" "fmt"
"log"
"os" "os"
"runtime/debug"
"strconv"
"strings" "strings"
"time"
"github.com/onyx-and-iris/q3rcon-proxy/pkg/udpproxy" "github.com/charmbracelet/log"
"github.com/urfave/cli/v3"
udpproxy "github.com/onyx-and-iris/q3rcon-proxy"
) )
func start(proxy string) { var version string // Version holds the application version, set at build time using ldflags.
port, target := func() (string, string) {
x := strings.Split(proxy, ":")
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)) // versionFromBuild retrieves the version information from the build metadata.
if err != nil { func versionFromBuild() string {
log.Fatal(err) if version != "" {
return version
} }
log.Printf("q3rcon-proxy initialized: [proxy] (0.0.0.0:%s) [target] (127.0.0.1:%s)", port, target) info, ok := debug.ReadBuildInfo()
if !ok {
return "(unable to read version)"
}
return strings.Split(info.Main.Version, "-")[0]
}
log.Fatal(c.ListenAndServe()) // proxyConfig holds the configuration for a single UDP proxy server.
type proxyConfig struct {
proxyHost string
targetHost string
portsMapping []string
sessionTimeout int
} }
func main() { func main() {
proxies := os.Getenv("Q3RCON_PROXY") cli.VersionPrinter = func(cmd *cli.Command) {
if proxies == "" { fmt.Printf("q3rcon-proxy version: %s\n", cmd.Root().Version)
log.Fatal("env Q3RCON_PROXY required")
} }
for _, proxy := range strings.Split(proxies, ";") { cmd := &cli.Command{
go start(proxy) 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.StringFlag{
Name: "ports-mapping",
Usage: "Proxy and target ports (proxy:target)",
Sources: cli.EnvVars("Q3RCON_PORTS_MAPPING"),
Required: true,
Action: func(_ context.Context, _ *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)
}
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"))
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 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"),
}
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
},
} }
<-make(chan int) 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()
} }

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_PORTS_MAPPING=20000:28960;20001:28961;20002:28962"
Environment="Q3RCON_HOST=0.0.0.0"
Environment="Q3RCON_LOGLEVEL=info"
ExecStart=/usr/local/bin/q3rcon-proxy
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target

21
docker/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM golang:1.24 AS build_image
WORKDIR /usr/src/app
# pre-copy/cache go.mod for pre-downloading dependencies and only redownloading them in subsequent builds if they change
COPY go.mod go.sum ./
RUN go mod download && go mod verify
COPY . .
# build binary, place into ./bin/
RUN CGO_ENABLED=0 GOOS=linux go build -o ./bin/q3rcon-proxy ./cmd/q3rcon-proxy/
FROM scratch AS final_image
WORKDIR /bin/
COPY --from=build_image /usr/src/app/bin/q3rcon-proxy .
# Command to run when starting the container
ENTRYPOINT [ "./q3rcon-proxy" ]

View File

@@ -0,0 +1,26 @@
version: '3'
vars:
IMAGE: q3rcon-proxy
tasks:
build:
desc: Build the Docker image
cmds:
- docker build -t {{.IMAGE}} -f docker/Dockerfile .
dir: .
login:
desc: Login to Github Container Registry
cmds:
- docker login ghcr.io -u {{.GHCR_USER}} --password-stdin <<< {{.GHCR_TOKEN}}
internal: true
push:
desc: Push the Docker image to Github Container Registry
deps:
- task: build
- task: login
cmds:
- docker tag {{.IMAGE}} ghcr.io/{{.GHCR_USER}}/{{.IMAGE}}:latest
- docker push ghcr.io/{{.GHCR_USER}}/{{.IMAGE}}:latest

27
go.mod
View File

@@ -1,3 +1,28 @@
module github.com/onyx-and-iris/q3rcon-proxy module github.com/onyx-and-iris/q3rcon-proxy
go 1.18 go 1.24.0
toolchain go1.24.1
require (
github.com/charmbracelet/log v0.4.2
github.com/urfave/cli/v3 v3.6.2
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/sys v0.41.0 // indirect
)

44
go.sum
View File

@@ -0,0 +1,44 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
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/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
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/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
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/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
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/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/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/urfave/cli/v3 v3.6.2 h1:lQuqiPrZ1cIz8hz+HcrG0TNZFxU70dPZ3Yl+pSrH9A8=
github.com/urfave/cli/v3 v3.6.2/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
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=
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/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

24
option.go Normal file
View File

@@ -0,0 +1,24 @@
package udpproxy
import (
"time"
"github.com/charmbracelet/log"
)
// 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

@@ -1,67 +0,0 @@
package udpproxy
import (
"log"
"net"
"time"
)
type Session struct {
serverConn *net.UDPConn
proxyConn *net.UDPConn
caddr *net.UDPAddr
updateTime time.Time
}
func createSession(caddr *net.UDPAddr, raddr *net.UDPAddr, proxyConn *net.UDPConn) (*Session, error) {
serverConn, err := net.DialUDP("udp", nil, raddr)
if err != nil {
return nil, err
}
session := &Session{
serverConn: serverConn,
proxyConn: proxyConn,
caddr: caddr,
updateTime: time.Now(),
}
go session.listen()
return session, nil
}
func (s *Session) listen() error {
for {
buf := make([]byte, 2048)
n, err := s.serverConn.Read(buf)
if err != nil {
log.Println(err)
continue
}
go s.proxyFrom(buf[:n])
}
}
func (s *Session) proxyFrom(buf []byte) error {
s.updateTime = time.Now()
_, err := s.proxyConn.WriteToUDP(buf, s.caddr)
if err != nil {
log.Println(err)
return err
}
return nil
}
func (s *Session) proxyTo(buf []byte) error {
s.updateTime = time.Now()
_, err := s.serverConn.Write(buf)
if err != nil {
log.Println(err)
return err
}
return nil
}

View File

@@ -1,92 +0,0 @@
package udpproxy
import (
"log"
"net"
"sync"
"time"
)
type Client struct {
laddr *net.UDPAddr
raddr *net.UDPAddr
proxyConn *net.UDPConn
mutex sync.RWMutex
sessions map[string]*Session
}
func New(port, target string) (*Client, error) {
laddr, err := net.ResolveUDPAddr("udp", port)
if err != nil {
return nil, err
}
raddr, err := net.ResolveUDPAddr("udp", target)
if err != nil {
return nil, err
}
return &Client{
laddr: laddr,
raddr: raddr,
mutex: sync.RWMutex{},
sessions: map[string]*Session{},
}, 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 {
var err error
c.proxyConn, err = net.ListenUDP("udp", c.laddr)
if err != nil {
return err
}
go c.pruneSessions()
for {
buf := make([]byte, 2048)
n, caddr, err := c.proxyConn.ReadFromUDP(buf)
if err != nil {
log.Println(err)
}
if !c.isValidPacket(buf[:16]) {
continue
}
session, found := c.sessions[caddr.String()]
if !found {
session, err = createSession(caddr, c.raddr, c.proxyConn)
if err != nil {
log.Println(err)
continue
}
c.sessions[caddr.String()] = session
}
go session.proxyTo(buf[:n])
}
}
func (c *Client) pruneSessions() {
ticker := time.NewTicker(1 * time.Minute)
// the locks here could be abusive and i dont even know if this is a real
// problem but we definitely need to clean up stale sessions
for range ticker.C {
for _, session := range c.sessions {
c.mutex.RLock()
if time.Since(session.updateTime) > time.Minute*5 {
delete(c.sessions, session.caddr.String())
}
c.mutex.RUnlock()
}
}
}

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)
}
}
}

114
session.go Normal file
View File

@@ -0,0 +1,114 @@
package udpproxy
import (
"errors"
"fmt"
"net"
"strings"
"time"
"github.com/charmbracelet/log"
)
type session struct {
serverConn *net.UDPConn
proxyConn *net.UDPConn
caddr *net.UDPAddr
updateTime time.Time
validator
}
func newSession(caddr, raddr *net.UDPAddr, proxyConn *net.UDPConn) (*session, error) {
serverConn, err := net.DialUDP("udp", nil, raddr)
if err != nil {
return nil, err
}
session := &session{
serverConn: serverConn,
proxyConn: proxyConn,
caddr: caddr,
updateTime: time.Now(),
validator: newValidator(),
}
go session.listen()
return session, nil
}
func (s *session) listen() error {
buf := make([]byte, 2048)
for {
n, err := s.serverConn.Read(buf)
if err != nil {
log.Error(err)
continue
}
go s.proxyFrom(buf[:n])
}
}
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()
_, err := s.proxyConn.WriteToUDP(buf, s.caddr)
if err != nil {
log.Error(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[len(s.rconResponseHeader):]))
}
}
return nil
}
func (s *session) proxyTo(buf []byte) error {
if !s.isValidRequestPacket(buf) {
var err error
if s.isChallengeRequestPacket(buf) {
parts := strings.SplitN(string(buf), " ", 3)
err = fmt.Errorf(
"invalid challenge from %s with GUID: %s",
s.caddr.IP,
parts[len(parts)-1],
)
} else {
err = errors.New("not a rcon or query request packet")
}
log.Error(err.Error())
return err
}
s.updateTime = time.Now()
_, err := s.serverConn.Write(buf)
if err != nil {
log.Error(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
}

41
sessioncache.go Normal file
View File

@@ -0,0 +1,41 @@
package udpproxy
import "sync"
// sessionCache tracks connection sessions.
type sessionCache struct {
mu sync.RWMutex
data map[string]*session
}
// newSessionCache creates a usable sessionCache.
func newSessionCache() sessionCache {
return sessionCache{
data: make(map[string]*session),
}
}
// read returns the associated session for an addr.
func (sc *sessionCache) read(addr string) (*session, bool) {
sc.mu.RLock()
defer sc.mu.RUnlock()
v, ok := sc.data[addr]
return v, ok
}
// insert adds a session for a given addr.
func (sc *sessionCache) insert(addr string, session *session) {
sc.mu.Lock()
defer sc.mu.Unlock()
sc.data[addr] = session
}
// delete removes the session for the given addr.
func (sc *sessionCache) delete(addr string) {
sc.mu.Lock()
defer sc.mu.Unlock()
delete(sc.data, addr)
}

94
udpproxy.go Normal file
View File

@@ -0,0 +1,94 @@
// Package udpproxy implements a simple UDP proxy server that forwards rcon and query packets between clients and a target server.
package udpproxy
import (
"fmt"
"net"
"time"
"github.com/charmbracelet/log"
)
// Client represents a UDP proxy server that forwards rcon and query packets between clients and a target server.
// It maintains a session cache to manage client sessions and handles packet forwarding between clients and the target server.
type Client struct {
laddr *net.UDPAddr
raddr *net.UDPAddr
proxyConn *net.UDPConn
sessionCache sessionCache
sessionTimeout time.Duration
}
// New creates a new Client with the specified proxy and target addresses, and applies any provided options.
func New(proxy, target string, options ...Option) (*Client, error) {
laddr, err := net.ResolveUDPAddr("udp", proxy)
if err != nil {
return nil, fmt.Errorf("invalid proxy address: %w", err)
}
raddr, err := net.ResolveUDPAddr("udp", target)
if err != nil {
return nil, fmt.Errorf("invalid target address: %w", err)
}
c := &Client{
laddr: laddr,
raddr: raddr,
sessionCache: newSessionCache(),
sessionTimeout: 20 * time.Minute,
}
for _, o := range options {
o(c)
}
return c, nil
}
// ListenAndServe starts the UDP proxy server and listens for incoming packets from clients.
// It reads packets from the proxy connection, checks for existing sessions, and forwards packets to the target server.
func (c *Client) ListenAndServe() error {
var err error
c.proxyConn, err = net.ListenUDP("udp", c.laddr)
if err != nil {
return fmt.Errorf("failed to listen on proxy address: %w", err)
}
go c.pruneSessions()
buf := make([]byte, 2048)
for {
n, caddr, err := c.proxyConn.ReadFromUDP(buf)
if err != nil {
log.Error(err)
}
session, ok := c.sessionCache.read(caddr.String())
if !ok {
session, err = newSession(caddr, c.raddr, c.proxyConn)
if err != nil {
log.Error(err)
continue
}
c.sessionCache.insert(caddr.String(), session)
}
go session.proxyTo(buf[:n])
}
}
func (c *Client) pruneSessions() {
ticker := time.NewTicker(1 * time.Minute)
for range ticker.C {
for _, session := range c.sessionCache.data {
if time.Since(session.updateTime) > c.sessionTimeout {
c.sessionCache.delete(session.caddr.String())
log.Debugf("session for %s deleted", session.caddr)
}
}
}
}

65
validator.go Normal file
View File

@@ -0,0 +1,65 @@
package udpproxy
import "bytes"
type validator struct {
rconRequestHeader []byte
getstatusRequestHeader []byte
getinfoRequestHeader []byte
getchallengeRequestHeader []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"),
getchallengeRequestHeader: []byte("\xff\xff\xff\xffgetchallenge"),
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) compare(buf, c []byte) bool {
return bytes.Equal(buf[:len(c)], c)
}
func (v validator) isRconRequestPacket(buf []byte) bool {
return v.compare(buf, v.rconRequestHeader)
}
func (v validator) isQueryRequestPacket(buf []byte) bool {
return v.compare(buf, v.getstatusRequestHeader) ||
v.compare(buf, v.getinfoRequestHeader)
}
func (v validator) isValidRequestPacket(buf []byte) bool {
return v.isRconRequestPacket(buf) || v.isQueryRequestPacket(buf)
}
func (v validator) isChallengeRequestPacket(buf []byte) bool {
return v.compare(buf, v.getchallengeRequestHeader)
}
func (v validator) isRconResponsePacket(buf []byte) bool {
return v.compare(buf, v.rconResponseHeader)
}
func (v validator) isQueryResponsePacket(buf []byte) bool {
return v.compare(buf, v.getstatusResponseHeader) ||
v.compare(buf, v.getinfoResponseHeader)
}
func (v validator) isValidResponsePacket(buf []byte) bool {
return v.isRconResponsePacket(buf) || v.isQueryResponsePacket(buf)
}
func (v validator) isBadRconResponse(buf []byte) bool {
return v.compare(buf[len(v.rconResponseHeader):], v.badRconIdentifier)
}