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
+99 -10
View File
@@ -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<ClientReceiveOutcome> {
self.relay_io().recv_ethernet_outcome().await
}
pub async fn recv_control_event(&self) -> Result<ControlMessage> {
recv_control_event(&self.connection).await
}
@@ -429,6 +463,15 @@ impl ClientRelayIo {
}
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 {
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(),
+49 -24
View File
@@ -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<TapAdapter>) -> 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]