Compare commits

...
2 Commits
Author SHA1 Message Date
rmanach 5edc20539d switch jaymod pk3 url to et.sonak.fr 2026-09-26 14:53:52 +02:00
rmanach 6b9d14f86b add etrelay UDP relay + etmasters script + update README 2026-09-26 14:53:52 +02:00
23 changed files with 1107 additions and 10 deletions
+5
View File
@@ -0,0 +1,5 @@
target
dist
*.tar.gz
*.json
+33 -9
View File
@@ -12,18 +12,18 @@ One to launch a Jaymod server:
## Requirements
Wolfenstein ET is built on a 32 bits arch. So, 32 bits librairies must exist to run the game.
Wolfenstein ET is built on a 32 bits arch. So, 32 bits libraries must exist to run the game.
```bash
# add the i386 architecture
sudo dpkg --add-architecture i386
# do update to fetch i386 librairies
# do update to fetch i386 libraries
sudo apt update
# install the 32 bits compatible librairies
sudo apt install ia32-libs (mint)
sudo apt install lib32z1 (ubuntu)
# install the 32 bits compatible libraries
sudo apt install ia32-libs # Mint
sudo apt install lib32z1 # Ubuntu
# after the installation the following lib must be present
sudo apt install libsdl1.2debian:i386
@@ -65,11 +65,11 @@ cd /usr/local/games/enemy-territory
Enjoy !
**NOTE** : Some librairies may be missing, you need to install it one by one (test to launch ./et.x86 and see missing libs).
**NOTE** : Some libraries may be missing, you need to install it one by one (test to launch ./et.x86 and see missing libs).
## Create a systemd service (server creation only)
In order to launch the server properly, a systemd service is a right solution instead of the **serverctl** sh script. Todo so :
In order to launch the server properly, a systemd service is a right solution instead of the **serverctl** sh script. To do so:
* put the **[launch_server.bash](./service/launch_server.bash)** wherever you want (ex: /opt/games/enemy-territory/server/linux).
* put the **[wolfenstein-et.service](./service/wolfenstein-et.service)** file in **/etc/systemd/system** directory (must be **root**).
@@ -87,9 +87,33 @@ sudo service wolfenstein-et start
sudo service wolfenstein-et status
```
## Public server behind a tunnel
When the server runs at home and is only reachable through a front server (WireGuard), its own heartbeats leave through the home box: masters challenge the wrong IP and the server never shows up in the in-game browser.
* **[relay](./relay)**: `etrelay`, a small Rust UDP relay running on the front. It proxies players and sends the master heartbeats from the front public address. Build, deploy and usage: [relay/README.md](./relay/README.md), how it works (with diagrams): [relay/ENGINE.md](./relay/ENGINE.md).
* Disable the server's own heartbeats in `server.cfg`, the relay sends them:
```cfg
set sv_master1 ""
set sv_master2 ""
set sv_master3 ""
set sv_master4 ""
set sv_master5 ""
```
### List the servers known by the masters
**[etmasters.py](./etmasters.py)** (Python 3, standard library only) asks the masters (`etmaster.idsoftware.com`, `etmaster.net`) for their server list, then every server for its info: name, map, players, mod. Handy to check the server is listed.
```bash
./etmasters.py # table sorted by players
./etmasters.py --json servers.json # JSON file instead
./etmasters.py --master etmaster.net:27950 --timeout 5
```
## Settings
More useful informations : https://wiki.archlinux.org/title/Wolfenstein:_Enemy_Territory.
More useful information: https://wiki.archlinux.org/title/Wolfenstein:_Enemy_Territory.
### Set screen size in **1920*1080**
* Open the player configuration file:
@@ -126,7 +150,7 @@ You must install the corresponding 32 bits library:
sudo apt install libasound2-plugins:i386
```
### Keyboard inputs does not work
### Keyboard input does not work
Try moving to `Xorg` instead of `Wayland`... To do so, on Ubuntu, logout, click on the **Settings** icon and select **Xorg**.
Executable
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""List Wolfenstein: Enemy Territory servers known by the master servers.
Asks each master for its server list (getservers), then every server for its info
(getinfo): name, map, players, mod. Prints a table, or writes JSON with --json.
./etmasters.py
./etmasters.py --json servers.json
./etmasters.py --master etmaster.net:27950
"""
import argparse
import json
import re
import select
import socket
import sys
import time
OOB = b"\xff\xff\xff\xff"
# 84: ET 2.60b / ET: Legacy protocol
GETSERVERS = OOB + b"getservers 84 empty full"
GETINFO = OOB + b"getinfo etmasters"
DEFAULT_MASTERS = ["etmaster.idsoftware.com:27950", "etmaster.net:27950"]
# ^1red ^7white... color codes in server names
COLOR_CODE = re.compile(r"\^.")
def query_master(master, timeout):
"""Server addresses listed by one master, None if it doesn't answer."""
host, port = master.rsplit(":", 1)
try:
ip = socket.gethostbyname(host)
except OSError:
return None
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(timeout)
sock.sendto(GETSERVERS, (ip, int(port)))
servers, answered = set(), False
try:
while True:
data, _ = sock.recvfrom(65535)
if b"getserversResponse" not in data:
continue
answered = True
body = data.split(b"getserversResponse", 1)[1]
# \<4 bytes IP><2 bytes port>\...\EOT
for entry in body.split(b"\\")[1:]:
if len(entry) >= 6 and not entry.startswith(b"EOT"):
addr = socket.inet_ntoa(entry[:4])
servers.add((addr, int.from_bytes(entry[4:6], "big")))
# Every packet ends with EOT, the list spans several: read until timeout.
except socket.timeout:
pass
finally:
sock.close()
return servers if answered else None
def query_infos(addresses, timeout):
"""getinfo to every server at once, {address: info dict} for those answering."""
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setblocking(False)
for addr in addresses:
try:
sock.sendto(GETINFO, addr)
except OSError:
pass
infos, deadline = {}, time.monotonic() + timeout
while len(infos) < len(addresses):
left = deadline - time.monotonic()
if left <= 0 or not select.select([sock], [], [], left)[0]:
break
try:
data, addr = sock.recvfrom(65535)
except OSError:
continue
if data.startswith(OOB + b"infoResponse"):
infos[addr] = parse_info(data)
sock.close()
return infos
def parse_info(data):
"""infoResponse\n\\key\\value\\... -> dict."""
body = data.split(b"\n", 1)[1].decode("latin-1").strip("\\\n\x00")
parts = body.split("\\")
return dict(zip(parts[::2], parts[1::2]))
def server_entry(addr, masters, info):
entry = {"address": f"{addr[0]}:{addr[1]}", "masters": sorted(masters), "online": info is not None}
if info:
entry.update(
name=COLOR_CODE.sub("", info.get("hostname", "")).strip(),
map=info.get("mapname", ""),
players=int(info.get("clients", 0) or 0),
humans=int(info["humans"]) if info.get("humans", "").isdigit() else None,
max_players=int(info.get("sv_maxclients", 0) or 0),
# game: mod folder (nq, etpro, legacy...), gamename is always "et"
mod=info.get("game") or info.get("gamename", ""),
password=info.get("needpass") == "1",
)
return entry
def print_table(entries, masters_status):
for master, count in masters_status.items():
state = "no answer" if count is None else f"{count} servers"
print(f"{master:32} {state}")
print()
online = [e for e in entries if e["online"]]
online.sort(key=lambda e: (-e["players"], e["name"].lower()))
header = f"{'ADDRESS':21} {'PLAYERS':>7} {'MAP':16} {'MOD':10} NAME"
print(header)
print("-" * len(header))
for e in online:
players = f"{e['players']}/{e['max_players']}"
lock = " [pw]" if e["password"] else ""
print(f"{e['address']:21} {players:>7} {e['map'][:16]:16} {e['mod'][:10]:10} {e['name'][:60]}{lock}")
total = sum(e["players"] for e in online)
print(f"\n{len(online)} servers online ({len(entries) - len(online)} not answering), {total} players")
def main():
parser = argparse.ArgumentParser(description="List ET servers known by the master servers.")
parser.add_argument("--master", action="append", metavar="HOST:PORT",
help=f"master to query, repeatable (default: {', '.join(DEFAULT_MASTERS)})")
parser.add_argument("--json", metavar="PATH", help="write servers as JSON to PATH instead of printing a table")
parser.add_argument("--timeout", type=float, default=3.0, help="seconds to wait for answers (default: 3)")
args = parser.parse_args()
masters = args.master or DEFAULT_MASTERS
masters_status, listed_by = {}, {}
for master in masters:
servers = query_master(master, args.timeout)
masters_status[master] = None if servers is None else len(servers)
for addr in servers or ():
listed_by.setdefault(addr, set()).add(master)
infos = query_infos(list(listed_by), args.timeout)
entries = [server_entry(addr, ms, infos.get(addr)) for addr, ms in listed_by.items()]
if args.json:
with open(args.json, "w") as f:
json.dump({"masters": masters_status, "servers": entries}, f, indent=2, ensure_ascii=False)
print(f"{len(entries)} servers written to {args.json}", file=sys.stderr)
else:
print_table(entries, masters_status)
if __name__ == "__main__":
main()
+1 -1
View File
@@ -8,7 +8,7 @@ import urllib3
FORMAT = "%(asctime)s - %(levelname)s - %(message)s"
logging.basicConfig(format=FORMAT, level=logging.INFO)
URL = "http://et.thegux.fr/jaymod/"
URL = "http://et.sonak.fr/jaymod/"
FILES = (
"1944_beach.pk3",
"baserace.pk3",
+258
View File
@@ -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
View File
@@ -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
+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.
+70
View File
@@ -0,0 +1,70 @@
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)
# Deployment target (front server) and ET server behind the tunnel.
HOST :=
UPSTREAM :=
# Extra etrelay flags, e.g. make deploy ARGS=--debug
ARGS ?=
.PHONY: show lint format check build static release deploy logs 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
deploy: static
scp $(STATIC_BIN) $(HOST):/tmp/$(NAME)
sed -e 's#@UPSTREAM@#$(UPSTREAM)#' -e 's#@ARGS@#$(ARGS)#' $(NAME).service | ssh $(HOST) 'cat > /etc/systemd/system/$(NAME).service'
ssh $(HOST) 'install -m 755 /tmp/$(NAME) /usr/local/bin/$(NAME) && rm /tmp/$(NAME) \
&& systemctl daemon-reload && systemctl enable $(NAME) && systemctl restart $(NAME) \
&& systemctl --no-pager status $(NAME)'
logs:
ssh $(HOST) 'journalctl -u $(NAME) -n 50 --no-pager'
# 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
+77
View File
@@ -0,0 +1,77 @@
# etrelay
UDP relay + master heartbeat for the Wolfenstein: Enemy Territory server, running on the front server (replaces its UDP 27960 DNAT rule / nginx `stream` block).
```
players / masters ──> front :27960 (etrelay) ══wg══> 10.66.0.10:27960 (nginx stream) ──> ET 10.0.1.103: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 10.66.0.10:27960: up
ET server 10.66.0.10:27960 is up
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 - <player>:27960: 5321 packets to ET, 4876 from ET
```
Deploy with it: `make deploy ARGS=--debug`, back to normal with `make deploy`.
## 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
```
## Deploy
1. Relay (static binary + systemd unit on `ionos`): `make deploy`
2. Front: remove what captured UDP 27960 before etrelay, or players bypass it:
- the DNAT rule (`PostUp`/`PostDown` in `/etc/wireguard/wg0.conf`, then `wg-quick down wg0 && wg-quick up wg0`); `iptables -t nat -S | grep 27960` must print nothing
- the nginx `stream` block (`proxy/nginx/nginx.conf` in the `pve` repo): `make -C proxy deploy-nginx` from there
3. ET server `server.cfg`: stop its own heartbeats, they leave through the home box with the wrong IP:
```
set sv_master1 ""
set sv_master2 ""
set sv_master3 ""
set sv_master4 ""
set sv_master5 ""
```
## Check
```bash
make logs # "ET server ... is up"
ssh ionos 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

+21
View File
@@ -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 (74.208.250.147)" {
component "etrelay\n0.0.0.0:27960" as relay
}
node "Home" {
component "nginx stream\n10.66.0.10:27960" as nginx
component "ET server\n10.0.1.103: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

+14
View File
@@ -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\n86.x.x.x" as box
node "Front\n74.208.250.147" as front
node "ET server\n10.0.1.103: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

+10
View File
@@ -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

+19
View File
@@ -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

+28
View File
@@ -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 74.208.250.147:27960
@enduml
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+26
View File
@@ -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
+19
View File
@@ -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
View File
@@ -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.66.0.10: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)
}