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══> ET <et-server>:27960
                      front <front-wg>:8090 (control API) <══wg══ admin tool
  • Proxy: one upstream socket per client address, packets forwarded as-is both ways, session dropped after 2 min of silence.
  • Filter: connectionless packets pass only for what clients and masters send a server (getstatus, getinfo, getchallenge, connect, PunkBuster's PB_). rcon never reaches the server from the internet: it travels in clear, and a stream of it blocks the local admin tool (2.60b runs one rcon per 500 ms, whoever sends it). getstatus/getinfo are limited per source IP (burst of 5, then 1/s): their answers are much bigger than the query, a spoofed source would make the server a DDoS reflector. At most 2048 sessions (one socket each).
  • 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.

Usage

etrelay <UPSTREAM> [--listen 0.0.0.0:27960] [--master host:port]... [--control <addr>] [--state-dir /var/lib/etrelay] [--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 as port 51234 (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.

Without --debug, each player joining is logged with its real address and the port the ET server sees it on (connect 203.0.113.7:27960 as port 51234): browser queries aren't.

Control API

--control <addr> serves a small HTTP API for the admin tool (etadm), on the tunnel address only (e.g. 10.66.0.1:8090: unreachable from the internet), with Authorization: Bearer $ETRELAY_CONTROL_TOKEN (32 characters at least). Binding is retried every 10 s: the tunnel may come up after etrelay.

The ET server sees every player as <tunnel address>:<port>, the port of the player's session socket; the API maps it back to the real address.

Method URL
GET /sessions [{"client": "203.0.113.7:27960", "port": 51234, "since": <unix>}]: open sessions
GET /bans [{"ip", "reason", "at", "until"}] (until null: until lifted)
PUT /bans/{ip} {"reason": "...", "minutes": N} (minutes left out: until lifted); cuts the address's sessions at once
DELETE /bans/{ip} 204, 404 when not banned

A banned address's packets are dropped before reaching the server: the player times out, and can't come back. Bans are kept in <state-dir>/bans.json (written atomically), expired ones forgotten.

Build

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 is a template: replace @UPSTREAM@ with the ET server ip:port as seen from the front, @ARGS@ with extra flags or nothing):
    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

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

S
Description
No description provided
Readme
633 KiB
Languages
Rust 97.2%
Makefile 2.8%