From 6bf23fff1903c9b2b8b201fd5fa90a6a7ea6ea63 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Fri, 22 May 2026 07:47:14 +0200 Subject: [PATCH] feat(client): log filtered relay-to-TAP frames Windows MVP debugging needs more than aggregate drop counters when LAN traffic reaches the client but is kept out of the TAP adapter. A DHCP or discovery failure is much easier to diagnose when the client log says which relayed frame was filtered and why. Expose a client receive outcome that preserves the existing accepted-frame API while allowing the Windows frame pump to log filtered RelayToTap frames with the source peer and drop reason. Document the new log signal in the README and manual MVP test guide. Test Plan: - cargo fmt --check - cargo test -p lanparty-client-core connects_to_relay_control_stream_as_client - cargo test -p lanparty-client-win formats_client_frame_log_lines - cargo test -p lanparty-client-core - cargo test -p lanparty-client-win - cargo test --workspace - cargo clippy -p lanparty-client-core --all-targets -- -D warnings - cargo clippy -p lanparty-client-win --all-targets -- -D warnings - cargo clippy --workspace --all-targets -- -D warnings - git diff --check - git diff --cached --check Windows-target check attempted: - cargo check -p lanparty-client-win --target x86_64-pc-windows-msvc The Windows-target check is still blocked on this Linux host before compiling lanparty-client-win because ring cannot find the MSVC lib.exe tool. Refs: MVP client diagnostics --- README.md | 7 +- TESTING.md | 6 ++ crates/lanparty-client-core/src/lib.rs | 109 ++++++++++++++++++++++--- crates/lanparty-client-win/src/main.rs | 73 +++++++++++------ 4 files changed, 158 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 1f64a16..2a87768 100644 --- a/README.md +++ b/README.md @@ -285,9 +285,10 @@ also safety-checked before TAP writes, so switch-control traffic, invalid-source frames, jumbo frames, and over-TAP-MTU frames stay out of the Windows adapter even if they reached the client over QUIC. Misdirected unicast frames not addressed to the client's virtual MAC are also -counted and skipped; accepted TAP-to-relay and relay-to-TAP frames are logged -with direction, peer id, MACs, ethertype/length, frame length, action, and drop -reason. TAP device read/write errors still stop the bridge. +counted, skipped, and logged with the drop reason; accepted TAP-to-relay and +relay-to-TAP frames are logged with direction, peer id, MACs, ethertype/length, +frame length, action, and drop reason. TAP device read/write errors still stop +the bridge. Relay lifecycle events are logged as they arrive, including gateway joins and peer leaves. The client remembers peer identities from join and catch-up events and from the initial welcome, so later leave logs can identify a disconnected diff --git a/TESTING.md b/TESTING.md index 613e89d..f8a99b8 100644 --- a/TESTING.md +++ b/TESTING.md @@ -289,8 +289,14 @@ Broadcast sent toward LAN; waiting for LAN broadcast reply LAN broadcast received client frame direction=TapToRelay ... action=Forwarded drop_reason=- client frame direction=RelayToTap ... action=Forwarded drop_reason=- +client frame direction=RelayToTap ... action=Filtered drop_reason=UnknownDestination ``` +A filtered `RelayToTap` line means the client received a relay frame but kept it +out of the TAP adapter. Occasional unrelated unicast can be normal; repeated +filtered DHCP, broadcast, or LAN-game traffic is worth investigating with the +drop reason. + Drops that can be normal during testing: ```text diff --git a/crates/lanparty-client-core/src/lib.rs b/crates/lanparty-client-core/src/lib.rs index 9b4a0a2..edde7e1 100644 --- a/crates/lanparty-client-core/src/lib.rs +++ b/crates/lanparty-client-core/src/lib.rs @@ -232,6 +232,19 @@ pub struct ReceivedEthernetFrame { payload: Bytes, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FilteredRelayEthernetFrame { + source_peer_id: u32, + payload: Bytes, + drop_reason: DropReason, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClientReceiveOutcome { + Accepted(ReceivedEthernetFrame), + Filtered(FilteredRelayEthernetFrame), +} + impl ReceivedEthernetFrame { #[must_use] pub const fn source_peer_id(&self) -> u32 { @@ -244,6 +257,23 @@ impl ReceivedEthernetFrame { } } +impl FilteredRelayEthernetFrame { + #[must_use] + pub const fn source_peer_id(&self) -> u32 { + self.source_peer_id + } + + #[must_use] + pub fn payload(&self) -> &[u8] { + &self.payload + } + + #[must_use] + pub const fn drop_reason(&self) -> DropReason { + self.drop_reason + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ClientSendOutcome { Sent, @@ -295,6 +325,10 @@ impl ClientSession { self.relay_io().recv_ethernet().await } + pub async fn recv_ethernet_outcome(&self) -> Result { + self.relay_io().recv_ethernet_outcome().await + } + pub async fn recv_control_event(&self) -> Result { recv_control_event(&self.connection).await } @@ -429,6 +463,15 @@ impl ClientRelayIo { } pub async fn recv_ethernet(&self) -> Result { + loop { + match self.recv_ethernet_outcome().await? { + ClientReceiveOutcome::Accepted(frame) => return Ok(frame), + ClientReceiveOutcome::Filtered(_) => {} + } + } + } + + pub async fn recv_ethernet_outcome(&self) -> Result { loop { let datagram = self.connection.read_datagram().await?; self.stats.record_datagram_rx(); @@ -453,26 +496,38 @@ impl ClientRelayIo { }; self.stats.record_ethernet_rx(ethernet_frame); - if gateway_lan_safety_drop_reason(ethernet_frame).is_some() { + if let Some(drop_reason) = gateway_lan_safety_drop_reason(ethernet_frame) { self.stats.record_dropped_frame(); - continue; + return Ok(ClientReceiveOutcome::Filtered(FilteredRelayEthernetFrame { + source_peer_id: header.peer_id(), + payload: Bytes::copy_from_slice(packet.payload()), + drop_reason: DropReason::from(drop_reason), + })); } if ethernet_frame_exceeds_tap_mtu( ethernet_frame, usize::from(self.welcome.effective_tap_mtu()), ) { self.stats.record_dropped_frame(); - continue; + return Ok(ClientReceiveOutcome::Filtered(FilteredRelayEthernetFrame { + source_peer_id: header.peer_id(), + payload: Bytes::copy_from_slice(packet.payload()), + drop_reason: DropReason::TapMtuExceeded, + })); } if !is_accepted_relay_destination(ethernet_frame, self.virtual_mac) { self.stats.record_dropped_frame(); - continue; + return Ok(ClientReceiveOutcome::Filtered(FilteredRelayEthernetFrame { + source_peer_id: header.peer_id(), + payload: Bytes::copy_from_slice(packet.payload()), + drop_reason: DropReason::UnknownDestination, + })); } - return Ok(ReceivedEthernetFrame { + return Ok(ClientReceiveOutcome::Accepted(ReceivedEthernetFrame { source_peer_id: header.peer_id(), payload: Bytes::copy_from_slice(packet.payload()), - }); + })); } } @@ -943,10 +998,44 @@ mod tests { .unwrap(), ClientSendOutcome::Sent ); - let received = tokio::time::timeout(Duration::from_secs(5), relay_io.recv_ethernet()) - .await - .unwrap() - .unwrap(); + let filtered = + tokio::time::timeout(Duration::from_secs(5), relay_io.recv_ethernet_outcome()) + .await + .unwrap() + .unwrap(); + let ClientReceiveOutcome::Filtered(filtered) = filtered else { + panic!("expected filtered relay frame"); + }; + assert_eq!(filtered.source_peer_id(), 1); + assert_eq!(filtered.drop_reason(), DropReason::ControlPlaneEtherType); + assert_eq!( + filtered.payload(), + control_plane_ethernet_frame().as_slice() + ); + + let filtered = + tokio::time::timeout(Duration::from_secs(5), relay_io.recv_ethernet_outcome()) + .await + .unwrap() + .unwrap(); + let ClientReceiveOutcome::Filtered(filtered) = filtered else { + panic!("expected filtered misdirected relay frame"); + }; + assert_eq!(filtered.source_peer_id(), 1); + assert_eq!(filtered.drop_reason(), DropReason::UnknownDestination); + assert_eq!( + filtered.payload(), + misdirected_unicast_ethernet_frame().as_slice() + ); + + let received = + tokio::time::timeout(Duration::from_secs(5), relay_io.recv_ethernet_outcome()) + .await + .unwrap() + .unwrap(); + let ClientReceiveOutcome::Accepted(received) = received else { + panic!("expected accepted relay frame"); + }; assert_eq!(received.source_peer_id(), 1); assert_eq!( received.payload(), diff --git a/crates/lanparty-client-win/src/main.rs b/crates/lanparty-client-win/src/main.rs index f7da10e..9bb8680 100644 --- a/crates/lanparty-client-win/src/main.rs +++ b/crates/lanparty-client-win/src/main.rs @@ -8,12 +8,12 @@ use std::{sync::mpsc, thread, time::Duration}; use anyhow::{Context, Result, bail}; use clap::Parser; -#[cfg(windows)] -use lanparty_client_core::ClientRelayIo; use lanparty_client_core::{ ClientIdentity, ClientIdentityStore, ClientSession, ClientSessionConfig, connect_client, }; #[cfg(windows)] +use lanparty_client_core::{ClientReceiveOutcome, ClientRelayIo}; +#[cfg(windows)] use lanparty_client_route::{ IpInterfaceFamily, NetworkInterfaceIdentity, PinnedRelayRoute, RouteSnapshot, ScopedDefaultRoutes, ScopedInterfaceMetric, ScopedInterfaceMtu, @@ -1115,28 +1115,43 @@ async fn run_tap_frame_pump(relay_io: ClientRelayIo, tap: Arc) -> Re .context("TAP reader thread stopped without reporting an error")?; return Err::<(), _>(error).context("TAP reader thread stopped"); } - relay_frame = relay_io.recv_ethernet() => { - let relay_frame = relay_frame.context("failed to receive relay Ethernet frame")?; - let source_peer_id = relay_frame.source_peer_id(); - let tap = Arc::clone(&tap); - let payload = relay_frame.payload().to_vec(); - let log_payload = payload.clone(); - tokio::task::spawn_blocking(move || { - tap.write_ethernet_frame(&payload) - .context("failed to write relay Ethernet frame to TAP") - }) - .await - .context("TAP writer task panicked")??; - println!( - "{}", - client_frame_log_line( - FrameDirection::RelayToTap, - Some(source_peer_id), - &log_payload, - FrameAction::Forwarded, - None, - ) - ); + relay_frame = relay_io.recv_ethernet_outcome() => { + match relay_frame.context("failed to receive relay Ethernet frame")? { + ClientReceiveOutcome::Accepted(relay_frame) => { + let source_peer_id = relay_frame.source_peer_id(); + let tap = Arc::clone(&tap); + let payload = relay_frame.payload().to_vec(); + let log_payload = payload.clone(); + tokio::task::spawn_blocking(move || { + tap.write_ethernet_frame(&payload) + .context("failed to write relay Ethernet frame to TAP") + }) + .await + .context("TAP writer task panicked")??; + println!( + "{}", + client_frame_log_line( + FrameDirection::RelayToTap, + Some(source_peer_id), + &log_payload, + FrameAction::Forwarded, + None, + ) + ); + } + ClientReceiveOutcome::Filtered(relay_frame) => { + eprintln!( + "{}", + client_frame_log_line( + FrameDirection::RelayToTap, + Some(relay_frame.source_peer_id()), + relay_frame.payload(), + FrameAction::Filtered, + Some(relay_frame.drop_reason()), + ) + ); + } + } } } } @@ -1300,6 +1315,16 @@ mod tests { ), "client frame direction=TapToRelay peer_id=2 src=- dst=- ethertype_or_len=- len=4 action=Dropped drop_reason=Malformed" ); + assert_eq!( + client_frame_log_line( + FrameDirection::RelayToTap, + Some(1), + &frame, + FrameAction::Filtered, + Some(DropReason::UnknownDestination), + ), + "client frame direction=RelayToTap peer_id=1 src=02:00:00:00:00:01 dst=02:00:00:00:00:02 ethertype_or_len=0x0800 len=21 action=Filtered drop_reason=UnknownDestination" + ); } #[test]