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:
2026-05-21 19:50:12 +02:00
parent c87033f74c
commit 5b12108e83
2 changed files with 96 additions and 13 deletions
+1 -1
View File
@@ -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
+95 -12
View File
@@ -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<u32, PeerInfo>,
clients_by_mac: HashMap<MacAddr, u32>,
client_multicast_limits: HashMap<u32, FrameRateLimit>,
client_unknown_unicast_limits: HashMap<u32, FrameRateLimit>,
}
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<DropReason> {
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();