Files
etrelay/src/main.rs
T

254 lines
8.6 KiB
Rust

//! 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)
}