commit 7f715979e1273de4896680311791198a051034b0 Author: rmanach Date: Sat Sep 26 15:51:43 2026 +0200 feat: initial etrelay UDP relay with master heartbeat diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..005fa4f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +target +dist + +# local symlink to the deployment config (pve repo) +.deploy diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..6ae4ebd --- /dev/null +++ b/Cargo.lock @@ -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", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9ecd35b --- /dev/null +++ b/Cargo.toml @@ -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 diff --git a/ENGINE.md b/ENGINE.md new file mode 100644 index 0000000..6de4dfc --- /dev/null +++ b/ENGINE.md @@ -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 ` 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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a66400f --- /dev/null +++ b/Makefile @@ -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: -v- (+ .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,,): 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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..dddfeec --- /dev/null +++ b/README.md @@ -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══> :27960 (nginx stream) ──> ET :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 [--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 :27960: up +ET server :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 - :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 `-v-` 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-x86_64-unknown-linux-musl /usr/local/bin/etrelay + sed -e 's#@UPSTREAM@##' -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). diff --git a/diagrams/architecture.png b/diagrams/architecture.png new file mode 100644 index 0000000..708bb20 Binary files /dev/null and b/diagrams/architecture.png differ diff --git a/diagrams/architecture.puml b/diagrams/architecture.puml new file mode 100644 index 0000000..d45142c --- /dev/null +++ b/diagrams/architecture.puml @@ -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:27960" as nginx + component "ET server\n:27960" as et +} + +player <--> relay : game traffic +master <--> relay : heartbeat / getChallenge / getInfo +relay <--> nginx : WireGuard (wg0) +nginx <--> et : LAN +@enduml diff --git a/diagrams/before.png b/diagrams/before.png new file mode 100644 index 0000000..6c1be88 Binary files /dev/null and b/diagrams/before.png differ diff --git a/diagrams/before.puml b/diagrams/before.puml new file mode 100644 index 0000000..7f4a18b --- /dev/null +++ b/diagrams/before.puml @@ -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: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 diff --git a/diagrams/heartbeat-states.png b/diagrams/heartbeat-states.png new file mode 100644 index 0000000..fb1decd Binary files /dev/null and b/diagrams/heartbeat-states.png differ diff --git a/diagrams/heartbeat-states.puml b/diagrams/heartbeat-states.puml new file mode 100644 index 0000000..fa65d5c --- /dev/null +++ b/diagrams/heartbeat-states.puml @@ -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 diff --git a/diagrams/player-session.png b/diagrams/player-session.png new file mode 100644 index 0000000..f07aaf9 Binary files /dev/null and b/diagrams/player-session.png differ diff --git a/diagrams/player-session.puml b/diagrams/player-session.puml new file mode 100644 index 0000000..742e4b0 --- /dev/null +++ b/diagrams/player-session.puml @@ -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 diff --git a/diagrams/registration.png b/diagrams/registration.png new file mode 100644 index 0000000..3caf4e2 Binary files /dev/null and b/diagrams/registration.png differ diff --git a/diagrams/registration.puml b/diagrams/registration.puml new file mode 100644 index 0000000..9582556 --- /dev/null +++ b/diagrams/registration.puml @@ -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 :27960 +@enduml diff --git a/diagrams/tasks.png b/diagrams/tasks.png new file mode 100644 index 0000000..1fc16ec Binary files /dev/null and b/diagrams/tasks.png differ diff --git a/diagrams/tasks.puml b/diagrams/tasks.puml new file mode 100644 index 0000000..a7cea9e --- /dev/null +++ b/diagrams/tasks.puml @@ -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" 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 diff --git a/etrelay.service b/etrelay.service new file mode 100644 index 0000000..b2ad1a8 --- /dev/null +++ b/etrelay.service @@ -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 diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..7baba71 --- /dev/null +++ b/src/main.rs @@ -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, + + /// 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, upstream: SocketAddr) -> Result<()> { + let mut sessions: HashMap>> = 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, + upstream: SocketAddr, + client: SocketAddr, +) -> Result>> { + let sock = connect(upstream).await?; + let (tx, mut rx) = mpsc::channel::>(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, upstream: SocketAddr, masters: Vec) { + 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 { + 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 { + 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 { + 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) +}