diff --git a/README.md b/README.md index 16cccdf..c44a505 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ Public relay binary and relay-owned room state: - stable effective room MTU chosen before Ethernet datagrams flow - live Ethernet datagram forwarding with no ingress reflection - 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 - peer leave cleanup for room membership and MAC indexes diff --git a/crates/lanparty-relay/src/lib.rs b/crates/lanparty-relay/src/lib.rs index 1e2bf39..9dacc54 100644 --- a/crates/lanparty-relay/src/lib.rs +++ b/crates/lanparty-relay/src/lib.rs @@ -22,6 +22,8 @@ pub use server::RelayServer; pub const DEFAULT_MAX_CLIENTS_PER_ROOM: usize = 16; const CLIENT_MULTICAST_BURST_FRAMES: u32 = 64; 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_EAPOL: u16 = 0x888e; const ETHERTYPE_SLOW_PROTOCOLS: u16 = 0x8809; @@ -286,6 +288,7 @@ struct Room { clients: HashMap, clients_by_mac: HashMap, client_multicast_limits: HashMap, + client_unknown_unicast_limits: HashMap, } impl Room { @@ -299,6 +302,7 @@ impl Room { clients: HashMap::new(), clients_by_mac: 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.insert(peer.peer_id(), peer.clone()); 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"); self.clients_by_mac.remove(&mac); self.client_multicast_limits.remove(&peer.peer_id()); + self.client_unknown_unicast_limits.remove(&peer.peer_id()); Ok(peer) } @@ -471,15 +478,21 @@ impl Room { 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) - } 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 { Vec::new() } else { vec![*client_peer_id] } } 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) }; @@ -493,7 +506,14 @@ impl Room { fn allow_client_multicast(&mut self, peer_id: u32, now: Instant) -> bool { self.client_multicast_limits .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) } @@ -528,13 +548,23 @@ impl Room { #[derive(Debug, Clone)] struct FrameRateLimit { tokens: u32, + capacity: u32, + refill_per_second: u32, last_refill: Instant, } 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 { - tokens: CLIENT_MULTICAST_BURST_FRAMES, + tokens: capacity, + capacity, + refill_per_second, last_refill: Instant::now(), } } @@ -555,17 +585,27 @@ impl FrameRateLimit { return; } - let refill = - elapsed_secs.saturating_mul(u64::from(CLIENT_MULTICAST_REFILL_FRAMES_PER_SECOND)); + let refill = elapsed_secs.saturating_mul(u64::from(self.refill_per_second)); let refill = refill.min(u64::from(u32::MAX)) as u32; - self.tokens = self - .tokens - .saturating_add(refill) - .min(CLIENT_MULTICAST_BURST_FRAMES); + self.tokens = self.tokens.saturating_add(refill).min(self.capacity); 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 { if frame.is_jumbo() { return Some(DropReason::JumboFrame); @@ -950,6 +990,49 @@ mod tests { 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] fn drops_client_frames_with_forged_source_mac() { let mut registry = RoomRegistry::default();