From a018cd8f5d2320b74d0e8e408fe16c73e744f456 Mon Sep 17 00:00:00 2001 From: rmanach Date: Sat, 26 Sep 2026 15:42:59 +0200 Subject: [PATCH] add etmasters script + etrelay section in README --- .gitignore | 2 + README.md | 42 +++++++++++--- etmasters.py | 161 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 196 insertions(+), 9 deletions(-) create mode 100644 .gitignore create mode 100755 etmasters.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e6bf896 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.tar.gz +*.json diff --git a/README.md b/README.md index bb7278d..e495f0f 100644 --- a/README.md +++ b/README.md @@ -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. + +* **[etrelay](https://gitea.sonak.fr/rmanach/etrelay)**: a small Rust UDP relay running on the front. It proxies players and sends the master heartbeats from the front public address. Build, usage and how it works (with diagrams): see its repository. +* 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**. diff --git a/etmasters.py b/etmasters.py new file mode 100755 index 0000000..ac98f3c --- /dev/null +++ b/etmasters.py @@ -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()