diff --git a/.vscode/launch.json b/.vscode/launch.json index 0385795..0d1b6dc 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,6 +4,19 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ + { + "name": "Launch main.go", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "cmd/source-udp-forwarder/main.go", + "env":{ + "GOCACHE": "${workspaceFolder}/.go/.cache/go-build", + "GOMODCACHE": "${workspaceFolder}/.go/pkg/mod" + }, + "showLog": true, + "trace": "error" + }, { "name": "Launch file", "type": "go", diff --git a/README.md b/README.md index 409d12e..d8c3b48 100644 --- a/README.md +++ b/README.md @@ -6,17 +6,17 @@ [![codecov](https://codecov.io/gh/startersclan/source-udp-forwarder/branch/master/graph/badge.svg)](https://codecov.io/gh/startersclan/source-udp-forwarder) [![go-report-card](https://goreportcard.com/badge/github.com/startersclan/source-udp-forwarder)](https://goreportcard.com/report/github.com/startersclan/source-udp-forwarder) -A simple UDP forwarder to the HLStatsX:CE daemon. +A simple HTTP and UDP log forwarder to the [HLStatsX:CE daemon](https://github.com/startersclan/hlstatsx-community-edition). ## Agenda -The [HLStatsX:CE perl daemon](https://github.com/startersclan/hlstatsx-community-edition/tree/master/scripts) infers a gameserver's IP:PORT from the client socket from which it receives (reads) the gameserver's `logaddress_add` logs. This means both the daemon and the gameservers have to run on the same network. +The [HLStatsX:CE perl daemon](https://github.com/startersclan/hlstatsx-community-edition/tree/master/scripts) infers a gameserver's IP:PORT from the client socket from which it receives (reads) the gameserver's `logaddress_add` or `logaddress_add_http` logs. This means both the daemon and the gameservers have to run on the same network. -This UDP forwarder eliminates this need by leveraging on an already built-in proxy protocol in the daemon - It simply runs as a sidecar to the gameserver, receives logs from the gameserver, prepends each log line with a spoofed `IP:PORT` as well as a [`proxy_key`](https://github.com/startersclan/hlstatsx-community-edition/blob/1.6.19/scripts/hlstats.pl#L1780) secret only known by the daemon, and finally sends that log line to the daemon. The daemon reads the gameserver's `IP:PORT` from each log line, rather than the usual inferring it from the client socket. +This log forwarder eliminates this need by leveraging on an already built-in proxy protocol in the daemon - It simply runs as a sidecar to the gameserver, receives logs from the gameserver, prepends each log line with a spoofed `IP:PORT` as well as a [`proxy_key`](https://github.com/startersclan/hlstatsx-community-edition/blob/1.6.19/scripts/hlstats.pl#L1780) secret only known by the daemon, and finally sends that log line to the daemon. The daemon reads the gameserver's `IP:PORT` from each log line, rather than the usual inferring it from the client socket. `source-udp-forwarder` uses less than `3MB` of memory. -## Install +## Usage ### Binaries @@ -32,15 +32,15 @@ To run the latest stable version: docker run -it startersclan/source-udp-forwarder:latest ``` -To run a specific version, for example `v0.1.0`: +To run a specific version, for example `v0.3.0`: ```sh -docker run -it startersclan/source-udp-forwarder:v0.1.0 +docker run -it startersclan/source-udp-forwarder:v0.3.0 ``` ## Demo -1. Start the gameserver with cvar `logaddress_add 0.0.0.0:26999` for `srcds` (`srcds` refuses to log to `logaddress_add 127.0.0.1:` for some reason) or `logaddress_add 127.0.0.1 26999` for `hlds` servers, to ensure the gameserver send logs to `source-udp-forwarder`. +1. Start the gameserver with cvar `logaddress_add_http "http://0.0.0.0:26999"` for Counter-Strike 2, `logaddress_add 0.0.0.0:26999` for `srcds` (`srcds` refuses to log to `logaddress_add 127.0.0.1:` for some reason), or `logaddress_add 127.0.0.1 26999` for `hlds` servers, and cvar `log on`, to ensure the gameserver send logs to `source-udp-forwarder`. 2. Start `source-udp-forwarder` as a sidecar to the gameserver (both on localhost), setting the follow environment variables: @@ -52,14 +52,15 @@ docker run -it startersclan/source-udp-forwarder:v0.1.0 3. Watch the daemon logs to ensure it's receiving logs from `source-udp-forwarder`. There should be a `PROXY` event tag attached to each log line received from `source-udp-forwarder`. -Here are some `docker-compose` examples demonstrating a gameserver UDP logs being proxied via `source-udp-forwarder` to the HLStatsX:CE perl daemon: +See `docker-compose` examples: -- [Counter-Strike 1.6](docs/hlds-cstrike-example/docker-compose.yml) - This will work for all [GoldSource](https://developer.valvesoftware.com/wiki/GoldSrc) games, such as Half-Life and Condition Zero -- [Half-Life 2 Multiplayer](docs/srcds-hl2mp-example/docker-compose.yml). This will work for all [Source](https://developer.valvesoftware.com/wiki/Source) games, such as Counter-Strike Global Offensive and Left 4 Dead 2. +- [Counter-Strike 2](docs/srcds-cs2-example/docker-compose.yml) - Works for Counter-Strike 2 and all games that sends logs using HTTP +- [Counter-Strike 1.6](docs/hlds-cstrike-example/docker-compose.yml) - This will work for all [GoldSource](https://developer.valvesoftware.com/wiki/GoldSrc) games which sends logs using UDP, such as Half-Life and Condition Zero +- [Half-Life 2 Multiplayer](docs/srcds-hl2mp-example/docker-compose.yml). This will work for all [Source](https://developer.valvesoftware.com/wiki/Source) games which sends logs using UDP, such as Counter-Strike Global Offensive and Left 4 Dead 2. -## Usage +## Configuration -Configuration is done via (from highest priorty to lowest priority): +Configuration is done via (from highest to lowest priority): 1. Command line 2. Environment variables @@ -74,11 +75,11 @@ Run `source-udp-forwarder -help` to see command line usage: | Environment variable | Description | |---|---| -| `UDP_LISTEN_ADDR` | `:` to listen on for incoming packets. Default value: `:26999` | -| `UDP_FORWARD_ADDR` | `:` or `:` to which incoming packets will be forwarded. Default value: `127.0.0.1:27500` | -| `FORWARD_PROXY_KEY` | The [`proxy_key`](https://github.com/startersclan/hlstatsx-community-edition/blob/1.6.19/scripts/hlstats.pl#L1780) secret defined in the HLStatsX:CE Web Admin Panel. Default value: `XXXXX` | -| `FORWARD_GAMESERVER_IP` | IP that the sent packet should include. Default value: `127.0.0.1` | -| `FORWARD_GAMESERVER_PORT` | Port that the sent packet should include. Default value: `27015` | +| `LISTEN_ADDR` | `:` to listen for incoming HTTP and UDP logs. Default value: `:26999` | +| `UDP_FORWARD_ADDR` | `:` or `:` to which incoming packets will be forwarded. Default value: `127.0.0.1:27500` | +| `FORWARD_PROXY_KEY` | The [`proxy_key`](https://github.com/startersclan/hlstatsx-community-edition/blob/1.6.19/scripts/hlstats.pl#L1780) secret defined in the HLStatsX:CE Web Admin Panel. Default value: `XXXXX` | +| `FORWARD_GAMESERVER_IP` | IP that the sent packet should include. Default value: `127.0.0.1` | +| `FORWARD_GAMESERVER_PORT` | Port that the sent packet should include. Default value: `27015` | | `LOG_LEVEL` | Log level. Defaults to `INFO`. May be one of the following (starting with the most verbose): `TRACE`, `DEBUG`, `INFO`, `WARN`, `ERROR`, `FATAL`. Default value: `INFO`| | `LOG_FORMAT` | Log format, valid options are `txt` and `json`. Default value: `txt` | diff --git a/cmd/source-udp-forwarder/main.go b/cmd/source-udp-forwarder/main.go index 4740443..ddf4c21 100644 --- a/cmd/source-udp-forwarder/main.go +++ b/cmd/source-udp-forwarder/main.go @@ -4,9 +4,12 @@ import ( "flag" "fmt" "os" + "os/signal" "regexp" "strconv" "strings" + "syscall" + "time" log "github.com/sirupsen/logrus" @@ -28,7 +31,7 @@ func getEnvBool(key string) (envValBool bool) { return } -func run() error { +func main() { customFormatter := new(log.TextFormatter) customFormatter.TimestampFormat = "2006-01-02 15:04:05" customFormatter.FullTimestamp = true @@ -36,8 +39,9 @@ func run() error { // log.Info("Hello Walrus before FullTimestamp=true") var ( - listenAddress = flag.String("udp.listen-address", getEnv("UDP_LISTEN_ADDR", ":26999"), ": to listen on for incoming packets.") - forwardAddress = flag.String("udp.forward-address", getEnv("UDP_FORWARD_ADDR", "127.0.0.1:27500"), ": of the daemon to which incoming packets will be forwarded.") + listenAddress = flag.String("listen-address", getEnv("LISTEN_ADDR", ":26999"), ": to listen for incoming HTTP and UDP logs.") + udpListenAddress = flag.String("udp.listen-address", getEnv("UDP_LISTEN_ADDR", ":26999"), ": to listen for incoming HTTP and UDP logs. (deprecated, use -listen-address instead)") + forwardAddress = flag.String("udp.forward-address", getEnv("UDP_FORWARD_ADDR", "127.0.0.1:27500"), ": of the daemon to which incoming packets will be forwarded.") proxyKey = flag.String("forward.proxy-key", getEnv("FORWARD_PROXY_KEY", "XXXXX"), "The PROXY_KEY secret defined in HLStatsX:CE settings.") srcIp = flag.String("forward.gameserver-ip", getEnv("FORWARD_GAMESERVER_IP", "127.0.0.1"), "IP that the sent packet should include.") @@ -50,6 +54,11 @@ func run() error { ) flag.Parse() + // Support the deprecated flag + if *udpListenAddress != ":26999" { + listenAddress = udpListenAddress + } + switch *logFormat { case "json": log.SetFormatter(&log.JSONFormatter{}) @@ -59,7 +68,7 @@ func run() error { log.Printf(version.GetVersion()) if *showVersion { - return nil + os.Exit(0) } *logLevel = strings.ToUpper(*logLevel) @@ -89,18 +98,15 @@ func run() error { log.Infof("Forward Gameserver IP: %s", *srcIp) log.Infof("Forward Gameserver Port: %s", *srcPort) + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGKILL, syscall.SIGHUP) forwarder, err := udpforwarder.Forward(*listenAddress, *forwardAddress, udpforwarder.DefaultTimeout, fmt.Sprintf("PROXY Key=%s %s:%sPROXY ", *proxyKey, *srcIp, *srcPort)) if forwarder == nil || err != nil { - return err + log.Fatal(err) } - // Block forever - select {} -} - -func main() { - if err := run(); err != nil { - fmt.Fprintf(os.Stderr, "error: %v\n", err) - os.Exit(1) - } + <-sig + log.Infof("Received shutdown signal. Exiting") + forwarder.Close() + time.Sleep(10000) } diff --git a/docker-compose.yml b/docker-compose.yml index ec86600..fd54757 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,7 @@ services: - LOG_LEVEL=DEBUG - LOG_FORMAT=txt ports: + - 26999:26999/tcp - 26999:26999/udp volumes: - ${OUTBIN?:err}:/${BIN?:err} diff --git a/docs/hlds-cstrike-example/docker-compose.yml b/docs/hlds-cstrike-example/docker-compose.yml index d63463a..594b521 100644 --- a/docs/hlds-cstrike-example/docker-compose.yml +++ b/docs/hlds-cstrike-example/docker-compose.yml @@ -1,4 +1,4 @@ -# In this example, you will see that the Counterstrike 1.6 gameserver sends its UDP logs to `source-udp-forwarder` which then proxies (forwards) logs to the HLStatsX:CE perl daemon. +# In this example, you will see that Counterstrike 1.6 server sends its UDP logs to `source-udp-forwarder` which then proxies (forwards) logs to the HLStatsX:CE perl daemon # This will work for all GoldSource games (https://developer.valvesoftware.com/wiki/GoldSrc), such as Half-Life and Condition Zero version: '2.2' services: @@ -6,64 +6,80 @@ services: # See: https://github.com/startersclan/docker-sourceservers cstrike: image: goldsourceservers/cstrike:latest - network_mode: host + volumes: + - dns-volume:/dns:ro + ports: + - 27015:27015/udp + networks: + - default stdin_open: true tty: true stop_signal: SIGKILL + depends_on: + - source-udp-forwarder entrypoint: - /bin/bash command: - -c - | set -eu - exec hlds_linux -console -noipx -secure -game cstrike +map de_dust2 +maxplayers 32 +sv_lan 0 +ip 0.0.0.0 +port 27015 +log on +logaddress_add 127.0.0.1 26999 + exec hlds_linux -console -noipx -secure -game cstrike +map de_dust2 +maxplayers 32 +sv_lan 0 +ip 0.0.0.0 +port 27015 +rcon_password password +log on +logaddress_add "$$( cat /dns/source-udp-forwarder )" 26999 - # 2. The proxy forwards gameserver logs to the daemon - # source-udp-forwarder: https://github.com/startersclan/source-udp-forwarder + # 2. source-udp-forwarder proxy forwards gameserver logs to the daemon + # See: https://github.com/startersclan/source-udp-forwarder source-udp-forwarder: image: startersclan/source-udp-forwarder:latest environment: - - UDP_LISTEN_ADDR=:26999 - - UDP_FORWARD_ADDR=127.0.0.1:27500 + - LISTEN_ADDR=:26999 + - UDP_FORWARD_ADDR=daemon:27500 - FORWARD_PROXY_KEY=somedaemonsecret # The daemon's proxy_key secret - FORWARD_GAMESERVER_IP=192.168.1.100 # The gameserver's IP as registered in the HLStatsX:CE database - FORWARD_GAMESERVER_PORT=27015 # The gameserver's IP as registered in the HLStatsX:CE database - LOG_LEVEL=DEBUG - LOG_FORMAT=txt - network_mode: host + volumes: + - dns-volume:/dns + networks: + - default + depends_on: + - daemon + entrypoint: + - /bin/sh + command: + - -c + - | + set -eu + + echo "Outputting my IP address" + ip addr show eth0 | grep 'inet ' | awk '{print $$2}' | cut -d '/' -f1 | tee /dns/source-udp-forwarder + + exec /source-udp-forwarder # 3. HLStatsX:CE perl daemon accepts the gameserver logs. Gameserver Logs are parsed and stats are recorded # The daemon's proxy_key secret can only be setup in the HLStatsX:CE Web Admin Panel and not via env vars - # HLStatsX:CE perl daemon: https://github.com/startersclan/docker-hlstatsxce-daemon - # NOTE: Currently, as of v1.6.19, the daemon crashes upon startup. You will need to fix perl errors and rebuild the image. + # See: https://github.com/startersclan/docker-hlstatsxce-daemon daemon: - image: startersclan/docker-hlstatsxce-daemon:v1.6.19-geoip-alpine-3.8 - environment: - - LOG_LEVEL=1 - - DB_HOST=127.0.0.1:3306 - - DB_NAME=hlstatsxce - - DB_USER=hlstatsxce - - DB_PASSWORD=hlstatsxce - - DNS_RESOLVE_IP=false - - LISTEN_IP=127.0.0.1 - - LISTEN_PORT=27500 - - RCON=true - network_mode: host - - # 4. The DB for HLStatsX:CE - db: - image: mysql:5.7 - environment: - - MYSQL_ROOT_PASSWORD=someverystrongpassword - - MYSQL_USER=hlstatsxce - - MYSQL_PASSWORD=hlstatsxce - - MYSQL_DATABASE=hlstatsxce - volumes: - - db-volume:/var/lib/mysql - network_mode: host + image: startersclan/hlstatsx-community-edition:1.9.0-daemon + ports: + - 27500:27500/udp # For external servers to send logs to the daemon + networks: + - default + command: + - --ip=0.0.0.0 + - --port=27500 + - --db-host=db:3306 + - --db-name=hlstatsxce + - --db-username=hlstatsxce + - --db-password=hlstatsxce + - --nodns-resolveip + - --debug + # - --debug + # - --help - # 5. HLStatsX:CE web UI: https://github.com/NomisCZ/hlstatsx-community-edition/ - # Currently, as of the time of writing, there is no docker image. The HLStatsX:CE web frontend must be setup by you. +networks: + default: volumes: + dns-volume: db-volume: + diff --git a/docs/srcds-cs2-example/docker-compose.yml b/docs/srcds-cs2-example/docker-compose.yml new file mode 100644 index 0000000..e30537a --- /dev/null +++ b/docs/srcds-cs2-example/docker-compose.yml @@ -0,0 +1,82 @@ +# In this example, you will see that Counter-Strike 2 server sends its HTTP logs to `source-udp-forwarder` which then proxies (forwards) logs to the HLStatsX:CE perl daemon. +version: '2.2' +services: + # 1. Counter-Strike 2 gameserver sends UDP logs to source-udp-forwarder + # See: https://github.com/startersclan/docker-sourceservers + cs2: + image: sourceservers/cs2:latest + volumes: + - dns-volume:/dns:ro + ports: + - 27015:27015/tcp + - 27015:27015/udp + networks: + - default + stdin_open: true + tty: true + stop_signal: SIGKILL + entrypoint: + - /bin/bash + command: + - -c + - | + set -eu + exec game/bin/linuxsteamrt64/cs2 -dedicated -game cs2 -port 27015 +game_type 0 +game_mode 1 +mapgroup mg_active +map de_dust2 +log on +logaddress_add_http "http://$$( cat /dns/source-udp-forwarder ):26999" + + # 2. source-udp-forwarder proxy forwards gameserver logs to the daemon + # See: https://github.com/startersclan/source-udp-forwarder + source-udp-forwarder: + image: startersclan/source-udp-forwarder:latest + environment: + - LISTEN_ADDR=:26999 + - UDP_FORWARD_ADDR=daemon:27500 + - FORWARD_PROXY_KEY=somedaemonsecret # The daemon's proxy_key secret + - FORWARD_GAMESERVER_IP=192.168.1.100 # The gameserver's IP as registered in the HLStatsX:CE database + - FORWARD_GAMESERVER_PORT=27015 # The gameserver's IP as registered in the HLStatsX:CE database + - LOG_LEVEL=DEBUG + - LOG_FORMAT=txt + volumes: + - dns-volume:/dns + networks: + - default + depends_on: + - daemon + entrypoint: + - /bin/sh + command: + - -c + - | + set -eu + + echo "Outputting my IP address" + ip addr show eth0 | grep 'inet ' | awk '{print $$2}' | cut -d '/' -f1 | tee /dns/source-udp-forwarder + + exec /source-udp-forwarder + + # 3. HLStatsX:CE perl daemon accepts the gameserver logs. Gameserver Logs are parsed and stats are recorded + # The daemon's proxy_key secret can only be setup in the HLStatsX:CE Web Admin Panel and not via env vars + # See: https://github.com/startersclan/docker-hlstatsxce-daemon + daemon: + image: startersclan/hlstatsx-community-edition:1.9.0-daemon + ports: + - 27500:27500/udp # For external servers to send logs to the daemon + networks: + - default + command: + - --ip=0.0.0.0 + - --port=27500 + - --db-host=db:3306 + - --db-name=hlstatsxce + - --db-username=hlstatsxce + - --db-password=hlstatsxce + - --nodns-resolveip + - --debug + # - --debug + # - --help + +networks: + default: + +volumes: + dns-volume: + db-volume: diff --git a/docs/srcds-hl2mp-example/docker-compose.yml b/docs/srcds-hl2mp-example/docker-compose.yml index f61dd73..4def8e2 100644 --- a/docs/srcds-hl2mp-example/docker-compose.yml +++ b/docs/srcds-hl2mp-example/docker-compose.yml @@ -1,12 +1,18 @@ -# In this example, you will see that the Half-Life 2 Multiplayer sends its UDP logs to `source-udp-forwarder` which then proxies (forwards) logs to the HLStatsX:CE perl daemon. -# This will work for all Source games (https://developer.valvesoftware.com/wiki/Source), such as Counter-Strike Global Offensive and Left 4 Dead 2. +# In this example, you will see that Half-Life 2 Multiplayer server sends its UDP logs to `source-udp-forwarder` which then proxies (forwards) logs to the HLStatsX:CE perl daemon +# This will work for all Source games (https://developer.valvesoftware.com/wiki/Source), such as Counter-Strike Global Offensive and Left 4 Dead 2 version: '2.2' services: # 1. Half-Life 2 Multiplayer gameserver sends UDP logs to source-udp-forwarder # See: https://github.com/startersclan/docker-sourceservers hl2mp: image: sourceservers/hl2mp:latest - network_mode: host + volumes: + - dns-volume:/dns:ro + ports: + - 27015:27015/tcp + - 27015:27015/udp + networks: + - default stdin_open: true tty: true stop_signal: SIGKILL @@ -17,54 +23,62 @@ services: - | set -eu # srcds cannot log to 127.0.0.1 for some reason, but 0.0.0.0 works - exec srcds_linux -game hl2mp -console -usercon -secure -ip 0.0.0.0 -port 27015 -steamport 27016 -tickrate 300 -maxplayers 16 +map dm_lockdown +sv_lan 0 +log on +logaddress_add 0.0.0.0:26999 + exec srcds_linux -game hl2mp -port 27015 +map dm_lockdown +sv_lan 0 +log on +logaddress_add "$$( cat /dns/source-udp-forwarder ):26999" - # 2. The proxy forwards gameserver logs to the daemon - # source-udp-forwarder: https://github.com/startersclan/source-udp-forwarder + # 2. source-udp-forwarder proxy forwards gameserver logs to the daemon + # See: https://github.com/startersclan/source-udp-forwarder source-udp-forwarder: image: startersclan/source-udp-forwarder:latest environment: - - UDP_LISTEN_ADDR=:26999 - - UDP_FORWARD_ADDR=127.0.0.1:27500 + - LISTEN_ADDR=:26999 + - UDP_FORWARD_ADDR=daemon:27500 - FORWARD_PROXY_KEY=somedaemonsecret # The daemon's proxy_key secret - FORWARD_GAMESERVER_IP=192.168.1.100 # The gameserver's IP as registered in the HLStatsX:CE database - FORWARD_GAMESERVER_PORT=27015 # The gameserver's IP as registered in the HLStatsX:CE database - LOG_LEVEL=DEBUG - LOG_FORMAT=txt - network_mode: host + volumes: + - dns-volume:/dns + networks: + - default + depends_on: + - daemon + entrypoint: + - /bin/sh + command: + - -c + - | + set -eu + + echo "Outputting my IP address" + ip addr show eth0 | grep 'inet ' | awk '{print $$2}' | cut -d '/' -f1 | tee /dns/source-udp-forwarder + + exec /source-udp-forwarder # 3. HLStatsX:CE perl daemon accepts the gameserver logs. Gameserver Logs are parsed and stats are recorded # The daemon's proxy_key secret can only be setup in the HLStatsX:CE Web Admin Panel and not via env vars - # HLStatsX:CE perl daemon: https://github.com/startersclan/docker-hlstatsxce-daemon - # NOTE: Currently, as of v1.6.19, the daemon crashes upon startup. You will need to fix perl errors and rebuild the image. + # See: https://github.com/startersclan/docker-hlstatsxce-daemon daemon: - image: startersclan/docker-hlstatsxce-daemon:v1.6.19-geoip-alpine-3.8 - environment: - - LOG_LEVEL=1 - - DB_HOST=127.0.0.1:3306 - - DB_NAME=hlstatsxce - - DB_USER=hlstatsxce - - DB_PASSWORD=hlstatsxce - - DNS_RESOLVE_IP=false - - LISTEN_IP=127.0.0.1 - - LISTEN_PORT=27500 - - RCON=true - network_mode: host - - # 4. The DB for HLStatsX:CE - db: - image: mysql:5.7 - environment: - - MYSQL_ROOT_PASSWORD=someverystrongpassword - - MYSQL_USER=hlstatsxce - - MYSQL_PASSWORD=hlstatsxce - - MYSQL_DATABASE=hlstatsxce - volumes: - - db-volume:/var/lib/mysql - network_mode: host + image: startersclan/hlstatsx-community-edition:1.9.0-daemon + ports: + - 27500:27500/udp # For external servers to send logs to the daemon + networks: + - default + command: + - --ip=0.0.0.0 + - --port=27500 + - --db-host=db:3306 + - --db-name=hlstatsxce + - --db-username=hlstatsxce + - --db-password=hlstatsxce + - --nodns-resolveip + - --debug + # - --debug + # - --help - # 5. HLStatsX:CE web UI: https://github.com/NomisCZ/hlstatsx-community-edition/ - # Currently, as of the time of writing, there is no docker image. The HLStatsX:CE web frontend must be setup by you. +networks: + default: volumes: + dns-volume: db-volume: diff --git a/pkg/forwarder/udpforwarder.go b/pkg/forwarder/udpforwarder.go index 3dc3045..6a06c70 100644 --- a/pkg/forwarder/udpforwarder.go +++ b/pkg/forwarder/udpforwarder.go @@ -3,7 +3,11 @@ package udpforwarder import ( + "context" + "io/ioutil" "net" + "net/http" + "strings" "sync" "time" @@ -18,10 +22,10 @@ type connection struct { } type Forwarder struct { - src *net.UDPAddr - dst *net.UDPAddr - client *net.UDPAddr - listenerConn *net.UDPConn + src *net.UDPAddr // UDP server + dst *net.UDPAddr // UDP destination + client *net.UDPAddr // My UDP client to forward to UDP destination + listenerConnUdp *net.UDPConn connections map[string]connection connectionsMutex *sync.RWMutex @@ -35,6 +39,8 @@ type Forwarder struct { timeout time.Duration closed bool + + httpSrv http.Server // HTTP server } // DefaultTimeout is the default timeout period of inactivity for convenience @@ -68,36 +74,86 @@ func Forward(src, dst string, timeout time.Duration, prependStr string) (*Forwar Zone: forwarder.src.Zone, } - forwarder.listenerConn, err = net.ListenUDP("udp", forwarder.src) + log.Infof("Listening on %s for UDP and HTTP logs", forwarder.src.String()) + + // Listen for UDP + forwarder.listenerConnUdp, err = net.ListenUDP("udp", forwarder.src) if err != nil { return nil, err } - log.Infof("Listening on %s", forwarder.src) - go forwarder.janitor() go forwarder.run() + // Listen for TCP (HTTP) + go func() { + // sm := http.NewServeMux() + // sm.HandleFunc("/", forwarder.httpHandler) + forwarder.httpSrv = http.Server{ + Addr: forwarder.src.String(), + Handler: http.HandlerFunc(forwarder.httpHandler), + } + if err := forwarder.httpSrv.ListenAndServe(); err != http.ErrServerClosed { + log.Fatalf("HTTP server ListenAndServe: %v", err) + } + }() + return forwarder, nil } +func (f *Forwarder) httpHandler(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + switch r.Method { + case "GET": + log.Debugf("Ignoring GET request") + case "POST": + reqBody, err := ioutil.ReadAll(r.Body) + if err != nil { + log.Fatal(err) + } + lines := strings.Split(string(reqBody[:]), "\n") + for _, l := range lines { + buf := []byte(l) + n := len(buf) + + log.Debugf("[HTTP] Received log from %s. buf length: %d, string: %s", r.RemoteAddr, n, string(buf[:n])) + log.Traceln(buf) + + log.Debugf("[HTTP] prependStrBytes len: %d, string: %s", len(f.prependStr), f.prependStr) + log.Traceln(f.prependStrBytes) + + log.Debugf("[HTTP] Prepending prependStrBytes to buf") + newbuf := append([]byte(f.prependStrBytes), buf[:n]...) + log.Debugf("[HTTP] newbuf len: %d, string: %s", len(newbuf), string(newbuf)) + log.Traceln(newbuf) + go f.handle(newbuf, nil, r.RemoteAddr) + } + default: + log.Debugf("Invalid request") + w.WriteHeader(http.StatusNotImplemented) + w.Write([]byte(http.StatusText(http.StatusNotImplemented))) + } +} func (f *Forwarder) run() { for { buf := make([]byte, bufferSize) - n, addr, err := f.listenerConn.ReadFromUDP(buf) + n, addr, err := f.listenerConnUdp.ReadFromUDP(buf) if err != nil { return } - log.Debugf("Received buf length: %d, string: %s", n, string(buf[:n])) + log.Debugf("[UDP] Received log from %s. buf length: %d, string: %s", addr, n, string(buf[:n])) log.Traceln(buf) - log.Debugf("prependStrBytes len: %d, string: %s", len(f.prependStr), f.prependStr) + log.Debugf("[UDP] prependStrBytes len: %d, string: %s", len(f.prependStr), f.prependStr) log.Traceln(f.prependStrBytes) - log.Debugf("Prepending prependStrBytes to buf") + log.Debugf("[UDP] Prepending prependStrBytes to buf") newbuf := append([]byte(f.prependStrBytes), buf[:n]...) - log.Debugf("newbuf len: %d, string: %s", len(newbuf), string(newbuf)) + log.Debugf("[UDP] newbuf len: %d, string: %s", len(newbuf), string(newbuf)) log.Traceln(newbuf) - go f.handle(newbuf, addr) + go f.handle(newbuf, addr, "") } } @@ -134,59 +190,74 @@ func (f *Forwarder) janitor() { } } -func (f *Forwarder) handle(data []byte, addr *net.UDPAddr) { +func (f *Forwarder) handle(data []byte, clientUdpAddr *net.UDPAddr, clientTcpAddr string) { + cAddr := "" + if clientUdpAddr != nil { + cAddr = clientUdpAddr.String() + } else if clientTcpAddr != "" { + cAddr = clientTcpAddr + } else { + log.Println("udp-forwarder: No clientUdpAddr and no clientTcpAddr") + return + } + f.connectionsMutex.RLock() - conn, found := f.connections[addr.String()] + conn, found := f.connections[cAddr] f.connectionsMutex.RUnlock() if !found { - log.Infof("Client connection does not exist. Added connection: %s", addr.String()) + log.Infof("Client connection does not exist. Added connection: %s", cAddr) conn, err := net.ListenUDP("udp", f.client) if err != nil { log.Println("udp-forwarder: failed to dial:", err) return } - log.Debugf("Listening on %s", conn.LocalAddr().String()) f.connectionsMutex.Lock() - f.connections[addr.String()] = connection{ + f.connections[cAddr] = connection{ udp: conn, lastActive: time.Now(), } f.connectionsMutex.Unlock() - f.connectCallback(addr.String()) + f.connectCallback(cAddr) - log.Debugf("Forwarding data from %s to %s", conn.LocalAddr().String(), f.dst.String()) + log.Debugf("Forwarding log from %s to %s", conn.LocalAddr().String(), f.dst.String()) conn.WriteTo(data, f.dst) - for { - buf := make([]byte, bufferSize) - n, _, err := conn.ReadFromUDP(buf) - if err != nil { - log.Debugf("Closing %s", conn.LocalAddr().String()) - f.connectionsMutex.Lock() - conn.Close() - delete(f.connections, addr.String()) - f.connectionsMutex.Unlock() - return + if clientUdpAddr != nil { + for { + buf := make([]byte, bufferSize) + _, _, err := conn.ReadFromUDP(buf) + if err != nil { + log.Debugf("Closing %s", conn.LocalAddr().String()) + f.connectionsMutex.Lock() + conn.Close() + delete(f.connections, cAddr) + f.connectionsMutex.Unlock() + return + } + + // Reply to client? + // go func(data []byte, conn *net.UDPConn, cAddr *net.UDPAddr) { + // f.listenerConnUdp.WriteTo(data, cAddr) + // }(buf[:n], conn, clientUdpAddr) } - - go func(data []byte, conn *net.UDPConn, addr *net.UDPAddr) { - f.listenerConn.WriteTo(data, addr) - }(buf[:n], conn, addr) + } + if clientTcpAddr != "" { + return } } else { - log.Debugf("Reusing existing client connection: %s", addr.String()) + log.Debugf("Reusing existing client connection: %s", conn.udp.LocalAddr()) } - log.Debugf("Forwarding data to: %s", f.dst.String()) + log.Debugf("Forwarding log from %s to %s", conn.udp.LocalAddr(), f.dst.String()) conn.udp.WriteTo(data, f.dst) shouldChangeTime := false f.connectionsMutex.RLock() - if _, found := f.connections[addr.String()]; found { - if f.connections[addr.String()].lastActive.Before( + if _, found := f.connections[cAddr]; found { + if f.connections[cAddr].lastActive.Before( time.Now().Add(f.timeout / 4)) { shouldChangeTime = true } @@ -196,10 +267,10 @@ func (f *Forwarder) handle(data []byte, addr *net.UDPAddr) { if shouldChangeTime { f.connectionsMutex.Lock() // Make sure it still exists - if _, found := f.connections[addr.String()]; found { - connWrapper := f.connections[addr.String()] + if _, found := f.connections[cAddr]; found { + connWrapper := f.connections[cAddr] connWrapper.lastActive = time.Now() - f.connections[addr.String()] = connWrapper + f.connections[cAddr] = connWrapper } f.connectionsMutex.Unlock() } @@ -207,13 +278,20 @@ func (f *Forwarder) handle(data []byte, addr *net.UDPAddr) { // Close stops the forwarder. func (f *Forwarder) Close() { + log.Infof("Stopping UDP server") f.connectionsMutex.Lock() f.closed = true for _, conn := range f.connections { conn.udp.Close() } - f.listenerConn.Close() + f.listenerConnUdp.Close() f.connectionsMutex.Unlock() + + // See: https://pkg.go.dev/net/http#Server.Shutdown + log.Infof("Stopping HTTP server") + if err := f.httpSrv.Shutdown(context.Background()); err != nil { + log.Printf("HTTP server Shutdown: %v", err) + } } // OnConnect can be called with a callback function to be called whenever a diff --git a/pkg/forwarder/udpforwarder_test.go b/pkg/forwarder/udpforwarder_test.go index 65d3e3a..070d16f 100644 --- a/pkg/forwarder/udpforwarder_test.go +++ b/pkg/forwarder/udpforwarder_test.go @@ -1,55 +1,53 @@ package udpforwarder import ( + "bytes" "fmt" "net" + "net/http" "testing" "time" + + log "github.com/sirupsen/logrus" ) func TestForward(t *testing.T) { + log.SetLevel(log.InfoLevel) listenAddr := "127.0.0.1:26999" forwardAddr := "127.0.0.1:27500" - forwarder, err := Forward( - listenAddr, - forwardAddr, - DefaultTimeout, - "foo", - ) + prependStr := "foo" + forwarder, err := Forward(listenAddr, forwardAddr, DefaultTimeout, prependStr) if forwarder == nil || err != nil { t.Fatal(err) } defer forwarder.Close() + time.Sleep(time.Millisecond * 100) // Don't stop too fast or else "bind: address already in use" in the next test } func TestHandleConnection(t *testing.T) { + log.SetLevel(log.InfoLevel) listenAddr := "127.0.0.1:26999" forwardAddr := "127.0.0.1:27500" - forwarder, err := Forward( - listenAddr, - forwardAddr, - DefaultTimeout, - "foo", - ) + prependStr := "foo" + forwarder, err := Forward(listenAddr, forwardAddr, DefaultTimeout, prependStr) if forwarder == nil || err != nil { t.Fatal(err) } defer forwarder.Close() - message := "L 10/11/2019 - 23:41:02: Started map \"awp_city\" (CRC \"-2134348459\")" + log := "L 10/11/2019 - 23:41:02: Started map \"awp_city\" (CRC \"-2134348459\")" // Gameserver sends log to forwarder - go func(tb testing.TB) { - conn, err := net.Dial("udp", listenAddr) - if err != nil { - tb.Fatal(err) - } - defer conn.Close() - - if _, err := fmt.Fprintf(conn, message); err != nil { - tb.Fatal(err) - } - }(t) + d := net.Dialer{ + LocalAddr: &net.UDPAddr{ + IP: net.ParseIP("127.0.0.1"), + Port: 27000, + }, + } + conn, err := d.Dial("udp", listenAddr) + defer conn.Close() + fmt.Fprintf(conn, log) + fmt.Fprintf(conn, log) // Allow the time for the connection to be added time.Sleep(time.Millisecond * 100) @@ -62,6 +60,7 @@ func TestHandleConnection(t *testing.T) { func TestJanitor(t *testing.T) { // Setup forwarder + log.SetLevel(log.InfoLevel) listenAddr := "127.0.0.1:26999" forwardAddr := "127.0.0.1:27500" timeout := time.Millisecond * 1 @@ -73,17 +72,14 @@ func TestJanitor(t *testing.T) { defer forwarder.Close() // Gameserver sends log to forwarder - message := "L 10/11/2019 - 23:41:02: Started map \"awp_city\" (CRC \"-2134348459\")" + log := "L 10/11/2019 - 23:41:02: Started map \"awp_city\" (CRC \"-2134348459\")" go func(tb testing.TB) { conn, err := net.Dial("udp", listenAddr) if err != nil { tb.Fatal(err) } defer conn.Close() - - if _, err := fmt.Fprintf(conn, message); err != nil { - tb.Fatal(err) - } + fmt.Fprintf(conn, log) }(t) // Allow the janitor some time to cleanup @@ -95,8 +91,9 @@ func TestJanitor(t *testing.T) { } } -func TestForwardedMessage(t *testing.T) { +func TestForwardedUdp(t *testing.T) { // Setup forwarder + log.SetLevel(log.InfoLevel) listenAddr := "127.0.0.1:26999" forwardAddr := "127.0.0.1:27500" prependStr := "foo" @@ -106,42 +103,100 @@ func TestForwardedMessage(t *testing.T) { } defer forwarder.Close() - // Gameserver sends log to forwarder - message := "L 10/11/2019 - 23:41:02: Started map \"awp_city\" (CRC \"-2134348459\")" + // Gameserver sends 3 log lines to forwarder + log := "L 10/11/2019 - 23:41:02: Started map \"awp_city\" (CRC \"-2134348459\")" + logs := []string{ + log, + log, + log, + } go func(tb testing.TB) { - conn, err := net.Dial("udp", listenAddr) + time.Sleep(time.Millisecond * 300) // Wait for daemon to be up + d := net.Dialer{ + LocalAddr: &net.UDPAddr{ + IP: net.ParseIP("127.0.0.1"), + Port: 27000, + }, + } + conn, err := d.Dial("udp", listenAddr) if err != nil { tb.Fatal(err) } defer conn.Close() - - if _, err := fmt.Fprintf(conn, message); err != nil { - tb.Fatal(err) + for _, l := range logs { + fmt.Fprintf(conn, l) } }(t) - // Daemon receives log from forwarder and test - expectedMessage := prependStr + message + // Daemon receives log from forwarder + expectedLog := prependStr + log addr, err := net.ResolveUDPAddr("udp", forwardAddr) - if err != nil { - t.Fatal(err) + connD, err := net.ListenUDP("udp", addr) + defer connD.Close() + buf := make([]byte, 1024) + c := 0 + for { + n, _, err := connD.ReadFromUDP(buf) + if err != nil { + t.Fatal(err) + } + msg := string(buf[:n]) + if msg != expectedLog { + t.Fatalf("Unexpected log:\nGot:\t\t%s\nExpected:\t%s\n", msg, expectedLog) + } + c++ + if c == len(logs) { + return + } } - conn, err := net.ListenUDP("udp", addr) - if err != nil { +} + +func TestForwardedHttp(t *testing.T) { + // Setup forwarder + log.SetLevel(log.InfoLevel) + listenAddr := "127.0.0.1:26999" + forwardAddr := "127.0.0.1:27500" + prependStr := "foo" + forwarder, err := Forward(listenAddr, forwardAddr, DefaultTimeout, prependStr) + if forwarder == nil || err != nil { t.Fatal(err) } - defer conn.Close() - bufferSize := 1024 - buf := make([]byte, bufferSize) + defer forwarder.Close() + + // Gameserver sends 3 logs (each with 2 lines) to the forwarder, each to forwarder + log := "L 10/11/2019 - 23:41:02: Started map \"awp_city\" (CRC \"-2134348459\")" + logs := []string{ + log + "\n" + log, + log + "\n" + log, + log + "\n" + log, + } + go func(tb testing.TB) { + time.Sleep(time.Millisecond * 300) // Wait for daemon to be up + url := "http://" + listenAddr + for _, l := range logs { + http.Post(url, "application/json", bytes.NewBuffer([]byte(l))) + } + }(t) + + // Daemon receives log from forwarder + expectedLog := prependStr + log + addr, err := net.ResolveUDPAddr("udp", forwardAddr) + connD, err := net.ListenUDP("udp", addr) + defer connD.Close() + buf := make([]byte, 1024) + c := 0 for { - n, _, err := conn.ReadFromUDP(buf) + n, _, err := connD.ReadFromUDP(buf) if err != nil { t.Fatal(err) } msg := string(buf[:n]) - if msg != expectedMessage { - t.Fatalf("Unexpected message:\nGot:\t\t%s\nExpected:\t%s\n", msg, expectedMessage) + if msg != expectedLog { + t.Fatalf("Unexpected log:\nGot:\t\t%s\nExpected:\t%s\n", msg, expectedLog) + } + c++ + if c == len(logs)*2 { + return } - return } }