From 319a1a25ada5aaaf8b417fb792f6c54d2a8bf797 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Thu, 21 May 2026 22:31:17 +0200 Subject: [PATCH] fix(gateway): drop over-budget LAN frames The gateway may see full-sized frames from the physical LAN even when the room uses a smaller tunnel MTU. With no overlay fragmentation, those frames cannot be encoded into the negotiated QUIC datagram budget. The live bridge previously propagated that budget error and stopped, which made one oversized LAN frame a fatal condition. Teach the gateway send path to return a send outcome. Normal direct callers still get an error for over-budget sends, but the Linux bridge loop now records, logs, and drops those frames with a DatagramBudget drop reason before continuing with later traffic. Malformed local Ethernet still remains an error because that indicates a broken local boundary rather than ordinary LAN traffic. The gateway stats test now covers the extra drop, and the frame-log test covers the new drop reason. README now documents that over-budget LAN frames are counted, dropped, and logged instead of fragmented or killing the bridge. Test Plan: - cargo fmt --check - cargo test -p lanparty-obs -p lanparty-gateway - cargo test --workspace - cargo clippy --workspace --all-targets -- -D warnings - git diff --check - git diff --cached --check Refs: PLAN.md No fragmentation for MVP --- README.md | 5 ++- crates/lanparty-gateway/src/lib.rs | 67 +++++++++++++++++++++++------- crates/lanparty-obs/src/lib.rs | 1 + 3 files changed, 57 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 0c88a19..75fa954 100644 --- a/README.md +++ b/README.md @@ -167,8 +167,9 @@ cargo run -p lanparty-gateway -- \ The gateway connects to the relay as `role = gateway`, completes the control-stream hello/welcome handshake, opens an AF_PACKET socket on the LAN interface with promiscuous packet membership, and bridges Ethernet frames -between the relay and wired LAN until shutdown. Sends fail before fragmentation -when an encoded Ethernet datagram exceeds the negotiated QUIC datagram budget. +between the relay and wired LAN until shutdown. It never fragments Ethernet +frames; LAN frames whose encoded datagrams exceed the negotiated QUIC budget are +counted, dropped, and logged instead of stopping the bridge. `--relay` accepts a DNS name or socket address; bare hosts default to UDP/443. It tracks remote-client source MACs seen from relay traffic and periodically emits small CAM refresh frames so the physical switch keeps those MACs diff --git a/crates/lanparty-gateway/src/lib.rs b/crates/lanparty-gateway/src/lib.rs index f67e585..17d9896 100644 --- a/crates/lanparty-gateway/src/lib.rs +++ b/crates/lanparty-gateway/src/lib.rs @@ -28,7 +28,7 @@ use lanparty_ctrl::{ decode_control_frame, encode_control_message, }; use lanparty_net::RelayEndpoint; -use lanparty_obs::TunnelStats; +use lanparty_obs::{DropReason, TunnelStats}; #[cfg(target_os = "linux")] use lanparty_obs::{FrameAction, FrameDirection, FrameLog}; use lanparty_proto::{ @@ -235,13 +235,21 @@ impl GatewayConnection { } pub fn send_ethernet(&self, frame: &[u8]) -> Result<()> { - send_gateway_ethernet( + match send_gateway_ethernet( &self.connection, &self.welcome, self.quic_max_datagram_size, &self.stats, frame, - ) + )? { + GatewaySendOutcome::Sent => Ok(()), + GatewaySendOutcome::Dropped(DropReason::DatagramBudget) => { + bail!("gateway Ethernet datagram exceeds negotiated QUIC budget") + } + GatewaySendOutcome::Dropped(drop_reason) => { + bail!("gateway Ethernet frame was dropped: {drop_reason:?}") + } + } } pub async fn recv_ethernet(&self) -> Result { @@ -305,13 +313,17 @@ impl GatewayConnection { } lan_frame = read_lan_ethernet(&packet_socket) => { let lan_frame = lan_frame?; - send_gateway_ethernet( + let outcome = send_gateway_ethernet( &connection, &welcome, quic_max_datagram_size, &stats, &lan_frame, )?; + let (action, drop_reason) = match outcome { + GatewaySendOutcome::Sent => (FrameAction::Forwarded, None), + GatewaySendOutcome::Dropped(reason) => (FrameAction::Dropped, Some(reason)), + }; println!( "{}", gateway_frame_log_line( @@ -319,8 +331,8 @@ impl GatewayConnection { FrameDirection::LanToRemote, Some(welcome.peer_id()), &lan_frame, - FrameAction::Forwarded, - None, + action, + drop_reason, ) ); } @@ -385,7 +397,7 @@ fn send_gateway_ethernet( quic_max_datagram_size: u16, stats: &GatewayTunnelStats, frame: &[u8], -) -> Result<()> { +) -> Result { let ethernet_frame = match EthernetFrame::parse(frame) { Ok(frame) => frame, Err(error) => { @@ -401,11 +413,9 @@ fn send_gateway_ethernet( frame, ) .context("failed to encode gateway Ethernet datagram")?; - if let Err(error) = - validate_datagram_budget(datagram.len(), usize::from(quic_max_datagram_size)) - { + if validate_datagram_budget(datagram.len(), usize::from(quic_max_datagram_size)).is_err() { stats.record_dropped_frame(); - return Err(error).context("gateway Ethernet datagram exceeds negotiated QUIC budget"); + return Ok(GatewaySendOutcome::Dropped(DropReason::DatagramBudget)); } connection @@ -413,7 +423,13 @@ fn send_gateway_ethernet( .context("failed to send gateway Ethernet datagram")?; stats.record_ethernet_tx(ethernet_frame); - Ok(()) + Ok(GatewaySendOutcome::Sent) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum GatewaySendOutcome { + Sent, + Dropped(DropReason), } async fn recv_gateway_ethernet( @@ -961,7 +977,7 @@ mod tests { let ControlMessage::Stats(stats) = stats_message else { panic!("expected gateway 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(); @@ -1017,8 +1033,14 @@ mod tests { assert_eq!(received.payload(), ethernet_frame(b"from relay").as_slice()); assert!(gateway.send_ethernet(&[0; 4]).is_err()); + let oversized_payload = vec![0; usize::from(gateway.quic_max_datagram_size())]; + assert!( + gateway + .send_ethernet(ðernet_frame(&oversized_payload)) + .is_err() + ); let stats = gateway.stats_snapshot(); - assert_eq!(stats, TunnelStats::new(1, 1, 1, 1, 1, 1)); + assert_eq!(stats, TunnelStats::new(1, 1, 1, 1, 2, 1)); gateway.send_stats_snapshot().await.unwrap(); tokio::time::timeout(Duration::from_secs(5), stats_received_rx) @@ -1141,6 +1163,23 @@ mod tests { ); } + #[cfg(target_os = "linux")] + #[test] + fn formats_gateway_datagram_budget_drops() { + let line = gateway_frame_log_line( + "eth0", + FrameDirection::LanToRemote, + Some(1), + ðernet_frame(b"oversized"), + FrameAction::Dropped, + Some(DropReason::DatagramBudget), + ); + + assert!(line.contains("direction=LanToRemote")); + assert!(line.contains("action=Dropped")); + assert!(line.contains("drop_reason=DatagramBudget")); + } + fn test_server_config() -> (ServerConfig, CertificateDer<'static>) { let certified_key = rcgen::generate_simple_self_signed(vec!["lanparty-relay.local".into()]).unwrap(); diff --git a/crates/lanparty-obs/src/lib.rs b/crates/lanparty-obs/src/lib.rs index 6f15472..65d62b1 100644 --- a/crates/lanparty-obs/src/lib.rs +++ b/crates/lanparty-obs/src/lib.rs @@ -36,6 +36,7 @@ pub enum DropReason { ControlPlaneEtherType, DhcpServerReply, Ipv6RouterAdvertisement, + DatagramBudget, UnknownDestination, RateLimit, }