feat(relay): rate limit client unknown unicast floods
Client-originated unknown unicast now has its own relay-side token bucket. When a client sends to an unlearned unicast MAC, the relay consumes from that bucket before flooding the frame to the room. Known unicast to a registered client bypasses this limit, and broadcast or multicast traffic continues to use its separate bucket. Keeping the buckets in room state matches the existing forwarding-policy ownership and lets unit tests drive explicit instants. This is the second rate-limit slice from PLAN.md. A total bandwidth cap remains future work. Test Plan: - cargo fmt --check - cargo test -p lanparty-relay - cargo clippy -p lanparty-relay --all-targets -- -D warnings - cargo test --workspace - cargo clippy --workspace --all-targets -- -D warnings - git diff --check Refs: PLAN.md
This commit is contained in:
@@ -80,7 +80,7 @@ Public relay binary and relay-owned room state:
|
|||||||
- stable effective room MTU chosen before Ethernet datagrams flow
|
- stable effective room MTU chosen before Ethernet datagrams flow
|
||||||
- live Ethernet datagram forwarding with no ingress reflection
|
- live Ethernet datagram forwarding with no ingress reflection
|
||||||
- L2 safety filters for jumbo, switch-control, DHCP-server, and IPv6-RA frames
|
- L2 safety filters for jumbo, switch-control, DHCP-server, and IPv6-RA frames
|
||||||
- client broadcast/multicast burst limiting
|
- client broadcast/multicast and unknown-unicast burst limiting
|
||||||
- malformed peer datagram disconnect threshold
|
- malformed peer datagram disconnect threshold
|
||||||
- peer leave cleanup for room membership and MAC indexes
|
- peer leave cleanup for room membership and MAC indexes
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ pub use server::RelayServer;
|
|||||||
pub const DEFAULT_MAX_CLIENTS_PER_ROOM: usize = 16;
|
pub const DEFAULT_MAX_CLIENTS_PER_ROOM: usize = 16;
|
||||||
const CLIENT_MULTICAST_BURST_FRAMES: u32 = 64;
|
const CLIENT_MULTICAST_BURST_FRAMES: u32 = 64;
|
||||||
const CLIENT_MULTICAST_REFILL_FRAMES_PER_SECOND: u32 = 32;
|
const CLIENT_MULTICAST_REFILL_FRAMES_PER_SECOND: u32 = 32;
|
||||||
|
const CLIENT_UNKNOWN_UNICAST_BURST_FRAMES: u32 = 64;
|
||||||
|
const CLIENT_UNKNOWN_UNICAST_REFILL_FRAMES_PER_SECOND: u32 = 32;
|
||||||
const ETHERTYPE_IPV4: u16 = 0x0800;
|
const ETHERTYPE_IPV4: u16 = 0x0800;
|
||||||
const ETHERTYPE_EAPOL: u16 = 0x888e;
|
const ETHERTYPE_EAPOL: u16 = 0x888e;
|
||||||
const ETHERTYPE_SLOW_PROTOCOLS: u16 = 0x8809;
|
const ETHERTYPE_SLOW_PROTOCOLS: u16 = 0x8809;
|
||||||
@@ -286,6 +288,7 @@ struct Room {
|
|||||||
clients: HashMap<u32, PeerInfo>,
|
clients: HashMap<u32, PeerInfo>,
|
||||||
clients_by_mac: HashMap<MacAddr, u32>,
|
clients_by_mac: HashMap<MacAddr, u32>,
|
||||||
client_multicast_limits: HashMap<u32, FrameRateLimit>,
|
client_multicast_limits: HashMap<u32, FrameRateLimit>,
|
||||||
|
client_unknown_unicast_limits: HashMap<u32, FrameRateLimit>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Room {
|
impl Room {
|
||||||
@@ -299,6 +302,7 @@ impl Room {
|
|||||||
clients: HashMap::new(),
|
clients: HashMap::new(),
|
||||||
clients_by_mac: HashMap::new(),
|
clients_by_mac: HashMap::new(),
|
||||||
client_multicast_limits: HashMap::new(),
|
client_multicast_limits: HashMap::new(),
|
||||||
|
client_unknown_unicast_limits: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,7 +339,9 @@ impl Room {
|
|||||||
self.clients_by_mac.insert(mac, peer.peer_id());
|
self.clients_by_mac.insert(mac, peer.peer_id());
|
||||||
self.clients.insert(peer.peer_id(), peer.clone());
|
self.clients.insert(peer.peer_id(), peer.clone());
|
||||||
self.client_multicast_limits
|
self.client_multicast_limits
|
||||||
.insert(peer.peer_id(), FrameRateLimit::new());
|
.insert(peer.peer_id(), client_multicast_limit());
|
||||||
|
self.client_unknown_unicast_limits
|
||||||
|
.insert(peer.peer_id(), client_unknown_unicast_limit());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,6 +430,7 @@ impl Room {
|
|||||||
let mac = peer.mac().expect("client peer info has MAC");
|
let mac = peer.mac().expect("client peer info has MAC");
|
||||||
self.clients_by_mac.remove(&mac);
|
self.clients_by_mac.remove(&mac);
|
||||||
self.client_multicast_limits.remove(&peer.peer_id());
|
self.client_multicast_limits.remove(&peer.peer_id());
|
||||||
|
self.client_unknown_unicast_limits.remove(&peer.peer_id());
|
||||||
|
|
||||||
Ok(peer)
|
Ok(peer)
|
||||||
}
|
}
|
||||||
@@ -471,15 +478,21 @@ impl Room {
|
|||||||
return Ok(ForwardingDecision::rate_limited());
|
return Ok(ForwardingDecision::rate_limited());
|
||||||
}
|
}
|
||||||
|
|
||||||
let targets = if frame.destination().is_multicast() {
|
let destination = frame.destination();
|
||||||
|
let targets = if destination.is_multicast() {
|
||||||
self.all_peer_ids_except(ingress_peer_id)
|
self.all_peer_ids_except(ingress_peer_id)
|
||||||
} else if let Some(client_peer_id) = self.clients_by_mac.get(&frame.destination()) {
|
} else if let Some(client_peer_id) = self.clients_by_mac.get(&destination) {
|
||||||
if *client_peer_id == ingress_peer_id {
|
if *client_peer_id == ingress_peer_id {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
} else {
|
} else {
|
||||||
vec![*client_peer_id]
|
vec![*client_peer_id]
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if ingress_role == Role::Client
|
||||||
|
&& !self.allow_client_unknown_unicast(ingress_peer_id, now)
|
||||||
|
{
|
||||||
|
return Ok(ForwardingDecision::rate_limited());
|
||||||
|
}
|
||||||
self.all_peer_ids_except(ingress_peer_id)
|
self.all_peer_ids_except(ingress_peer_id)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -493,7 +506,14 @@ impl Room {
|
|||||||
fn allow_client_multicast(&mut self, peer_id: u32, now: Instant) -> bool {
|
fn allow_client_multicast(&mut self, peer_id: u32, now: Instant) -> bool {
|
||||||
self.client_multicast_limits
|
self.client_multicast_limits
|
||||||
.entry(peer_id)
|
.entry(peer_id)
|
||||||
.or_insert_with(FrameRateLimit::new)
|
.or_insert_with(client_multicast_limit)
|
||||||
|
.allow(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allow_client_unknown_unicast(&mut self, peer_id: u32, now: Instant) -> bool {
|
||||||
|
self.client_unknown_unicast_limits
|
||||||
|
.entry(peer_id)
|
||||||
|
.or_insert_with(client_unknown_unicast_limit)
|
||||||
.allow(now)
|
.allow(now)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,13 +548,23 @@ impl Room {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct FrameRateLimit {
|
struct FrameRateLimit {
|
||||||
tokens: u32,
|
tokens: u32,
|
||||||
|
capacity: u32,
|
||||||
|
refill_per_second: u32,
|
||||||
last_refill: Instant,
|
last_refill: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FrameRateLimit {
|
impl FrameRateLimit {
|
||||||
fn new() -> Self {
|
fn new(capacity: u32, refill_per_second: u32) -> Self {
|
||||||
|
assert!(capacity > 0, "rate-limit capacity must be nonzero");
|
||||||
|
assert!(
|
||||||
|
refill_per_second > 0,
|
||||||
|
"rate-limit refill rate must be nonzero"
|
||||||
|
);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
tokens: CLIENT_MULTICAST_BURST_FRAMES,
|
tokens: capacity,
|
||||||
|
capacity,
|
||||||
|
refill_per_second,
|
||||||
last_refill: Instant::now(),
|
last_refill: Instant::now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -555,17 +585,27 @@ impl FrameRateLimit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let refill =
|
let refill = elapsed_secs.saturating_mul(u64::from(self.refill_per_second));
|
||||||
elapsed_secs.saturating_mul(u64::from(CLIENT_MULTICAST_REFILL_FRAMES_PER_SECOND));
|
|
||||||
let refill = refill.min(u64::from(u32::MAX)) as u32;
|
let refill = refill.min(u64::from(u32::MAX)) as u32;
|
||||||
self.tokens = self
|
self.tokens = self.tokens.saturating_add(refill).min(self.capacity);
|
||||||
.tokens
|
|
||||||
.saturating_add(refill)
|
|
||||||
.min(CLIENT_MULTICAST_BURST_FRAMES);
|
|
||||||
self.last_refill = now;
|
self.last_refill = now;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn client_multicast_limit() -> FrameRateLimit {
|
||||||
|
FrameRateLimit::new(
|
||||||
|
CLIENT_MULTICAST_BURST_FRAMES,
|
||||||
|
CLIENT_MULTICAST_REFILL_FRAMES_PER_SECOND,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_unknown_unicast_limit() -> FrameRateLimit {
|
||||||
|
FrameRateLimit::new(
|
||||||
|
CLIENT_UNKNOWN_UNICAST_BURST_FRAMES,
|
||||||
|
CLIENT_UNKNOWN_UNICAST_REFILL_FRAMES_PER_SECOND,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn safety_drop_reason(ingress_role: Role, frame: EthernetFrame<'_>) -> Option<DropReason> {
|
fn safety_drop_reason(ingress_role: Role, frame: EthernetFrame<'_>) -> Option<DropReason> {
|
||||||
if frame.is_jumbo() {
|
if frame.is_jumbo() {
|
||||||
return Some(DropReason::JumboFrame);
|
return Some(DropReason::JumboFrame);
|
||||||
@@ -950,6 +990,49 @@ mod tests {
|
|||||||
assert_eq!(decision.action(), FrameAction::Forwarded);
|
assert_eq!(decision.action(), FrameAction::Forwarded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_limits_client_unknown_unicast_after_burst() {
|
||||||
|
let mut registry = RoomRegistry::default();
|
||||||
|
let gateway = registry.join(gateway_hello()).unwrap();
|
||||||
|
let client_one = registry.join(client_hello(1)).unwrap();
|
||||||
|
let client_two = registry.join(client_hello(2)).unwrap();
|
||||||
|
let unknown_unicast = ethernet(physical_mac(), mac(1));
|
||||||
|
let known_unicast = ethernet(mac(2), mac(1));
|
||||||
|
let now = Instant::now();
|
||||||
|
|
||||||
|
for _ in 0..CLIENT_UNKNOWN_UNICAST_BURST_FRAMES {
|
||||||
|
let decision = registry
|
||||||
|
.forward_ethernet_at(&room(), client_one.peer().peer_id(), &unknown_unicast, now)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(decision.action(), FrameAction::Forwarded);
|
||||||
|
assert_eq!(
|
||||||
|
decision.targets(),
|
||||||
|
&[gateway.peer().peer_id(), client_two.peer().peer_id()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let decision = registry
|
||||||
|
.forward_ethernet_at(&room(), client_one.peer().peer_id(), &unknown_unicast, now)
|
||||||
|
.unwrap();
|
||||||
|
assert_rate_limited(&decision);
|
||||||
|
|
||||||
|
let decision = registry
|
||||||
|
.forward_ethernet_at(&room(), client_one.peer().peer_id(), &known_unicast, now)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(decision.action(), FrameAction::Forwarded);
|
||||||
|
assert_eq!(decision.targets(), &[client_two.peer().peer_id()]);
|
||||||
|
|
||||||
|
let decision = registry
|
||||||
|
.forward_ethernet_at(
|
||||||
|
&room(),
|
||||||
|
client_one.peer().peer_id(),
|
||||||
|
&unknown_unicast,
|
||||||
|
now + Duration::from_secs(1),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(decision.action(), FrameAction::Forwarded);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn drops_client_frames_with_forged_source_mac() {
|
fn drops_client_frames_with_forged_source_mac() {
|
||||||
let mut registry = RoomRegistry::default();
|
let mut registry = RoomRegistry::default();
|
||||||
|
|||||||
Reference in New Issue
Block a user