187 lines
6.0 KiB
Rust
187 lines
6.0 KiB
Rust
//! Which client packets reach the ET server: in-game packets always, connectionless ones
|
|
//! only when a client or a master would send them, queries rate limited per source IP.
|
|
use crate::OOB;
|
|
use std::collections::HashMap;
|
|
use std::net::IpAddr;
|
|
use std::time::Duration;
|
|
use tokio::time::Instant;
|
|
|
|
/// Connectionless commands clients and masters send a server. Anything else is dropped,
|
|
/// rcon above all: only etadm uses it, from the server itself, and a stream of rcon
|
|
/// packets would block it (2.60b runs one rcon per 500 ms, whoever sends it).
|
|
const ALLOWED: [&str; 4] = ["getstatus", "getinfo", "getchallenge", "connect"];
|
|
/// PunkBuster's connectionless packets, matched as is by the server (no tokenizing).
|
|
const PUNKBUSTER: &[u8] = b"PB_";
|
|
/// Queries answered with a much bigger packet: from a spoofed source they would make the
|
|
/// server flood a victim (UDP amplification). Limited per source IP.
|
|
const QUERIES: [&str; 2] = ["getstatus", "getinfo"];
|
|
/// A burst (a browser refresh, a master's challenge), then one query a second.
|
|
const QUERY_BURST: f64 = 5.0;
|
|
const QUERY_RATE: f64 = 1.0;
|
|
/// Source IPs the limiter remembers: past this, queries from new IPs are dropped, so a
|
|
/// flood of spoofed sources can't grow memory.
|
|
const MAX_QUERY_SOURCES: usize = 65_536;
|
|
/// Idle IPs are forgotten after this (their bucket is full again long before).
|
|
const QUERY_FORGET: Duration = Duration::from_secs(60);
|
|
|
|
pub struct Filter {
|
|
queries: HashMap<IpAddr, Bucket>,
|
|
forgotten: Instant,
|
|
}
|
|
|
|
/// Token bucket of one source IP's queries.
|
|
struct Bucket {
|
|
tokens: f64,
|
|
at: Instant,
|
|
}
|
|
|
|
impl Filter {
|
|
pub fn new(now: Instant) -> Self {
|
|
Self {
|
|
queries: HashMap::new(),
|
|
forgotten: now,
|
|
}
|
|
}
|
|
|
|
/// Why the packet is dropped, None to forward it. In-game packets always pass.
|
|
pub fn refuse(&mut self, packet: &[u8], ip: IpAddr, now: Instant) -> Option<&'static str> {
|
|
let body = packet.strip_prefix(OOB)?;
|
|
if body.starts_with(PUNKBUSTER) {
|
|
return None;
|
|
}
|
|
|
|
let name = first_token(body);
|
|
if !ALLOWED
|
|
.iter()
|
|
.any(|c| c.as_bytes().eq_ignore_ascii_case(name))
|
|
{
|
|
return Some("connectionless command not allowed");
|
|
}
|
|
|
|
if QUERIES
|
|
.iter()
|
|
.any(|q| q.as_bytes().eq_ignore_ascii_case(name))
|
|
&& !self.query(ip, now)
|
|
{
|
|
return Some("query rate");
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
/// Takes a token from ip's bucket.
|
|
fn query(&mut self, ip: IpAddr, now: Instant) -> bool {
|
|
if now.duration_since(self.forgotten) >= QUERY_FORGET {
|
|
self.queries
|
|
.retain(|_, b| now.duration_since(b.at) < QUERY_FORGET);
|
|
self.forgotten = now;
|
|
}
|
|
|
|
if !self.queries.contains_key(&ip) && self.queries.len() >= MAX_QUERY_SOURCES {
|
|
return false;
|
|
}
|
|
|
|
let bucket = self.queries.entry(ip).or_insert(Bucket {
|
|
tokens: QUERY_BURST,
|
|
at: now,
|
|
});
|
|
let refill = now.duration_since(bucket.at).as_secs_f64() * QUERY_RATE;
|
|
bucket.tokens = (bucket.tokens + refill).min(QUERY_BURST);
|
|
bucket.at = now;
|
|
|
|
if bucket.tokens < 1.0 {
|
|
return false;
|
|
}
|
|
|
|
bucket.tokens -= 1.0;
|
|
true
|
|
}
|
|
}
|
|
|
|
/// First word of a connectionless packet as the server's tokenizer reads it: leading
|
|
/// bytes up to space skipped (control characters included). Only compared against
|
|
/// ALLOWED: a word the tokenizer would read differently (comments, quotes) matches
|
|
/// nothing and is dropped.
|
|
fn first_token(body: &[u8]) -> &[u8] {
|
|
let start = body.iter().position(|b| *b > b' ').unwrap_or(body.len());
|
|
let body = &body[start..];
|
|
let end = body.iter().position(|b| *b <= b' ').unwrap_or(body.len());
|
|
|
|
&body[..end]
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const IP: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, 7));
|
|
|
|
fn oob(body: &[u8]) -> Vec<u8> {
|
|
[OOB, body].concat()
|
|
}
|
|
|
|
#[test]
|
|
fn forwards_game_traffic() {
|
|
let mut f = Filter::new(Instant::now());
|
|
let now = Instant::now();
|
|
|
|
for body in [
|
|
&b"getchallenge"[..],
|
|
b"connect \x12\x34",
|
|
b"getinfo xxx",
|
|
b"PB_whatever",
|
|
] {
|
|
assert_eq!(f.refuse(&oob(body), IP, now), None, "{body:?}");
|
|
}
|
|
|
|
// In-game packets (no OOB prefix).
|
|
assert_eq!(f.refuse(b"\x01\x02\x03", IP, now), None);
|
|
}
|
|
|
|
#[test]
|
|
fn drops_rcon_however_written() {
|
|
let mut f = Filter::new(Instant::now());
|
|
let now = Instant::now();
|
|
|
|
for body in [
|
|
&b"rcon pass status"[..],
|
|
b"RCON pass status",
|
|
b" \t rcon pass status",
|
|
b"\x01rcon pass status",
|
|
b"/**/rcon pass status",
|
|
b"\"rcon\" pass status",
|
|
b"",
|
|
] {
|
|
assert!(f.refuse(&oob(body), IP, now).is_some(), "{body:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn limits_queries_per_ip() {
|
|
let start = Instant::now();
|
|
let mut f = Filter::new(start);
|
|
|
|
let passed = (0..20)
|
|
.filter(|_| f.refuse(&oob(b"getstatus"), IP, start).is_none())
|
|
.count();
|
|
assert_eq!(passed, QUERY_BURST as usize);
|
|
|
|
// Another IP has its own bucket; connect isn't a query.
|
|
let other = IpAddr::V4(std::net::Ipv4Addr::new(198, 51, 100, 1));
|
|
assert_eq!(f.refuse(&oob(b"getinfo"), other, start), None);
|
|
assert_eq!(f.refuse(&oob(b"connect x"), IP, start), None);
|
|
|
|
// One token a second.
|
|
let later = start + Duration::from_secs(1);
|
|
assert_eq!(f.refuse(&oob(b"getinfo"), IP, later), None);
|
|
assert!(f.refuse(&oob(b"getinfo"), IP, later).is_some());
|
|
|
|
// Forgotten after QUERY_FORGET: full again.
|
|
let much_later = start + QUERY_FORGET + Duration::from_secs(1);
|
|
let passed = (0..20)
|
|
.filter(|_| f.refuse(&oob(b"getinfo"), IP, much_later).is_none())
|
|
.count();
|
|
assert_eq!(passed, QUERY_BURST as usize);
|
|
}
|
|
}
|