add etrelay UDP relay + etmasters script + update README
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
target
|
||||
dist
|
||||
|
||||
*.tar.gz
|
||||
*.json
|
||||
@@ -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
@@ -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()
|
||||
Generated
+258
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||

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

|
||||
|
||||
## Registration sequence
|
||||
|
||||

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

|
||||
|
||||
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:
|
||||
|
||||

|
||||
|
||||
- **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
|
||||
|
||||

|
||||
|
||||
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.
|
||||
@@ -0,0 +1,73 @@
|
||||
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 SSH host) and ET server behind the tunnel (ip:port),
|
||||
# e.g. make deploy HOST=front UPSTREAM=10.0.0.2:27960
|
||||
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
|
||||
@test -n "$(HOST)" -a -n "$(UPSTREAM)" || (echo "usage: make deploy HOST=<ssh host> UPSTREAM=<ip:port>" && exit 1)
|
||||
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:
|
||||
@test -n "$(HOST)" || (echo "usage: make logs HOST=<ssh host>" && exit 1)
|
||||
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
|
||||
@@ -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══> <wg-peer>:27960 (nginx stream) ──> ET <et-server>: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 <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 (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
|
||||
```
|
||||
|
||||
Deploy with it: `make deploy HOST=<front> UPSTREAM=<wg-peer>:27960 ARGS=--debug`, back to normal without `ARGS`.
|
||||
|
||||
## 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 the front, `HOST` is its SSH host): `make deploy HOST=<front> UPSTREAM=<wg-peer>:27960`
|
||||
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 HOST=<front> # "ET server ... is up"
|
||||
ssh <front> 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 |
@@ -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 (public IP)" {
|
||||
component "etrelay\n0.0.0.0:27960" as relay
|
||||
}
|
||||
|
||||
node "Home" {
|
||||
component "nginx stream\n<wg-peer>:27960" as nginx
|
||||
component "ET server\n<et-server>: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 |
@@ -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\nhome IP" as box
|
||||
node "Front\npublic IP" as front
|
||||
node "ET server\n<et-server>: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 |
@@ -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 |
@@ -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 |
@@ -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 <front-ip>:27960
|
||||
@enduml
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
@@ -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
|
||||
@@ -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
|
||||
@@ -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.0.0.2: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)
|
||||
}
|
||||
Reference in New Issue
Block a user