add etrelay UDP relay + etmasters script + update README

This commit is contained in:
rmanach
2026-09-26 14:53:52 +02:00
parent 002289fd35
commit 6b9d14f86b
22 changed files with 1106 additions and 9 deletions
+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 (91.220.53.121:27950)
session + 91.220.53.121:27950 (2 active)
91.220.53.121:27950 -> ET getChallenge (27 B)
91.220.53.121:27950 <- ET challengeResponse (32 B)
91.220.53.121:27950 -> ET getInfo (22 B)
91.220.53.121:27950 <- ET infoResponse (303 B)
session + 138.201.36.184:45488 (3 active)
138.201.36.184:45488 -> ET getstatus (14 B)
138.201.36.184: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.