feat: initial etrelay UDP relay with master heartbeat
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
target
|
||||||
|
dist
|
||||||
|
|
||||||
|
# local symlink to the deployment config (pve repo)
|
||||||
|
.deploy
|
||||||
Generated
+258
@@ -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
@@ -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
|
||||||
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Registration sequence
|
||||||
|
|
||||||
|

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

|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
@@ -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
|
||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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
|
||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user