Files
etrelay/ENGINE.md
T

5.2 KiB
Raw Blame History

How etrelay works

Diagrams: PlantUML sources in 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

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

Registration sequence

Master registration (every 5 min)

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

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

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

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.