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
This commit is contained in:
2026-05-22 07:47:14 +02:00
parent fa9265ff51
commit 6bf23fff19
4 changed files with 158 additions and 37 deletions
+4 -3
View File
@@ -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 invalid-source frames, jumbo frames, and over-TAP-MTU frames stay out of the
Windows adapter even if they reached the client over QUIC. Windows adapter even if they reached the client over QUIC.
Misdirected unicast frames not addressed to the client's virtual MAC are also 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 counted, skipped, and logged with the drop reason; accepted TAP-to-relay and
with direction, peer id, MACs, ethertype/length, frame length, action, and drop relay-to-TAP frames are logged with direction, peer id, MACs, ethertype/length,
reason. TAP device read/write errors still stop the bridge. 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 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 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 and from the initial welcome, so later leave logs can identify a disconnected
+6
View File
@@ -289,8 +289,14 @@ Broadcast sent toward LAN; waiting for LAN broadcast reply
LAN broadcast received LAN broadcast received
client frame direction=TapToRelay ... action=Forwarded drop_reason=- client frame direction=TapToRelay ... action=Forwarded drop_reason=-
client frame direction=RelayToTap ... 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: Drops that can be normal during testing:
```text ```text
+98 -9
View File
@@ -232,6 +232,19 @@ pub struct ReceivedEthernetFrame {
payload: Bytes, 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 { impl ReceivedEthernetFrame {
#[must_use] #[must_use]
pub const fn source_peer_id(&self) -> u32 { 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClientSendOutcome { pub enum ClientSendOutcome {
Sent, Sent,
@@ -295,6 +325,10 @@ impl ClientSession {
self.relay_io().recv_ethernet().await self.relay_io().recv_ethernet().await
} }
pub async fn recv_ethernet_outcome(&self) -> Result<ClientReceiveOutcome> {
self.relay_io().recv_ethernet_outcome().await
}
pub async fn recv_control_event(&self) -> Result<ControlMessage> { pub async fn recv_control_event(&self) -> Result<ControlMessage> {
recv_control_event(&self.connection).await recv_control_event(&self.connection).await
} }
@@ -429,6 +463,15 @@ impl ClientRelayIo {
} }
pub async fn recv_ethernet(&self) -> Result<ReceivedEthernetFrame> { pub async fn recv_ethernet(&self) -> Result<ReceivedEthernetFrame> {
loop {
match self.recv_ethernet_outcome().await? {
ClientReceiveOutcome::Accepted(frame) => return Ok(frame),
ClientReceiveOutcome::Filtered(_) => {}
}
}
}
pub async fn recv_ethernet_outcome(&self) -> Result<ClientReceiveOutcome> {
loop { loop {
let datagram = self.connection.read_datagram().await?; let datagram = self.connection.read_datagram().await?;
self.stats.record_datagram_rx(); self.stats.record_datagram_rx();
@@ -453,26 +496,38 @@ impl ClientRelayIo {
}; };
self.stats.record_ethernet_rx(ethernet_frame); 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(); 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( if ethernet_frame_exceeds_tap_mtu(
ethernet_frame, ethernet_frame,
usize::from(self.welcome.effective_tap_mtu()), usize::from(self.welcome.effective_tap_mtu()),
) { ) {
self.stats.record_dropped_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::TapMtuExceeded,
}));
} }
if !is_accepted_relay_destination(ethernet_frame, self.virtual_mac) { if !is_accepted_relay_destination(ethernet_frame, self.virtual_mac) {
self.stats.record_dropped_frame(); self.stats.record_dropped_frame();
continue; return Ok(ClientReceiveOutcome::Filtered(FilteredRelayEthernetFrame {
}
return Ok(ReceivedEthernetFrame {
source_peer_id: header.peer_id(), source_peer_id: header.peer_id(),
payload: Bytes::copy_from_slice(packet.payload()), payload: Bytes::copy_from_slice(packet.payload()),
}); drop_reason: DropReason::UnknownDestination,
}));
}
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(), .unwrap(),
ClientSendOutcome::Sent ClientSendOutcome::Sent
); );
let received = tokio::time::timeout(Duration::from_secs(5), relay_io.recv_ethernet()) let filtered =
tokio::time::timeout(Duration::from_secs(5), relay_io.recv_ethernet_outcome())
.await .await
.unwrap() .unwrap()
.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.source_peer_id(), 1);
assert_eq!( assert_eq!(
received.payload(), received.payload(),
+29 -4
View File
@@ -8,12 +8,12 @@ use std::{sync::mpsc, thread, time::Duration};
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use clap::Parser; use clap::Parser;
#[cfg(windows)]
use lanparty_client_core::ClientRelayIo;
use lanparty_client_core::{ use lanparty_client_core::{
ClientIdentity, ClientIdentityStore, ClientSession, ClientSessionConfig, connect_client, ClientIdentity, ClientIdentityStore, ClientSession, ClientSessionConfig, connect_client,
}; };
#[cfg(windows)] #[cfg(windows)]
use lanparty_client_core::{ClientReceiveOutcome, ClientRelayIo};
#[cfg(windows)]
use lanparty_client_route::{ use lanparty_client_route::{
IpInterfaceFamily, NetworkInterfaceIdentity, PinnedRelayRoute, RouteSnapshot, IpInterfaceFamily, NetworkInterfaceIdentity, PinnedRelayRoute, RouteSnapshot,
ScopedDefaultRoutes, ScopedInterfaceMetric, ScopedInterfaceMtu, ScopedDefaultRoutes, ScopedInterfaceMetric, ScopedInterfaceMtu,
@@ -1115,8 +1115,9 @@ async fn run_tap_frame_pump(relay_io: ClientRelayIo, tap: Arc<TapAdapter>) -> Re
.context("TAP reader thread stopped without reporting an error")?; .context("TAP reader thread stopped without reporting an error")?;
return Err::<(), _>(error).context("TAP reader thread stopped"); return Err::<(), _>(error).context("TAP reader thread stopped");
} }
relay_frame = relay_io.recv_ethernet() => { relay_frame = relay_io.recv_ethernet_outcome() => {
let relay_frame = relay_frame.context("failed to receive relay Ethernet frame")?; match relay_frame.context("failed to receive relay Ethernet frame")? {
ClientReceiveOutcome::Accepted(relay_frame) => {
let source_peer_id = relay_frame.source_peer_id(); let source_peer_id = relay_frame.source_peer_id();
let tap = Arc::clone(&tap); let tap = Arc::clone(&tap);
let payload = relay_frame.payload().to_vec(); let payload = relay_frame.payload().to_vec();
@@ -1138,6 +1139,20 @@ async fn run_tap_frame_pump(relay_io: ClientRelayIo, tap: Arc<TapAdapter>) -> Re
) )
); );
} }
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" "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] #[test]