diff --git a/README.md b/README.md index e41ba59..35b8d9e 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Monorepo for a Layer 2 over QUIC LAN party bridge. Transport-agnostic tunnel contract shared by all binaries: - overlay datagram header encoding and decoding +- negotiated QUIC datagram budget validation before send - Ethernet frame header parsing - MAC address parsing and identity validation - QUIC datagram to TAP MTU budget helpers @@ -59,7 +60,7 @@ 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 +- Ethernet frame send/receive helpers over QUIC DATAGRAM with budget checks - 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 @@ -158,10 +159,12 @@ 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. `--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 associated with the gateway port. Gateway +between the relay and wired LAN until shutdown. Sends fail before fragmentation +when an encoded Ethernet datagram exceeds the negotiated QUIC datagram budget. +`--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 +associated with the gateway port. Gateway frame logs include direction, peer id when present, MACs, ethertype/length, frame length, action, and drop reason. The gateway also tracks frame/datagram counters and periodically sends stats snapshots to the relay. Relay lifecycle diff --git a/crates/lanparty-client-core/src/lib.rs b/crates/lanparty-client-core/src/lib.rs index 4c98f7b..78fb9d4 100644 --- a/crates/lanparty-client-core/src/lib.rs +++ b/crates/lanparty-client-core/src/lib.rs @@ -26,7 +26,9 @@ use lanparty_ctrl::{ encode_control_message, }; use lanparty_obs::{QuicDiagnostics, TunnelStats}; -use lanparty_proto::{EthernetFrame, FrameType, MacAddr, decode_datagram, encode_datagram}; +use lanparty_proto::{ + EthernetFrame, FrameType, MacAddr, decode_datagram, encode_datagram, validate_datagram_budget, +}; use quinn::{ClientConfig, Endpoint, crypto::rustls::QuicClientConfig}; use rustls::pki_types::CertificateDer; @@ -266,6 +268,7 @@ impl ClientSession { ClientRelayIo::new( self.connection.clone(), self.welcome.clone(), + self.quic_max_datagram_size, Arc::clone(&self.stats), ) } @@ -305,6 +308,7 @@ impl ClientSession { pub struct ClientRelayIo { connection: quinn::Connection, welcome: ServerWelcome, + quic_max_datagram_size: u16, stats: Arc, } @@ -313,11 +317,13 @@ impl ClientRelayIo { fn new( connection: quinn::Connection, welcome: ServerWelcome, + quic_max_datagram_size: u16, stats: Arc, ) -> Self { Self { connection, welcome, + quic_max_datagram_size, stats, } } @@ -327,6 +333,11 @@ impl ClientRelayIo { &self.welcome } + #[must_use] + pub const fn quic_max_datagram_size(&self) -> u16 { + self.quic_max_datagram_size + } + pub fn send_ethernet(&self, frame: &[u8]) -> Result<()> { let ethernet_frame = match EthernetFrame::parse(frame) { Ok(frame) => frame, @@ -343,6 +354,12 @@ 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)) + { + self.stats.record_dropped_frame(); + return Err(error).context("client Ethernet datagram exceeds negotiated QUIC budget"); + } self.connection .send_datagram(Bytes::from(datagram)) @@ -799,6 +816,10 @@ mod tests { ); let relay_io = client.relay_io(); assert_eq!(relay_io.welcome().peer_id(), 2); + assert_eq!( + relay_io.quic_max_datagram_size(), + client.quic_max_datagram_size() + ); relay_io .send_ethernet(ðernet_frame(b"to relay")) diff --git a/crates/lanparty-gateway/src/lib.rs b/crates/lanparty-gateway/src/lib.rs index 80ff6e0..f67e585 100644 --- a/crates/lanparty-gateway/src/lib.rs +++ b/crates/lanparty-gateway/src/lib.rs @@ -33,7 +33,7 @@ use lanparty_obs::TunnelStats; use lanparty_obs::{FrameAction, FrameDirection, FrameLog}; use lanparty_proto::{ EthernetFrame, FrameType, MAX_STANDARD_ETHERNET_FRAME_LEN, MacAddr, decode_datagram, - encode_datagram, + encode_datagram, validate_datagram_budget, }; use quinn::{ClientConfig, Endpoint, crypto::rustls::QuicClientConfig}; use rustls::pki_types::CertificateDer; @@ -196,6 +196,7 @@ pub struct GatewayConnection { connection: quinn::Connection, config: GatewayConfig, welcome: ServerWelcome, + quic_max_datagram_size: u16, stats: Arc, } @@ -228,8 +229,19 @@ impl GatewayConnection { &self.welcome } + #[must_use] + pub const fn quic_max_datagram_size(&self) -> u16 { + self.quic_max_datagram_size + } + pub fn send_ethernet(&self, frame: &[u8]) -> Result<()> { - send_gateway_ethernet(&self.connection, &self.welcome, &self.stats, frame) + send_gateway_ethernet( + &self.connection, + &self.welcome, + self.quic_max_datagram_size, + &self.stats, + frame, + ) } pub async fn recv_ethernet(&self) -> Result { @@ -263,6 +275,7 @@ impl GatewayConnection { endpoint, connection, welcome, + quic_max_datagram_size, stats, .. } = self; @@ -292,7 +305,13 @@ impl GatewayConnection { } lan_frame = read_lan_ethernet(&packet_socket) => { let lan_frame = lan_frame?; - send_gateway_ethernet(&connection, &welcome, &stats, &lan_frame)?; + send_gateway_ethernet( + &connection, + &welcome, + quic_max_datagram_size, + &stats, + &lan_frame, + )?; println!( "{}", gateway_frame_log_line( @@ -363,6 +382,7 @@ impl GatewayConnection { fn send_gateway_ethernet( connection: &quinn::Connection, welcome: &ServerWelcome, + quic_max_datagram_size: u16, stats: &GatewayTunnelStats, frame: &[u8], ) -> Result<()> { @@ -381,6 +401,12 @@ 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)) + { + stats.record_dropped_frame(); + return Err(error).context("gateway Ethernet datagram exceeds negotiated QUIC budget"); + } connection .send_datagram(Bytes::from(datagram)) @@ -747,6 +773,7 @@ pub async fn connect_gateway(config: GatewayConfig) -> Result connection, config, welcome, + quic_max_datagram_size: hello_datagram_size, stats: Arc::default(), }), ControlMessage::Reject(reject) => bail!( @@ -968,6 +995,7 @@ mod tests { assert_eq!(gateway.config().interface(), "eth0"); assert_eq!(gateway.welcome().room_id(), 7); assert_eq!(gateway.welcome().peer_id(), 1); + assert!(gateway.quic_max_datagram_size() <= 1400); let event = tokio::time::timeout(Duration::from_secs(5), gateway.recv_control_event()) .await diff --git a/crates/lanparty-proto/src/lib.rs b/crates/lanparty-proto/src/lib.rs index 7506c55..d678f3c 100644 --- a/crates/lanparty-proto/src/lib.rs +++ b/crates/lanparty-proto/src/lib.rs @@ -20,5 +20,5 @@ pub use mtu::{ }; pub use overlay::{ FrameType, OVERLAY_HEADER_LEN, OVERLAY_MAGIC, OVERLAY_VERSION, OverlayHeader, OverlayPacket, - ProtoError, decode_datagram, encode_datagram, + ProtoError, decode_datagram, encode_datagram, validate_datagram_budget, }; diff --git a/crates/lanparty-proto/src/overlay.rs b/crates/lanparty-proto/src/overlay.rs index d1a6535..cf9d628 100644 --- a/crates/lanparty-proto/src/overlay.rs +++ b/crates/lanparty-proto/src/overlay.rs @@ -170,6 +170,8 @@ pub enum ProtoError { UnknownFrameType(u8), #[error("payload length {len} exceeds wire maximum {max}")] PayloadTooLarge { len: usize, max: usize }, + #[error("encoded datagram length {len} exceeds negotiated QUIC datagram budget {max}")] + DatagramExceedsBudget { len: usize, max: usize }, #[error("declared payload length {declared} does not match actual length {actual}")] PayloadLengthMismatch { declared: usize, actual: usize }, #[error("Ethernet frame is too short: got {actual} bytes, need at least {minimum}")] @@ -190,6 +192,20 @@ pub fn encode_datagram( Ok(datagram) } +pub fn validate_datagram_budget( + datagram_len: usize, + max_datagram_size: usize, +) -> Result<(), ProtoError> { + if datagram_len > max_datagram_size { + return Err(ProtoError::DatagramExceedsBudget { + len: datagram_len, + max: max_datagram_size, + }); + } + + Ok(()) +} + pub fn decode_datagram(bytes: &[u8]) -> Result, ProtoError> { let header = OverlayHeader::decode(bytes)?; let payload = &bytes[OVERLAY_HEADER_LEN..]; @@ -281,4 +297,18 @@ mod tests { ProtoError::PayloadTooLarge { .. } )); } + + #[test] + fn rejects_datagrams_over_negotiated_budget() { + let datagram = encode_datagram(FrameType::Ethernet, 1, 2, 0, &[1, 2, 3]).unwrap(); + + assert!(validate_datagram_budget(datagram.len(), datagram.len()).is_ok()); + assert_eq!( + validate_datagram_budget(datagram.len(), datagram.len() - 1).unwrap_err(), + ProtoError::DatagramExceedsBudget { + len: datagram.len(), + max: datagram.len() - 1 + } + ); + } }