From 587b0516cd2ad9de9300dc24f7d0b56cf583d276 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Thu, 21 May 2026 20:03:13 +0200 Subject: [PATCH] fix(relay): keep unknown unicast on gateway path The relay models the physical LAN as the gateway port, not as another remote client. Client-originated unknown unicast now forwards only to the gateway, and gateway-originated unknown unicast is dropped unless it resolves to a registered remote client. Broadcast and multicast fanout is unchanged. This prevents promiscuous gateway capture of unrelated LAN unicast from being flooded to every remote client. It also keeps client-to-LAN traffic from needlessly leaking to other clients in the room. Test Plan: - cargo fmt --check - cargo test -p lanparty-relay - cargo test --workspace - cargo clippy --workspace --all-targets -- -D warnings - git diff --check Refs: PLAN.md --- README.md | 2 ++ crates/lanparty-relay/src/lib.rs | 38 ++++++++++++++++++++++++++------ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 7afc47d..85f30bc 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,8 @@ self-signed development certificate; `--dev-cert-der-out` writes that certificate so the gateway and client can pin it in development. Production certificate handling remains future work. Ethernet forwarding decisions are logged with room, peer, MAC, ethertype, action, drop reason, and target count. +Unknown unicast from a client is forwarded only to the gateway port; unknown +unicast from the gateway is dropped instead of flooded to every remote client. ## Gateway diff --git a/crates/lanparty-relay/src/lib.rs b/crates/lanparty-relay/src/lib.rs index 069a296..e3fafff 100644 --- a/crates/lanparty-relay/src/lib.rs +++ b/crates/lanparty-relay/src/lib.rs @@ -507,7 +507,10 @@ impl Room { { return Ok(ForwardingDecision::rate_limited()); } - self.all_peer_ids_except(ingress_peer_id) + match ingress_role { + Role::Client => self.gateway_peer_id_except(ingress_peer_id), + Role::Gateway => Vec::new(), + } }; if targets.is_empty() { @@ -564,6 +567,13 @@ impl Room { peer_ids.sort_unstable(); peer_ids } + + fn gateway_peer_id_except(&self, ingress_peer_id: u32) -> Vec { + self.gateway + .as_ref() + .filter(|gateway| gateway.peer_id() != ingress_peer_id) + .map_or_else(Vec::new, |gateway| vec![gateway.peer_id()]) + } } #[derive(Debug, Clone)] @@ -933,18 +943,35 @@ mod tests { fn forwards_unknown_client_unicast_to_gateway() { let mut registry = RoomRegistry::default(); let gateway = registry.join(gateway_hello()).unwrap(); - let client = registry.join(client_hello(1)).unwrap(); + let client_one = registry.join(client_hello(1)).unwrap(); + let client_two = registry.join(client_hello(2)).unwrap(); let frame = ethernet(MacAddr::new([0x00, 1, 2, 3, 4, 5]), mac(1)); let decision = registry - .forward_ethernet(&room(), client.peer().peer_id(), &frame) + .forward_ethernet(&room(), client_one.peer().peer_id(), &frame) .unwrap(); assert_eq!(decision.action(), FrameAction::Forwarded); assert_eq!(decision.targets(), &[gateway.peer().peer_id()]); + assert!(!decision.targets().contains(&client_two.peer().peer_id())); assert_eq!(decision.drop_reason(), None); } + #[test] + fn drops_gateway_unicast_to_unknown_remote_mac() { + let mut registry = RoomRegistry::default(); + let gateway = registry.join(gateway_hello()).unwrap(); + registry.join(client_hello(1)).unwrap(); + registry.join(client_hello(2)).unwrap(); + let frame = ethernet(physical_mac(), MacAddr::new([0x00, 1, 2, 3, 4, 5])); + + let decision = registry + .forward_ethernet(&room(), gateway.peer().peer_id(), &frame) + .unwrap(); + + assert_dropped(&decision, DropReason::UnknownDestination); + } + #[test] fn forwards_gateway_unicast_to_matching_client() { let mut registry = RoomRegistry::default(); @@ -1032,10 +1059,7 @@ mod tests { .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()] - ); + assert_eq!(decision.targets(), &[gateway.peer().peer_id()]); } let decision = registry