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
This commit is contained in:
2026-05-21 22:31:17 +02:00
parent d503533a3c
commit 319a1a25ad
3 changed files with 57 additions and 16 deletions
+3 -2
View File
@@ -167,8 +167,9 @@ cargo run -p lanparty-gateway -- \
The gateway connects to the relay as `role = gateway`, completes the The gateway connects to the relay as `role = gateway`, completes the
control-stream hello/welcome handshake, opens an AF_PACKET socket on the LAN control-stream hello/welcome handshake, opens an AF_PACKET socket on the LAN
interface with promiscuous packet membership, and bridges Ethernet frames interface with promiscuous packet membership, and bridges Ethernet frames
between the relay and wired LAN until shutdown. Sends fail before fragmentation between the relay and wired LAN until shutdown. It never fragments Ethernet
when an encoded Ethernet datagram exceeds the negotiated QUIC datagram budget. 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. `--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 It tracks remote-client source MACs seen from relay traffic and periodically
emits small CAM refresh frames so the physical switch keeps those MACs emits small CAM refresh frames so the physical switch keeps those MACs
+53 -14
View File
@@ -28,7 +28,7 @@ use lanparty_ctrl::{
decode_control_frame, encode_control_message, decode_control_frame, encode_control_message,
}; };
use lanparty_net::RelayEndpoint; use lanparty_net::RelayEndpoint;
use lanparty_obs::TunnelStats; use lanparty_obs::{DropReason, TunnelStats};
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
use lanparty_obs::{FrameAction, FrameDirection, FrameLog}; use lanparty_obs::{FrameAction, FrameDirection, FrameLog};
use lanparty_proto::{ use lanparty_proto::{
@@ -235,13 +235,21 @@ impl GatewayConnection {
} }
pub fn send_ethernet(&self, frame: &[u8]) -> Result<()> { pub fn send_ethernet(&self, frame: &[u8]) -> Result<()> {
send_gateway_ethernet( match send_gateway_ethernet(
&self.connection, &self.connection,
&self.welcome, &self.welcome,
self.quic_max_datagram_size, self.quic_max_datagram_size,
&self.stats, &self.stats,
frame, 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<ReceivedEthernetFrame> { pub async fn recv_ethernet(&self) -> Result<ReceivedEthernetFrame> {
@@ -305,13 +313,17 @@ impl GatewayConnection {
} }
lan_frame = read_lan_ethernet(&packet_socket) => { lan_frame = read_lan_ethernet(&packet_socket) => {
let lan_frame = lan_frame?; let lan_frame = lan_frame?;
send_gateway_ethernet( let outcome = send_gateway_ethernet(
&connection, &connection,
&welcome, &welcome,
quic_max_datagram_size, quic_max_datagram_size,
&stats, &stats,
&lan_frame, &lan_frame,
)?; )?;
let (action, drop_reason) = match outcome {
GatewaySendOutcome::Sent => (FrameAction::Forwarded, None),
GatewaySendOutcome::Dropped(reason) => (FrameAction::Dropped, Some(reason)),
};
println!( println!(
"{}", "{}",
gateway_frame_log_line( gateway_frame_log_line(
@@ -319,8 +331,8 @@ impl GatewayConnection {
FrameDirection::LanToRemote, FrameDirection::LanToRemote,
Some(welcome.peer_id()), Some(welcome.peer_id()),
&lan_frame, &lan_frame,
FrameAction::Forwarded, action,
None, drop_reason,
) )
); );
} }
@@ -385,7 +397,7 @@ fn send_gateway_ethernet(
quic_max_datagram_size: u16, quic_max_datagram_size: u16,
stats: &GatewayTunnelStats, stats: &GatewayTunnelStats,
frame: &[u8], frame: &[u8],
) -> Result<()> { ) -> Result<GatewaySendOutcome> {
let ethernet_frame = match EthernetFrame::parse(frame) { let ethernet_frame = match EthernetFrame::parse(frame) {
Ok(frame) => frame, Ok(frame) => frame,
Err(error) => { Err(error) => {
@@ -401,11 +413,9 @@ fn send_gateway_ethernet(
frame, frame,
) )
.context("failed to encode gateway Ethernet datagram")?; .context("failed to encode gateway Ethernet datagram")?;
if let Err(error) = if validate_datagram_budget(datagram.len(), usize::from(quic_max_datagram_size)).is_err() {
validate_datagram_budget(datagram.len(), usize::from(quic_max_datagram_size))
{
stats.record_dropped_frame(); stats.record_dropped_frame();
return Err(error).context("gateway Ethernet datagram exceeds negotiated QUIC budget"); return Ok(GatewaySendOutcome::Dropped(DropReason::DatagramBudget));
} }
connection connection
@@ -413,7 +423,13 @@ fn send_gateway_ethernet(
.context("failed to send gateway Ethernet datagram")?; .context("failed to send gateway Ethernet datagram")?;
stats.record_ethernet_tx(ethernet_frame); 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( async fn recv_gateway_ethernet(
@@ -961,7 +977,7 @@ mod tests {
let ControlMessage::Stats(stats) = stats_message else { let ControlMessage::Stats(stats) = stats_message else {
panic!("expected gateway stats event"); 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(); stats_received_tx.send(()).unwrap();
let mut disconnect_recv = connection.accept_uni().await.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_eq!(received.payload(), ethernet_frame(b"from relay").as_slice());
assert!(gateway.send_ethernet(&[0; 4]).is_err()); assert!(gateway.send_ethernet(&[0; 4]).is_err());
let oversized_payload = vec![0; usize::from(gateway.quic_max_datagram_size())];
assert!(
gateway
.send_ethernet(&ethernet_frame(&oversized_payload))
.is_err()
);
let stats = gateway.stats_snapshot(); 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(); gateway.send_stats_snapshot().await.unwrap();
tokio::time::timeout(Duration::from_secs(5), stats_received_rx) 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),
&ethernet_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>) { fn test_server_config() -> (ServerConfig, CertificateDer<'static>) {
let certified_key = let certified_key =
rcgen::generate_simple_self_signed(vec!["lanparty-relay.local".into()]).unwrap(); rcgen::generate_simple_self_signed(vec!["lanparty-relay.local".into()]).unwrap();
+1
View File
@@ -36,6 +36,7 @@ pub enum DropReason {
ControlPlaneEtherType, ControlPlaneEtherType,
DhcpServerReply, DhcpServerReply,
Ipv6RouterAdvertisement, Ipv6RouterAdvertisement,
DatagramBudget,
UnknownDestination, UnknownDestination,
RateLimit, RateLimit,
} }