feat: initial etrelay UDP relay with master heartbeat

This commit is contained in:
rmanach
2026-09-26 15:51:43 +02:00
commit 7f715979e1
20 changed files with 904 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
target
dist
# local symlink to the deployment config (pve repo)
.deploy
Generated
+258
View File
@@ -0,0 +1,258 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "clap"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "etrelay"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"tokio",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "mio"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
dependencies = [
"libc",
"wasi",
"windows-sys",
]
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "socket2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tokio"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"libc",
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys",
]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "etrelay"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.100"
clap = { version = "4.5.51", features = ["derive"] }
tokio = { version = "1", features = ["macros", "net", "rt", "sync", "time"] }
[profile.release]
opt-level = 3
lto = "fat"
codegen-units = 1
panic = "abort"
strip = true
+96
View File
@@ -0,0 +1,96 @@
# How etrelay works
> Diagrams: PlantUML sources in [`diagrams/`](diagrams), PNGs regenerated with `make diagrams`.
## The problem
A server shows up in the in-game browser only if a **master server** lists it. Registration is a 3-step handshake over UDP:
1. The game server sends `heartbeat EnemyTerritory-1` to the master.
2. The master does not trust it: it queries the **source IP:port of the heartbeat**. `etmaster.net` sends `getChallenge`, then `getInfo` (classic Quake 3 masters send `getinfo <challenge>` directly).
3. The game server answers (`challengeResponse`, `infoResponse`). The master lists the **source IP:port of those answers**.
Our ET server sits at home, reachable from the Internet only through the front server and the WireGuard tunnel. Players went in through the front (DNAT, then nginx `stream`), but the ET server's own heartbeats went **out through the home box**: the master challenged the home IP, where nothing listens for it. Result: server reachable by IP, invisible in the browser.
![Before: heartbeat leaves through the home box](diagrams/before.png)
## The fix
`etrelay` runs on the front and owns the public UDP port 27960. It does two things on **the same socket**:
- **Proxy**: forwards every client (players, server browsers, masters) to the ET server, like nginx `stream`.
- **Heartbeat**: sends the heartbeats itself, so the master challenges the **front** address. That challenge is just another client packet: it goes through the proxy, the ET server answers it, the answer leaves from the front address, and the master lists the front.
The ET server's own heartbeats are disabled (`sv_master1..5 ""`).
Masters are independent: a server is only listed on the masters it heartbeats, and each client browses its own master (ET 2.60b: `etmaster.idsoftware.com`, ET: Legacy: `etmaster.net`). The relay heartbeats both by default; diagrams show `etmaster.net`, the id Software master works the same way.
![After: everything goes through the front](diagrams/architecture.png)
## Registration sequence
![Master registration (every 5 min)](diagrams/registration.png)
What it looks like in production (`--debug`):
```
heartbeat EnemyTerritory-1 -> etmaster.net:27950 (198.51.100.10:27950)
session + 198.51.100.10:27950 (2 active)
198.51.100.10:27950 -> ET getChallenge (27 B)
198.51.100.10:27950 <- ET challengeResponse (32 B)
198.51.100.10:27950 -> ET getInfo (22 B)
198.51.100.10:27950 <- ET infoResponse (303 B)
session + 203.0.113.7:45488 (3 active)
203.0.113.7:45488 -> ET getstatus (14 B)
203.0.113.7:45488 <- ET statusResponse (993 B)
```
The last two lines are a server tracker that got the address from the master.
## Player sequence
![Player: one session per client address](diagrams/player-session.png)
The ET server sees every client as the nginx `stream` host (one source IP, one port per session): IP bans don't work, same as before the relay.
## Internals
Single-threaded tokio runtime, three kinds of tasks:
![Tasks and data flow](diagrams/tasks.png)
- **relay loop**: reads the public socket, finds the client's session and pushes the packet into its queue. No session, or an expired one: opens a new one (and purges expired entries). A full queue drops the packet, like any congested UDP hop.
- **session task**: owns one ephemeral socket connected to the ET server. Forwards the queue to the ET server and the ET server answers back to the client **through the public socket** (so they come from `:27960`). Ends after `SESSION_TIMEOUT` without a packet either way.
- **heartbeat task**: probes the ET server with `getinfo` on its own socket (up to 3 attempts, 3 s each), then heartbeats the masters through the public socket. Masters are resolved on every heartbeat (DNS may change).
### Heartbeat states
![Heartbeat decision (every 5 min)](diagrams/heartbeat-states.png)
The master drops a server that stops heartbeating after a while anyway; the flatline only makes it faster.
### Constants
| Constant | Value | Why |
|---|---|---|
| `HEARTBEAT_INTERVAL` | 5 min | Same period as the ET server itself |
| `PROBE_ATTEMPTS` × `PROBE_TIMEOUT` | 3 × 3 s | One lost UDP packet must not flatline the server |
| `SESSION_TIMEOUT` | 2 min | Idle clients (server browsers) freed quickly; players send packets constantly |
| `SESSION_QUEUE` | 64 packets | Burst absorption per client before dropping |
| `MAX_PACKET` | 65 535 B | Any UDP datagram: `statusResponse` easily exceeds nginx's old 512 B buffer |
### Protocol cheat sheet
Connectionless packets start with `\xff\xff\xff\xff`, then a command:
| Command | From → to | Meaning |
|---|---|---|
| `heartbeat EnemyTerritory-1` | server → master | "I'm alive, come check" |
| `heartbeat ETFlatline-1` | server → master | "I'm going down" |
| `getChallenge` / `challengeResponse` | master ↔ server | Master check before `getInfo` (etmaster.net) |
| `getinfo` / `getInfo` | master/browser → server | Short info request (optional challenge) |
| `infoResponse` | server → master/browser | Hostname, map, player count, challenge |
| `getstatus` / `statusResponse` | browser ↔ server | Full cvars + player list |
| `getchallenge` / `challengeResponse` / `connect` | player ↔ server | Connection handshake |
Anything else (no prefix) is in-game traffic: forwarded as-is, only counted in `--debug` logs.
+59
View File
@@ -0,0 +1,59 @@
ROOT_DIR := $(dir $(realpath $(lastword $(MAKEFILE_LIST))))
NAME := etrelay
VERSION := $(shell grep -m1 '^version' Cargo.toml | cut -d '"' -f2)
GNU := x86_64-unknown-linux-gnu
MUSL := x86_64-unknown-linux-musl
# Release artifacts: <name>-v<version>-<target triple> (+ .sha256).
DIST_DIR := $(ROOT_DIR)dist
DYNAMIC_BIN := $(DIST_DIR)/$(NAME)-v$(VERSION)-$(GNU)
STATIC_BIN := $(DIST_DIR)/$(NAME)-v$(VERSION)-$(MUSL)
.PHONY: show lint format check build static release diagrams clean
show:
@echo "$(NAME) version: $(VERSION)"
lint:
cargo clippy --fix --allow-dirty
format:
cargo fmt --all
check: lint format
# Dynamic (glibc): runs on hosts with the same or newer glibc.
build: check
cargo build --release --target $(GNU)
@$(call dist,$(GNU),$(DYNAMIC_BIN))
# Static (musl): runs on any x86_64 Linux. Pure Rust, no musl-gcc needed.
static: check
rustup target add $(MUSL)
cargo build --release --target $(MUSL)
@$(call dist,$(MUSL),$(STATIC_BIN))
release: build static
# PlantUML sources (diagrams/*.puml) -> PNG next to them, used by ENGINE.md.
diagrams:
plantuml -tpng $(ROOT_DIR)diagrams/*.puml
@ls -1 $(ROOT_DIR)diagrams/*.png
clean:
cargo clean
rm -rf $(DIST_DIR)
# $(call dist,<target triple>,<artifact path>): copy the release binary to dist/ + checksum.
define dist
mkdir -p $(DIST_DIR)
cp $(ROOT_DIR)target/$(1)/release/$(NAME) $(2)
cd $(DIST_DIR) && sha256sum $(notdir $(2)) > $(notdir $(2)).sha256
ls -lh $(2)
endef
# Deployment targets (deploy, logs, status...) kept with the infra config, reached through
# the local .deploy symlink (gitignored). Optional: silently skipped when absent.
ETRELAY_DIR := $(patsubst %/,%,$(ROOT_DIR))
-include $(ROOT_DIR).deploy/Makefile
+80
View File
@@ -0,0 +1,80 @@
# etrelay
UDP relay + master heartbeat for a Wolfenstein: Enemy Territory server reachable only through a tunnel: runs on the front server (public IP) and makes the server show up in the in-game browser.
```
players / masters ──> front :27960 (etrelay) ══wg══> <wg-peer>:27960 (nginx stream) ──> ET <et-server>:27960
```
- **Proxy**: one upstream socket per client address, packets forwarded as-is both ways, session dropped after 2 min of silence.
- **Heartbeat**: every 5 min, `getinfo` to the ET server; if it answers, `heartbeat EnemyTerritory-1` to the masters **from the public socket**. When it stops answering, one `heartbeat ETFlatline-1`.
Masters check (`getChallenge`, `getInfo`) the address the heartbeat came from: the public `:27960`. Those packets go through the relay like any client, the ET server answers them, and the master lists the front public address.
How it works in detail, with diagrams: [ENGINE.md](ENGINE.md).
## Usage
```bash
etrelay <UPSTREAM> [--listen 0.0.0.0:27960] [--master host:port]... [--debug]
```
Default masters: `etmaster.idsoftware.com:27950` (id Software, the one ET 2.60b clients query) and `etmaster.net:27950` (ET: Legacy community). Passing `--master` replaces both.
`--debug` logs sessions (open/close, packet counts), connectionless packets by command name only (`getinfo`, `getstatus`, `connect`...: no arguments, `connect` carries the userinfo), probes and heartbeats. In-game packets are only counted.
```
probe <wg-peer>:27960: up
ET server <wg-peer>:27960 is up
heartbeat EnemyTerritory-1 -> etmaster.net:27950 (198.51.100.10:27950)
session + 198.51.100.10:27950 (2 active)
198.51.100.10:27950 -> ET getChallenge (27 B)
198.51.100.10:27950 <- ET challengeResponse (32 B)
198.51.100.10:27950 -> ET getInfo (22 B)
198.51.100.10:27950 <- ET infoResponse (303 B)
session - <player>:27960: 5321 packets to ET, 4876 from ET
```
As a service: add `--debug` to `ExecStart`, then `systemctl daemon-reload && systemctl restart etrelay`.
## Build
```bash
make build # dynamic (glibc)
make static # static (musl), runs on any x86_64 Linux
make release # both
```
Artifacts land in `dist/`, named `<name>-v<version>-<target triple>` with a `.sha256` checksum:
```
dist/etrelay-v0.1.0-x86_64-unknown-linux-gnu # dynamic
dist/etrelay-v0.1.0-x86_64-unknown-linux-musl # static
```
## Run as a service
1. Binary and unit on the front server ([etrelay.service](etrelay.service) is a template: replace `@UPSTREAM@` with the ET server `ip:port` as seen from the front, `@ARGS@` with extra flags or nothing):
```bash
install -m 755 dist/etrelay-v<version>-x86_64-unknown-linux-musl /usr/local/bin/etrelay
sed -e 's#@UPSTREAM@#<ip:port>#' -e 's#@ARGS@##' etrelay.service > /etc/systemd/system/etrelay.service
systemctl daemon-reload && systemctl enable --now etrelay
```
2. Nothing else on the front may capture UDP 27960 (DNAT rule, nginx `stream`...), or players bypass the relay: `iptables -t nat -S | grep 27960` must print nothing.
3. ET server `server.cfg`: stop its own heartbeats, they leave through the home connection with the wrong IP:
```
set sv_master1 ""
set sv_master2 ""
set sv_master3 ""
set sv_master4 ""
set sv_master5 ""
```
## Check
```bash
journalctl -u etrelay -n 50 # "ET server ... is up"
tcpdump -ni any udp port 27950 # heartbeat out, getChallenge/getInfo in, answers out
```
The server then shows up on the master (in-game server browser).
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+21
View File
@@ -0,0 +1,21 @@
@startuml
skinparam shadowing false
title After: everything goes through the front
actor Player as player
node "Master\netmaster.net:27950" as master
node "Front (public IP)" {
component "etrelay\n0.0.0.0:27960" as relay
}
node "Home" {
component "nginx stream\n<wg-peer>:27960" as nginx
component "ET server\n<et-server>:27960" as et
}
player <--> relay : game traffic
master <--> relay : heartbeat / getChallenge / getInfo
relay <--> nginx : WireGuard (wg0)
nginx <--> et : LAN
@enduml
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+14
View File
@@ -0,0 +1,14 @@
@startuml
skinparam shadowing false
title Before: heartbeat leaves through the home box
node "Master\netmaster.net:27950" as master
node "Home box\nhome IP" as box
node "Front\npublic IP" as front
node "ET server\n<et-server>:27960" as et
et --> box : heartbeat
box --> master : heartbeat (source = home IP)
master --> box : getChallenge\n(to home IP: dropped)
front ..> et : players (DNAT + wg + nginx stream)
@enduml
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+10
View File
@@ -0,0 +1,10 @@
@startuml
skinparam shadowing false
title Heartbeat decision (every 5 min)
[*] --> Down
Down --> Up : probe answers\n/ heartbeat EnemyTerritory-1
Up --> Up : probe answers\n/ heartbeat EnemyTerritory-1
Up --> Down : no answer\n/ heartbeat ETFlatline-1 (once)
Down --> Down : no answer\n/ nothing sent
@enduml
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+19
View File
@@ -0,0 +1,19 @@
@startuml
skinparam shadowing false
title Player: one session per client address
actor "Player\n1.2.3.4:5555" as player
participant "etrelay\nfront :27960" as relay
participant "session task\n(ephemeral socket)" as session
participant "ET server" as et
player -> relay : getchallenge / connect / game packets
relay -> session : first packet: open session
session -> et : forward (source = ephemeral port)
et --> session : reply
session --> player : reply sent from front:27960
...packets both ways, deadline pushed back on each packet...
...2 min without any packet...
session -> session : close (socket dropped)
note over relay : next packet from 1.2.3.4:5555\nopens a new session
@enduml
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

+28
View File
@@ -0,0 +1,28 @@
@startuml
skinparam shadowing false
title Master registration (every 5 min)
participant "Master\netmaster.net:27950" as master
participant "etrelay\nfront :27960" as relay
participant "ET server\n(via wg + nginx)" as et
== Liveness probe (dedicated ephemeral socket) ==
relay -> et : getinfo etrelay
et --> relay : infoResponse
note right of relay : up: send heartbeat\nno answer x3: skip (or flatline once)
== Heartbeat (public socket) ==
relay -> master : heartbeat EnemyTerritory-1\nsource = front:27960
== Master checks: handled like any client ==
master -> relay : getChallenge\nto front:27960
relay -> relay : new session for master:27950
relay -> et : getChallenge
et --> relay : challengeResponse
relay --> master : challengeResponse\nsource = front:27960
master -> relay : getInfo
relay -> et : getInfo
et --> relay : infoResponse (hostname, map, players)
relay --> master : infoResponse\nsource = front:27960
note left of master : lists <front-ip>:27960
@enduml
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+26
View File
@@ -0,0 +1,26 @@
@startuml
skinparam shadowing false
title Tasks and data flow
rectangle "relay loop (main task)" as loop {
card "public socket\nrecv_from" as recv
card "sessions:\nHashMap<client, Sender>" as map
}
rectangle "session task (one per client)" as st {
card "mpsc Receiver\n(queue: 64)" as rx
card "upstream socket\n(connected to ET)" as up
}
rectangle "heartbeat task" as hb {
card "interval 5 min" as tick
card "probe socket" as probe
}
recv --> map : lookup client
map --> rx : try_send(packet)\n(full: drop)
rx --> up : send
up --> recv : recv, then public.send_to(client)
tick --> probe : getinfo
tick --> recv : heartbeat via public socket
@enduml
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=Wolfenstein: Enemy Territory UDP relay + master heartbeat
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/local/bin/etrelay @UPSTREAM@ @ARGS@
Restart=always
RestartSec=2
# Port 27960 > 1024: no privilege needed.
DynamicUser=yes
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
[Install]
WantedBy=multi-user.target
+253
View File
@@ -0,0 +1,253 @@
//! UDP relay for a Wolfenstein: Enemy Territory server reachable only through a tunnel.
//!
//! - Proxies every client (players, server browsers, masters) to the ET server, one
//! upstream socket per client address, like nginx `stream`.
//! - Sends master heartbeats from the public socket, so masters challenge (`getinfo`) the
//! public address: the challenge goes through the relay like any client packet, the ET
//! server answers it, and the master lists the public address.
use anyhow::{Context, Result};
use clap::Parser;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::net::{UdpSocket, lookup_host};
use tokio::sync::mpsc::{self, error::TrySendError};
use tokio::time::{Instant, interval, sleep_until, timeout};
/// Connectionless packet prefix (Quake 3 / ET protocol).
const OOB: &[u8] = b"\xff\xff\xff\xff";
/// Same heartbeat period as the ET server itself.
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(300);
const HEARTBEAT_ALIVE: &str = "EnemyTerritory-1";
const HEARTBEAT_DEAD: &str = "ETFlatline-1";
const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
const PROBE_ATTEMPTS: usize = 3;
const SESSION_TIMEOUT: Duration = Duration::from_secs(120);
const SESSION_QUEUE: usize = 64;
const MAX_PACKET: usize = 65_535;
/// Longest connectionless command name shown in debug logs.
const MAX_COMMAND: usize = 32;
static DEBUG: AtomicBool = AtomicBool::new(false);
/// println! only with --debug.
macro_rules! debug {
($($arg:tt)*) => {
if DEBUG.load(Ordering::Relaxed) {
println!($($arg)*);
}
};
}
/// UDP relay + master heartbeat for a Wolfenstein: Enemy Territory server.
#[derive(Parser)]
#[command(version, name = "etrelay")]
struct Cli {
/// ET server address, e.g. 10.0.0.2:27960
upstream: SocketAddr,
/// Public address to listen on
#[arg(long, default_value = "0.0.0.0:27960")]
listen: SocketAddr,
/// Master server host:port (repeatable)
#[arg(
long = "master",
default_values = ["etmaster.idsoftware.com:27950", "etmaster.net:27950"]
)]
masters: Vec<String>,
/// Log sessions, connectionless packets (getinfo, getstatus, connect...), probes and heartbeats
#[arg(long)]
debug: bool,
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> {
let cli = Cli::parse();
DEBUG.store(cli.debug, Ordering::Relaxed);
let public = UdpSocket::bind(cli.listen)
.await
.with_context(|| format!("bind {}", cli.listen))?;
let public = Arc::new(public);
println!(
"etrelay: {} -> {}, masters: {}",
cli.listen,
cli.upstream,
cli.masters.join(", ")
);
tokio::spawn(heartbeat(public.clone(), cli.upstream, cli.masters));
relay(public, cli.upstream).await
}
/// Dispatches client packets to their session, opening one on first packet.
async fn relay(public: Arc<UdpSocket>, upstream: SocketAddr) -> Result<()> {
let mut sessions: HashMap<SocketAddr, mpsc::Sender<Vec<u8>>> = HashMap::new();
let mut buf = vec![0u8; MAX_PACKET];
loop {
let (n, client) = public.recv_from(&mut buf).await.context("recv")?;
let mut packet = buf[..n].to_vec();
if let Some(tx) = sessions.get(&client) {
match tx.try_send(packet) {
// Full: drop it, like any congested UDP hop.
Ok(()) | Err(TrySendError::Full(_)) => continue,
// Session expired: open a new one below.
Err(TrySendError::Closed(p)) => packet = p,
}
}
sessions.retain(|_, tx| !tx.is_closed());
match open_session(public.clone(), upstream, client).await {
Ok(tx) => {
let _ = tx.try_send(packet);
sessions.insert(client, tx);
debug!("session + {client} ({} active)", sessions.len());
}
Err(e) => eprintln!("session {client}: {e:#}"),
}
}
}
/// Forwards packets between one client and the ET server until SESSION_TIMEOUT of silence.
async fn open_session(
public: Arc<UdpSocket>,
upstream: SocketAddr,
client: SocketAddr,
) -> Result<mpsc::Sender<Vec<u8>>> {
let sock = connect(upstream).await?;
let (tx, mut rx) = mpsc::channel::<Vec<u8>>(SESSION_QUEUE);
tokio::spawn(async move {
let mut buf = vec![0u8; MAX_PACKET];
let mut deadline = Instant::now() + SESSION_TIMEOUT;
let (mut sent, mut received) = (0u64, 0u64);
loop {
tokio::select! {
Some(packet) = rx.recv() => {
if let Some(command) = command(&packet) {
debug!("{client} -> ET {command} ({} B)", packet.len());
}
if let Err(e) = sock.send(&packet).await {
debug!("{client} -> ET send failed: {e}");
}
sent += 1;
deadline = Instant::now() + SESSION_TIMEOUT;
}
res = sock.recv(&mut buf) => match res {
Ok(n) => {
if let Some(command) = command(&buf[..n]) {
debug!("{client} <- ET {command} ({n} B)");
}
let _ = public.send_to(&buf[..n], client).await;
received += 1;
deadline = Instant::now() + SESSION_TIMEOUT;
}
// ICMP unreachable while the ET server is down: keep waiting.
Err(e) => debug!("{client} <- ET recv failed: {e}"),
},
_ = sleep_until(deadline) => break,
}
}
debug!("session - {client}: {sent} packets to ET, {received} from ET");
});
Ok(tx)
}
/// Heartbeats masters while the ET server answers, sends one flatline when it stops.
async fn heartbeat(public: Arc<UdpSocket>, upstream: SocketAddr, masters: Vec<String>) {
let mut ticker = interval(HEARTBEAT_INTERVAL);
let mut alive = false;
loop {
ticker.tick().await;
let up = probe(upstream).await;
debug!("probe {upstream}: {}", if up { "up" } else { "no answer" });
if up != alive {
println!("ET server {upstream} is {}", if up { "up" } else { "down" });
}
let message = match (up, alive) {
(true, _) => HEARTBEAT_ALIVE,
(false, true) => HEARTBEAT_DEAD,
(false, false) => continue,
};
alive = up;
let packet = [OOB, format!("heartbeat {message}\n").as_bytes()].concat();
for master in &masters {
match send_to_host(&public, &packet, master).await {
Ok(addr) => debug!("heartbeat {message} -> {master} ({addr})"),
Err(e) => eprintln!("heartbeat {master}: {e:#}"),
}
}
}
}
/// True if the ET server answers `getinfo`.
async fn probe(upstream: SocketAddr) -> bool {
for _ in 0..PROBE_ATTEMPTS {
let attempt = async {
let sock = connect(upstream).await?;
sock.send(&[OOB, b"getinfo etrelay"].concat()).await?;
let mut buf = vec![0u8; MAX_PACKET];
let n = sock.recv(&mut buf).await?;
Ok::<_, anyhow::Error>(buf[..n].starts_with(&[OOB, b"infoResponse"].concat()))
};
if let Ok(Ok(true)) = timeout(PROBE_TIMEOUT, attempt).await {
return true;
}
}
false
}
/// Resolves `host` (DNS may change between heartbeats) and sends from `sock`.
async fn send_to_host(sock: &UdpSocket, packet: &[u8], host: &str) -> Result<SocketAddr> {
let ipv4 = sock.local_addr()?.is_ipv4();
let addr = lookup_host(host)
.await?
.find(|a| a.is_ipv4() == ipv4)
.context("no address in the listen socket family")?;
sock.send_to(packet, addr).await?;
Ok(addr)
}
/// Command name of a connectionless packet (`getinfo`, `infoResponse`, `connect`...),
/// None for in-game packets. Arguments are left out: `connect` carries the userinfo.
fn command(packet: &[u8]) -> Option<String> {
let body = packet.strip_prefix(OOB)?;
let end = body
.iter()
.position(|b| b.is_ascii_whitespace() || *b == b'\\')
.unwrap_or(body.len())
.min(MAX_COMMAND);
Some(String::from_utf8_lossy(&body[..end]).into_owned())
}
/// Ephemeral socket connected to `upstream`.
async fn connect(upstream: SocketAddr) -> Result<UdpSocket> {
let any: SocketAddr = if upstream.is_ipv4() {
"0.0.0.0:0".parse()?
} else {
"[::]:0".parse()?
};
let sock = UdpSocket::bind(any).await?;
sock.connect(upstream).await?;
Ok(sock)
}