diff --git a/README.md b/README.md index 130360e..be895c3 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ Public relay binary and relay-owned room state: - one gateway per room, duplicate client MAC rejection, and room limits - stable effective room MTU chosen before Ethernet datagrams flow - live Ethernet datagram forwarding with no ingress reflection +- forwarding drops for Ethernet frames above the negotiated TAP MTU - per-peer egress budget checks against the negotiated datagram size - reliable `PeerJoined`/`PeerLeft` notifications to existing room peers - L2 safety filters for invalid-source, jumbo, switch-control, remote VLAN @@ -185,14 +186,15 @@ frames between the relay and wired LAN until shutdown. It captures whole LAN frames up to the overlay payload-length ceiling before deciding whether they fit the tunnel. It never fragments Ethernet frames; LAN frames with invalid source MACs, L2 -control-plane traffic, jumbo frames, or encoded datagrams exceeding the -negotiated QUIC budget are counted, dropped, and logged locally instead of -stopping the bridge or consuming relay bandwidth. Remote frames received from +control-plane traffic, jumbo frames, frames above the negotiated TAP MTU, or +encoded datagrams exceeding the negotiated QUIC budget are counted, dropped, +and logged locally instead of stopping the bridge or consuming relay bandwidth. +Remote frames received from the relay are safety-checked again before LAN injection and must use the announced virtual MAC for their source peer, so invalid-source, forged-source, L2 control-plane, remote VLAN, DHCP-server, IPv6 Router Advertisement, IPv6 -fragment, and jumbo frames cannot cross the gateway's final physical-LAN -boundary even if they reached the gateway over QUIC. +fragment, jumbo, and over-TAP-MTU frames cannot cross the gateway's final +physical-LAN boundary even if they reached the gateway over QUIC. `--relay` accepts a DNS name or socket address; bare hosts default to UDP/443. The gateway rejects Linux interfaces that sysfs identifies as Wi-Fi, and rejects wired interfaces whose sysfs carrier state reports no link; managed wireless @@ -275,11 +277,12 @@ broadcast-flow confirmation. One-way broadcast diagnostics distinguish frames sent toward the LAN from broadcast frames received back from the LAN. Malformed frames read from TAP, invalid or unauthorized source-MAC frames, L2 control-plane traffic, remote VLAN tags, DHCP server replies, IPv6 Router Advertisements, IPv6 -fragments, jumbo frames, and TAP frames whose encoded datagrams exceed the -negotiated QUIC budget are counted and dropped before relay send without -stopping the bridge. Relayed LAN frames are also safety-checked before TAP -writes, so switch-control traffic, invalid-source frames, and jumbo frames stay -out of the Windows adapter even if they reached the client over QUIC. +fragments, jumbo frames, frames above the negotiated TAP MTU, and TAP frames +whose encoded datagrams exceed the negotiated QUIC budget are counted and +dropped before relay send without stopping the bridge. Relayed LAN frames are +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 diff --git a/TESTING.md b/TESTING.md index e49519d..283cc4e 100644 --- a/TESTING.md +++ b/TESTING.md @@ -256,6 +256,7 @@ Drops that can be normal during testing: ```text drop_reason=UnknownDestination +drop_reason=TapMtuExceeded drop_reason=DatagramBudget drop_reason=RateLimit ``` @@ -263,6 +264,9 @@ 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. +`TapMtuExceeded` means a host emitted an Ethernet frame larger than the +negotiated tunnel MTU; occasional drops can happen while testing software that +does not honor the smaller adapter MTU yet. Drops that should be investigated if they dominate: diff --git a/crates/lanparty-client-core/src/lib.rs b/crates/lanparty-client-core/src/lib.rs index 2fabec5..d8a0d0b 100644 --- a/crates/lanparty-client-core/src/lib.rs +++ b/crates/lanparty-client-core/src/lib.rs @@ -28,7 +28,8 @@ use lanparty_ctrl::{ use lanparty_obs::{DropReason, QuicDiagnostics, TunnelStats}; use lanparty_proto::{ EthernetFrame, FrameType, MacAddr, OVERLAY_FLAGS_NONE, decode_datagram, encode_datagram, - gateway_lan_safety_drop_reason, remote_client_safety_drop_reason, validate_datagram_budget, + ethernet_frame_exceeds_tap_mtu, gateway_lan_safety_drop_reason, + remote_client_safety_drop_reason, validate_datagram_budget, }; use quinn::{ClientConfig, Endpoint, crypto::rustls::QuicClientConfig}; use rustls::pki_types::CertificateDer; @@ -396,6 +397,13 @@ impl ClientRelayIo { self.stats.record_dropped_frame(); return Ok(ClientSendOutcome::Dropped(DropReason::from(drop_reason))); } + if ethernet_frame_exceeds_tap_mtu( + ethernet_frame, + usize::from(self.welcome.effective_tap_mtu()), + ) { + self.stats.record_dropped_frame(); + return Ok(ClientSendOutcome::Dropped(DropReason::TapMtuExceeded)); + } let datagram = encode_datagram( FrameType::Ethernet, @@ -449,6 +457,13 @@ impl ClientRelayIo { self.stats.record_dropped_frame(); continue; } + if ethernet_frame_exceeds_tap_mtu( + ethernet_frame, + usize::from(self.welcome.effective_tap_mtu()), + ) { + self.stats.record_dropped_frame(); + continue; + } if !is_accepted_relay_destination(ethernet_frame, self.virtual_mac) { self.stats.record_dropped_frame(); continue; @@ -868,7 +883,7 @@ mod tests { let ControlMessage::Stats(stats) = stats_message else { panic!("expected client stats event"); }; - assert_eq!(stats, TunnelStats::new(1, 3, 1, 3, 9, 1)); + assert_eq!(stats, TunnelStats::new(1, 3, 1, 3, 10, 1)); stats_received_tx.send(()).unwrap(); let mut disconnect_recv = connection.accept_uni().await.unwrap(); @@ -976,6 +991,13 @@ mod tests { .unwrap(), ClientSendOutcome::Dropped(DropReason::UnauthorizedSourceMac) ); + let tap_oversized_payload = vec![0; usize::from(client.welcome().effective_tap_mtu()) + 1]; + assert_eq!( + relay_io + .send_ethernet_with_outcome(ðernet_frame(&tap_oversized_payload)) + .unwrap(), + ClientSendOutcome::Dropped(DropReason::TapMtuExceeded) + ); let jumbo_payload = vec![0; lanparty_proto::MAX_STANDARD_ETHERNET_PAYLOAD_LEN + 1]; assert_eq!( relay_io @@ -1002,7 +1024,7 @@ mod tests { assert_eq!(stats.broadcast_frames_rx(), 0); assert_eq!(stats.datagrams_tx(), 1); assert_eq!(stats.datagrams_rx(), 3); - assert_eq!(stats.dropped_frames(), 9); + assert_eq!(stats.dropped_frames(), 10); assert_eq!(stats.malformed_frames(), 1); assert_eq!(client.stats_snapshot(), stats); diff --git a/crates/lanparty-gateway/src/lib.rs b/crates/lanparty-gateway/src/lib.rs index 7aada5c..0b355b6 100644 --- a/crates/lanparty-gateway/src/lib.rs +++ b/crates/lanparty-gateway/src/lib.rs @@ -33,7 +33,8 @@ use lanparty_obs::{DropReason, TunnelStats}; use lanparty_obs::{FrameAction, FrameDirection, FrameLog}; use lanparty_proto::{ EthernetFrame, FrameType, MacAddr, OVERLAY_FLAGS_NONE, decode_datagram, encode_datagram, - gateway_lan_safety_drop_reason, remote_client_safety_drop_reason, validate_datagram_budget, + ethernet_frame_exceeds_tap_mtu, gateway_lan_safety_drop_reason, + remote_client_safety_drop_reason, validate_datagram_budget, }; use quinn::{ClientConfig, Endpoint, crypto::rustls::QuicClientConfig}; use rustls::pki_types::CertificateDer; @@ -515,6 +516,10 @@ fn send_gateway_ethernet( stats.record_dropped_frame(); return Ok(GatewaySendOutcome::Dropped(DropReason::from(drop_reason))); } + if ethernet_frame_exceeds_tap_mtu(ethernet_frame, usize::from(welcome.effective_tap_mtu())) { + stats.record_dropped_frame(); + return Ok(GatewaySendOutcome::Dropped(DropReason::TapMtuExceeded)); + } let datagram = encode_datagram( FrameType::Ethernet, @@ -595,6 +600,17 @@ async fn recv_gateway_ethernet_outcome( }, )); } + if ethernet_frame_exceeds_tap_mtu(ethernet_frame, usize::from(welcome.effective_tap_mtu())) + { + stats.record_dropped_frame(); + return Ok(GatewayReceiveOutcome::Filtered( + FilteredRelayEthernetFrame { + source_peer_id: header.peer_id(), + payload: Bytes::copy_from_slice(packet.payload()), + drop_reason: DropReason::TapMtuExceeded, + }, + )); + } return Ok(GatewayReceiveOutcome::Accepted(ReceivedEthernetFrame { source_peer_id: header.peer_id(), @@ -1207,7 +1223,7 @@ mod tests { let ControlMessage::Stats(stats) = stats_message else { panic!("expected gateway stats event"); }; - assert_eq!(stats, TunnelStats::new(1, 2, 1, 2, 5, 1)); + assert_eq!(stats, TunnelStats::new(1, 2, 1, 2, 6, 1)); stats_received_tx.send(()).unwrap(); let mut disconnect_recv = connection.accept_uni().await.unwrap(); @@ -1264,6 +1280,14 @@ mod tests { .send_ethernet(ðernet_frame_from(MacAddr::BROADCAST, b"invalid source")) .is_err() ); + let tap_oversized_payload = vec![0; usize::from(gateway.welcome().effective_tap_mtu()) + 1]; + let tap_mtu_error = gateway + .send_ethernet(ðernet_frame(&tap_oversized_payload)) + .unwrap_err(); + assert!( + tap_mtu_error.to_string().contains("TapMtuExceeded"), + "expected TapMtuExceeded error, got {tap_mtu_error:#}" + ); gateway.send_ethernet(ðernet_frame(b"to relay")).unwrap(); let received = tokio::time::timeout(Duration::from_secs(5), gateway.recv_ethernet()) .await @@ -1283,7 +1307,7 @@ mod tests { .is_err() ); let stats = gateway.stats_snapshot(); - assert_eq!(stats, TunnelStats::new(1, 2, 1, 2, 5, 1)); + assert_eq!(stats, TunnelStats::new(1, 2, 1, 2, 6, 1)); gateway.send_stats_snapshot().await.unwrap(); tokio::time::timeout(Duration::from_secs(5), stats_received_rx) diff --git a/crates/lanparty-obs/src/lib.rs b/crates/lanparty-obs/src/lib.rs index 14e2d3a..168d540 100644 --- a/crates/lanparty-obs/src/lib.rs +++ b/crates/lanparty-obs/src/lib.rs @@ -40,6 +40,7 @@ pub enum DropReason { Ipv6RouterAdvertisement, Ipv6Fragment, VlanTaggedFrame, + TapMtuExceeded, DatagramBudget, UnknownDestination, RateLimit, diff --git a/crates/lanparty-proto/src/lib.rs b/crates/lanparty-proto/src/lib.rs index 7e0a95f..78b7d0e 100644 --- a/crates/lanparty-proto/src/lib.rs +++ b/crates/lanparty-proto/src/lib.rs @@ -17,7 +17,8 @@ pub use ethernet::{ pub use mac::{MacAddr, MacParseError}; pub use mtu::{ DEFAULT_DATAGRAM_SAFETY_MARGIN, DEFAULT_TAP_MTU, MIN_USEFUL_TAP_MTU, MtuError, - max_tap_mtu_for_datagram, recommended_tap_mtu, + ethernet_frame_exceeds_tap_mtu, max_ethernet_frame_len_for_tap_mtu, max_tap_mtu_for_datagram, + recommended_tap_mtu, }; pub use overlay::{ FrameType, OVERLAY_FLAGS_NONE, OVERLAY_HEADER_LEN, OVERLAY_MAGIC, OVERLAY_VERSION, diff --git a/crates/lanparty-proto/src/mtu.rs b/crates/lanparty-proto/src/mtu.rs index 9b25050..65fe15c 100644 --- a/crates/lanparty-proto/src/mtu.rs +++ b/crates/lanparty-proto/src/mtu.rs @@ -1,6 +1,6 @@ use thiserror::Error; -use crate::{ETHERNET_HEADER_LEN, OVERLAY_HEADER_LEN}; +use crate::{ETHERNET_HEADER_LEN, EthernetFrame, OVERLAY_HEADER_LEN}; pub const DEFAULT_TAP_MTU: usize = 1200; pub const MIN_USEFUL_TAP_MTU: usize = 576; @@ -40,6 +40,16 @@ pub fn recommended_tap_mtu(quic_max_datagram_size: usize) -> Result usize { + ETHERNET_HEADER_LEN + tap_mtu +} + +#[must_use] +pub const fn ethernet_frame_exceeds_tap_mtu(frame: EthernetFrame<'_>, tap_mtu: usize) -> bool { + frame.len() > max_ethernet_frame_len_for_tap_mtu(tap_mtu) +} + #[cfg(test)] mod tests { use super::*; @@ -57,6 +67,20 @@ mod tests { ); } + #[test] + fn checks_ethernet_frame_len_against_tap_mtu() { + let frame = vec![0; ETHERNET_HEADER_LEN + 1200]; + let frame = EthernetFrame::parse(&frame).unwrap(); + + assert_eq!(max_ethernet_frame_len_for_tap_mtu(1200), 1214); + assert!(!ethernet_frame_exceeds_tap_mtu(frame, 1200)); + + let oversized = vec![0; ETHERNET_HEADER_LEN + 1201]; + let oversized = EthernetFrame::parse(&oversized).unwrap(); + + assert!(ethernet_frame_exceeds_tap_mtu(oversized, 1200)); + } + #[test] fn rejects_too_small_datagram_budget() { let error = recommended_tap_mtu(128).unwrap_err(); diff --git a/crates/lanparty-relay/src/lib.rs b/crates/lanparty-relay/src/lib.rs index 1762c0c..4e2acf4 100644 --- a/crates/lanparty-relay/src/lib.rs +++ b/crates/lanparty-relay/src/lib.rs @@ -14,8 +14,8 @@ use lanparty_ctrl::{ }; use lanparty_obs::{DropReason, FrameAction}; use lanparty_proto::{ - EthernetFrame, MacAddr, gateway_lan_safety_drop_reason, recommended_tap_mtu, - remote_client_safety_drop_reason, + EthernetFrame, MacAddr, ethernet_frame_exceeds_tap_mtu, gateway_lan_safety_drop_reason, + recommended_tap_mtu, remote_client_safety_drop_reason, }; use thiserror::Error; @@ -31,8 +31,6 @@ const CLIENT_UNKNOWN_UNICAST_REFILL_FRAMES_PER_SECOND: u32 = 32; const CLIENT_TOTAL_BANDWIDTH_BURST_BYTES: u64 = 4 * MEBIBYTE; const CLIENT_TOTAL_BANDWIDTH_REFILL_BYTES_PER_SECOND: u64 = 2 * MEBIBYTE; #[cfg(test)] -use lanparty_proto::ETHERNET_HEADER_LEN; -#[cfg(test)] const ETHERTYPE_IPV4: u16 = 0x0800; #[cfg(test)] const ETHERTYPE_LLDP: u16 = 0x88cc; @@ -561,6 +559,11 @@ impl Room { if let Some(drop_reason) = safety_drop_reason { return Ok(ForwardingDecision::filtered(DropReason::from(drop_reason))); } + if let Some(effective_tap_mtu) = self.effective_tap_mtu + && ethernet_frame_exceeds_tap_mtu(frame, usize::from(effective_tap_mtu)) + { + return Ok(ForwardingDecision::dropped(DropReason::TapMtuExceeded)); + } if ingress_role == Role::Client && !self.allow_client_total_bandwidth(ingress_peer_id, frame.len() as u64, now) @@ -1263,7 +1266,7 @@ mod tests { let mut registry = RoomRegistry::default(); let client_one = registry.join(client_hello(1)).unwrap(); let client_two = registry.join(client_hello(2)).unwrap(); - let payload = vec![0; MAX_STANDARD_ETHERNET_FRAME_LEN - ETHERNET_HEADER_LEN]; + let payload = vec![0; usize::from(client_one.welcome().effective_tap_mtu())]; let frame = ethernet_with_payload(mac(2), mac(1), ETHERTYPE_IPV4, &payload); let frame_len = frame.len() as u64; let burst_frames = CLIENT_TOTAL_BANDWIDTH_BURST_BYTES / frame_len; @@ -1353,6 +1356,32 @@ mod tests { assert_filtered(&decision, DropReason::JumboFrame); } + #[test] + fn drops_frames_above_effective_tap_mtu() { + let mut registry = RoomRegistry::default(); + let gateway = registry.join(gateway_hello()).unwrap(); + let client = registry.join(client_hello(1)).unwrap(); + let oversized_payload = vec![0; usize::from(client.welcome().effective_tap_mtu()) + 1]; + let client_frame = ethernet_with_payload( + MacAddr::BROADCAST, + mac(1), + ETHERTYPE_IPV4, + &oversized_payload, + ); + let gateway_frame = + ethernet_with_payload(mac(1), physical_mac(), ETHERTYPE_IPV4, &oversized_payload); + + let client_decision = registry + .forward_ethernet(&room(), client.peer().peer_id(), &client_frame) + .unwrap(); + let gateway_decision = registry + .forward_ethernet(&room(), gateway.peer().peer_id(), &gateway_frame) + .unwrap(); + + assert_dropped(&client_decision, DropReason::TapMtuExceeded); + assert_dropped(&gateway_decision, DropReason::TapMtuExceeded); + } + #[test] fn filters_l2_control_plane_frames_from_clients_and_gateway() { let mut registry = RoomRegistry::default();