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