From f4ee5b4d2cccde2b0d8be48f7234e07eb376873f Mon Sep 17 00:00:00 2001 From: ddidderr Date: Fri, 22 May 2026 06:18:37 +0200 Subject: [PATCH] fix(gateway): drop unrelated LAN unicast locally The gateway runs in promiscuous mode so it can capture frames for remote client MACs, but that also means it sees unrelated unicast traffic between physical LAN machines. Before this change those frames were sent to the public relay and only then discarded as unknown destinations. Use the remote-client table seeded by relay lifecycle events to decide whether a LAN frame should leave the gateway. Broadcast and multicast traffic still flows to the relay, and unicast to a connected remote client still flows to the relay. Unicast to any other destination is counted and logged locally as UnknownDestination. This keeps busy LAN traffic out of the relay data path and makes the gateway behavior match the MVP switching model: LAN frames go to matching remote clients, while broadcast and multicast fan out. README.md documents the local filter, and TESTING.md explains why LanToRemote UnknownDestination can be normal on a busy LAN. Test Plan: - cargo test -p lanparty-gateway - cargo test --workspace - cargo clippy --workspace --all-targets -- -D warnings - cargo fmt --check - git diff --check Refs: PLAN.md switching model; TESTING.md MVP log signals --- README.md | 11 +++-- TESTING.md | 4 ++ crates/lanparty-gateway/src/lib.rs | 74 ++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 90d33b8..1791d89 100644 --- a/README.md +++ b/README.md @@ -201,10 +201,13 @@ frame logs include direction, peer id when present, MACs, ethertype/length, frame length, action, and drop reason. The gateway also tracks frame/datagram counters and periodically sends stats snapshots to the relay. Malformed or runt LAN frames are counted and logged as dropped instead of disappearing before -accounting. Relay lifecycle events seed and retire remote-client MACs for CAM -refresh even before that client sends traffic. On shutdown, the gateway sends a -best-effort disconnect control message before closing QUIC so the relay can -report the intended reason. +accounting. It drops unrelated LAN unicast locally once the destination is known +not to be a connected remote client, so busy LAN traffic is not sent to the +public relay just to be discarded there. Relay lifecycle events seed and retire +remote-client MACs for CAM refresh and LAN-destination filtering even before +that client sends traffic. On shutdown, the gateway sends a best-effort +disconnect control message before closing QUIC so the relay can report the +intended reason. ## Windows Client diff --git a/TESTING.md b/TESTING.md index 39adfd1..70e8ad7 100644 --- a/TESTING.md +++ b/TESTING.md @@ -208,6 +208,10 @@ drop_reason=DatagramBudget drop_reason=RateLimit ``` +On gateway `LanToRemote` logs, `UnknownDestination` usually means the gateway +captured unrelated LAN unicast and dropped it locally instead of sending it to +the relay. + Drops that should be investigated if they dominate: ```text diff --git a/crates/lanparty-gateway/src/lib.rs b/crates/lanparty-gateway/src/lib.rs index 8201899..1cb8a59 100644 --- a/crates/lanparty-gateway/src/lib.rs +++ b/crates/lanparty-gateway/src/lib.rs @@ -348,6 +348,21 @@ impl GatewayConnection { ); continue; } + if let Some(drop_reason) = remote_clients.lan_frame_drop_reason(&lan_frame)? { + stats.record_dropped_frame(); + println!( + "{}", + gateway_frame_log_line( + packet_socket.get_ref().interface(), + FrameDirection::LanToRemote, + Some(welcome.peer_id()), + &lan_frame, + FrameAction::Dropped, + Some(drop_reason), + ) + ); + continue; + } let outcome = send_gateway_ethernet( &connection, @@ -877,6 +892,17 @@ impl RemoteClientTable { Ok(None) } + fn lan_frame_drop_reason(&self, frame: &[u8]) -> Result> { + let frame = EthernetFrame::parse(frame).context("LAN Ethernet frame is malformed")?; + let destination = frame.destination(); + let targets_remote_client = self.remote_clients.values().any(|mac| *mac == destination); + if destination.is_multicast() || targets_remote_client { + return Ok(None); + } + + Ok(Some(DropReason::UnknownDestination)) + } + fn observe_control_event(&mut self, event: &ControlMessage) -> Option { match event { ControlMessage::PeerJoined(peer) => self.observe_peer_joined(peer), @@ -1327,6 +1353,45 @@ mod tests { ); } + #[cfg(target_os = "linux")] + #[test] + fn filters_unrelated_lan_unicast_before_relay_send() { + let gateway_mac = MacAddr::new([0x0a, 0, 0, 0, 0, 1]); + let remote_mac = MacAddr::new([0x02, 0, 0, 0, 0, 2]); + let lan_mac = MacAddr::new([0x0a, 0, 0, 0, 0, 3]); + let mut remote_clients = RemoteClientTable::new(gateway_mac); + + assert_eq!( + remote_clients + .lan_frame_drop_reason(&broadcast_ethernet_frame(b"lan broadcast")) + .unwrap(), + None + ); + assert_eq!( + remote_clients + .lan_frame_drop_reason(ðernet_frame_to(remote_mac, b"before joined")) + .unwrap(), + Some(DropReason::UnknownDestination) + ); + + remote_clients.observe_control_event(&ControlMessage::PeerJoined( + PeerInfo::new(7, Role::Client, Some(remote_mac)).unwrap(), + )); + + assert_eq!( + remote_clients + .lan_frame_drop_reason(ðernet_frame_to(remote_mac, b"remote unicast")) + .unwrap(), + None + ); + assert_eq!( + remote_clients + .lan_frame_drop_reason(ðernet_frame_to(lan_mac, b"lan host unicast")) + .unwrap(), + Some(DropReason::UnknownDestination) + ); + } + #[cfg(target_os = "linux")] #[test] fn updates_cam_refresh_from_lifecycle_events() { @@ -1462,6 +1527,15 @@ mod tests { ethernet_frame_with_addresses(MacAddr::new([0x02, 0, 0, 0, 0, 2]), source, 0x0800, payload) } + fn ethernet_frame_to(destination: MacAddr, payload: &[u8]) -> Vec { + ethernet_frame_with_addresses( + destination, + MacAddr::new([0x0a, 0, 0, 0, 0, 1]), + 0x0800, + payload, + ) + } + fn control_plane_ethernet_frame() -> Vec { ethernet_frame_with_addresses( MacAddr::new([0x01, 0x80, 0xc2, 0, 0, 0]),