feat: control API with session ports and persistent bans
This commit is contained in:
+186
@@ -0,0 +1,186 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user