fix(tunnel): enforce negotiated TAP MTU

The MVP tunnel negotiates an effective TAP MTU and configures the Windows TAP IP
interface to that value, but the forwarding path only rejected frames that were
standard-Ethernet jumbo frames or exceeded the QUIC datagram budget. A frame
could therefore be larger than the negotiated TAP MTU while still fitting inside
the QUIC datagram budget.

Make the TAP-MTU frame limit an explicit shared protocol helper and enforce it
at every data-path boundary: Windows client send/receive, Linux gateway
send/receive, and relay forwarding. Such frames now produce TapMtuExceeded in
logs and counters instead of being forwarded until a later layer drops or
accepts them implicitly.

This keeps the no-fragmentation contract honest: one Ethernet frame still maps
to one QUIC datagram, but only if that frame also fits the room's negotiated TAP
MTU.

Test Plan:
- cargo fmt --check
- cargo test -p lanparty-proto tap_mtu
- cargo test -p lanparty-client-core connects_to_relay_control_stream_as_client
- cargo test -p lanparty-gateway connects_to_relay_control_stream_as_gateway
- cargo test -p lanparty-relay drops_frames_above_effective_tap_mtu
- cargo test -p lanparty-relay rate_limits_client_total_bandwidth_after_burst
- cargo test --workspace
- cargo clippy --workspace --all-targets -- -D warnings
- cargo build --release -p lanparty-relay -p lanparty-gateway
- git diff --check
- git diff --cached --check

Refs: MVP no-fragmentation tunnel MTU contract
This commit is contained in:
2026-05-22 07:15:11 +02:00
parent f229445c3d
commit bd22a68a6f
8 changed files with 131 additions and 23 deletions
+25 -3
View File
@@ -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(&ethernet_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);
+27 -3
View File
@@ -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(&ethernet_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(&ethernet_frame(&tap_oversized_payload))
.unwrap_err();
assert!(
tap_mtu_error.to_string().contains("TapMtuExceeded"),
"expected TapMtuExceeded error, got {tap_mtu_error:#}"
);
gateway.send_ethernet(&ethernet_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)
+1
View File
@@ -40,6 +40,7 @@ pub enum DropReason {
Ipv6RouterAdvertisement,
Ipv6Fragment,
VlanTaggedFrame,
TapMtuExceeded,
DatagramBudget,
UnknownDestination,
RateLimit,
+2 -1
View File
@@ -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,
+25 -1
View File
@@ -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, MtuEr
Ok(max.min(DEFAULT_TAP_MTU))
}
#[must_use]
pub const fn max_ethernet_frame_len_for_tap_mtu(tap_mtu: usize) -> 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();
+34 -5
View File
@@ -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();