diff --git a/README.md b/README.md index 7cf6cb1..23fb6ad 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,8 @@ Platform-neutral remote client relay session: - welcome/reject handling with assigned peer id and effective TAP MTU - QUIC DATAGRAM support and negotiated datagram budget diagnostics - reliable relay control-event reads for peer lifecycle messages -- Ethernet frame send/receive helpers over QUIC DATAGRAM with budget checks +- Ethernet frame send/receive helpers over QUIC DATAGRAM with budget checks and + local drop outcomes for malformed or oversized sends - client tunnel statistics for frame/datagram rx/tx and drops - reliable client stats snapshot sends for relay diagnostics - best-effort graceful disconnect messages before QUIC close @@ -223,7 +224,9 @@ diagnostics refresh the TAP unicast IP so DHCP results that arrive after bridging starts become visible in later status lines. Each snapshot also emits short user-facing lines such as relay/gateway connection status, relay-route and TAP readiness warnings, DHCP address presence, and broadcast-flow -confirmation when those signals are observed. +confirmation when those signals are observed. Malformed TAP frames, jumbo +frames, and TAP frames whose encoded datagrams exceed the negotiated QUIC budget +are counted and dropped before relay send without stopping 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 so later leave logs can identify a disconnected LAN gateway or client MAC when diff --git a/crates/lanparty-client-core/src/lib.rs b/crates/lanparty-client-core/src/lib.rs index 78fb9d4..a43ac21 100644 --- a/crates/lanparty-client-core/src/lib.rs +++ b/crates/lanparty-client-core/src/lib.rs @@ -25,7 +25,7 @@ use lanparty_ctrl::{ MAX_CONTROL_MESSAGE_LEN, RELAY_ALPN, RoomCode, ServerWelcome, decode_control_frame, encode_control_message, }; -use lanparty_obs::{QuicDiagnostics, TunnelStats}; +use lanparty_obs::{DropReason, QuicDiagnostics, TunnelStats}; use lanparty_proto::{ EthernetFrame, FrameType, MacAddr, decode_datagram, encode_datagram, validate_datagram_budget, }; @@ -242,6 +242,12 @@ impl ReceivedEthernetFrame { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClientSendOutcome { + Sent, + Dropped(DropReason), +} + impl ClientSession { #[must_use] pub const fn config(&self) -> &ClientSessionConfig { @@ -277,6 +283,10 @@ impl ClientSession { self.relay_io().send_ethernet(frame) } + pub fn send_ethernet_with_outcome(&self, frame: &[u8]) -> Result { + self.relay_io().send_ethernet_with_outcome(frame) + } + pub async fn recv_ethernet(&self) -> Result { self.relay_io().recv_ethernet().await } @@ -339,13 +349,33 @@ impl ClientRelayIo { } pub fn send_ethernet(&self, frame: &[u8]) -> Result<()> { + match self.send_ethernet_with_outcome(frame)? { + ClientSendOutcome::Sent => Ok(()), + ClientSendOutcome::Dropped(DropReason::DatagramBudget) => { + bail!("client Ethernet datagram exceeds negotiated QUIC budget") + } + ClientSendOutcome::Dropped(DropReason::Malformed) => { + bail!("client Ethernet frame is malformed") + } + ClientSendOutcome::Dropped(drop_reason) => { + bail!("client Ethernet frame was dropped: {drop_reason:?}") + } + } + } + + pub fn send_ethernet_with_outcome(&self, frame: &[u8]) -> Result { let ethernet_frame = match EthernetFrame::parse(frame) { Ok(frame) => frame, - Err(error) => { + Err(_) => { self.stats.record_malformed_frame(); - return Err(error).context("client Ethernet frame is malformed"); + return Ok(ClientSendOutcome::Dropped(DropReason::Malformed)); } }; + if ethernet_frame.is_jumbo() { + self.stats.record_dropped_frame(); + return Ok(ClientSendOutcome::Dropped(DropReason::JumboFrame)); + } + let datagram = encode_datagram( FrameType::Ethernet, self.welcome.room_id(), @@ -354,11 +384,11 @@ impl ClientRelayIo { frame, ) .context("failed to encode client Ethernet datagram")?; - if let Err(error) = - validate_datagram_budget(datagram.len(), usize::from(self.quic_max_datagram_size)) + if validate_datagram_budget(datagram.len(), usize::from(self.quic_max_datagram_size)) + .is_err() { self.stats.record_dropped_frame(); - return Err(error).context("client Ethernet datagram exceeds negotiated QUIC budget"); + return Ok(ClientSendOutcome::Dropped(DropReason::DatagramBudget)); } self.connection @@ -366,7 +396,7 @@ impl ClientRelayIo { .context("failed to send client Ethernet datagram")?; self.stats.record_ethernet_tx(ethernet_frame); - Ok(()) + Ok(ClientSendOutcome::Sent) } pub async fn recv_ethernet(&self) -> Result { @@ -770,7 +800,7 @@ mod tests { let ControlMessage::Stats(stats) = stats_message else { panic!("expected client stats event"); }; - assert_eq!(stats, TunnelStats::new(1, 1, 1, 1, 1, 1)); + assert_eq!(stats, TunnelStats::new(1, 1, 1, 1, 2, 1)); stats_received_tx.send(()).unwrap(); let mut disconnect_recv = connection.accept_uni().await.unwrap(); @@ -821,9 +851,12 @@ mod tests { client.quic_max_datagram_size() ); - relay_io - .send_ethernet(ðernet_frame(b"to relay")) - .unwrap(); + assert_eq!( + relay_io + .send_ethernet_with_outcome(ðernet_frame(b"to relay")) + .unwrap(), + ClientSendOutcome::Sent + ); let received = tokio::time::timeout(Duration::from_secs(5), relay_io.recv_ethernet()) .await .unwrap() @@ -841,7 +874,17 @@ mod tests { assert_eq!(peer.peer_id(), 1); assert_eq!(peer.role(), Role::Gateway); - assert!(relay_io.send_ethernet(&[0; 4]).is_err()); + let oversized_payload = vec![0; usize::from(relay_io.quic_max_datagram_size())]; + assert_eq!( + relay_io + .send_ethernet_with_outcome(ðernet_frame(&oversized_payload)) + .unwrap(), + ClientSendOutcome::Dropped(DropReason::DatagramBudget) + ); + assert_eq!( + relay_io.send_ethernet_with_outcome(&[0; 4]).unwrap(), + ClientSendOutcome::Dropped(DropReason::Malformed) + ); let stats = relay_io.stats_snapshot(); assert_eq!(stats.ethernet_frames_tx(), 1); assert_eq!(stats.ethernet_frames_rx(), 1); @@ -849,7 +892,7 @@ mod tests { assert_eq!(stats.broadcast_frames_rx(), 0); assert_eq!(stats.datagrams_tx(), 1); assert_eq!(stats.datagrams_rx(), 1); - assert_eq!(stats.dropped_frames(), 1); + assert_eq!(stats.dropped_frames(), 2); assert_eq!(stats.malformed_frames(), 1); assert_eq!(client.stats_snapshot(), stats); diff --git a/crates/lanparty-client-win/src/main.rs b/crates/lanparty-client-win/src/main.rs index cec5a39..e3145b2 100644 --- a/crates/lanparty-client-win/src/main.rs +++ b/crates/lanparty-client-win/src/main.rs @@ -834,9 +834,15 @@ fn read_and_relay_tap_frame( let len = tap .read_ethernet_frame(buffer) .context("failed to read TAP Ethernet frame")?; - relay_io - .send_ethernet(&buffer[..len]) - .context("failed to send TAP Ethernet frame to relay")?; + match relay_io + .send_ethernet_with_outcome(&buffer[..len]) + .context("failed to send TAP Ethernet frame to relay")? + { + lanparty_client_core::ClientSendOutcome::Sent => {} + lanparty_client_core::ClientSendOutcome::Dropped(reason) => { + eprintln!("dropped TAP Ethernet frame before relay send: {reason:?}"); + } + } Ok(()) }