Compare commits
2
Commits
cc2edfb98b
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4feb0964a2 | ||
|
|
a018cd8f5d |
+1
-4
@@ -1,5 +1,2 @@
|
|||||||
target
|
|
||||||
dist
|
|
||||||
|
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
*.json
|
*.json
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ sudo service wolfenstein-et status
|
|||||||
|
|
||||||
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.
|
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).
|
* **[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:
|
* Disable the server's own heartbeats in `server.cfg`, the relay sends them:
|
||||||
```cfg
|
```cfg
|
||||||
set sv_master1 ""
|
set sv_master1 ""
|
||||||
|
|||||||
Generated
-258
@@ -1,258 +0,0 @@
|
|||||||
# 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",
|
|
||||||
]
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
[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
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
# 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.
|
Before Width: | Height: | Size: 24 KiB |
@@ -1,21 +0,0 @@
|
|||||||
@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.
|
Before Width: | Height: | Size: 22 KiB |
@@ -1,14 +0,0 @@
|
|||||||
@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.
|
Before Width: | Height: | Size: 17 KiB |
@@ -1,10 +0,0 @@
|
|||||||
@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.
|
Before Width: | Height: | Size: 35 KiB |
@@ -1,19 +0,0 @@
|
|||||||
@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.
|
Before Width: | Height: | Size: 48 KiB |
@@ -1,28 +0,0 @@
|
|||||||
@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.
|
Before Width: | Height: | Size: 35 KiB |
@@ -1,26 +0,0 @@
|
|||||||
@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
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
[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
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
//! 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