diff --git a/README.md b/README.md index 7745fd2..65ccf9c 100644 --- a/README.md +++ b/README.md @@ -178,9 +178,10 @@ gateway that cannot bridge. Once both sides are ready, it bridges Ethernet 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 whose encoded datagrams exceed the -negotiated QUIC budget are counted, dropped, and logged instead of stopping the -bridge. +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. `--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 diff --git a/crates/lanparty-gateway/src/lib.rs b/crates/lanparty-gateway/src/lib.rs index 1bd7a48..37e97f8 100644 --- a/crates/lanparty-gateway/src/lib.rs +++ b/crates/lanparty-gateway/src/lib.rs @@ -44,6 +44,9 @@ pub use packet::PacketSocket; const MAX_CONTROL_FRAME_LEN: usize = CONTROL_LENGTH_PREFIX_LEN + MAX_CONTROL_MESSAGE_LEN; const DISCONNECT_DRAIN_TIMEOUT: Duration = Duration::from_millis(250); +const ETHERTYPE_EAPOL: u16 = 0x888e; +const ETHERTYPE_SLOW_PROTOCOLS: u16 = 0x8809; +const ETHERTYPE_LLDP: u16 = 0x88cc; #[cfg(target_os = "linux")] const CAM_REFRESH_INTERVAL: Duration = Duration::from_secs(60); #[cfg(target_os = "linux")] @@ -427,6 +430,11 @@ fn send_gateway_ethernet( return Err(error).context("gateway Ethernet frame is malformed"); } }; + if let Some(drop_reason) = gateway_lan_drop_reason(ethernet_frame) { + stats.record_dropped_frame(); + return Ok(GatewaySendOutcome::Dropped(drop_reason)); + } + let datagram = encode_datagram( FrameType::Ethernet, welcome.room_id(), @@ -448,6 +456,35 @@ fn send_gateway_ethernet( Ok(GatewaySendOutcome::Sent) } +fn gateway_lan_drop_reason(frame: EthernetFrame<'_>) -> Option { + if !frame.source().is_valid_unicast() { + return Some(DropReason::InvalidSourceMac); + } + + if frame.is_jumbo() { + return Some(DropReason::JumboFrame); + } + + if is_lan_control_plane_frame(frame) { + return Some(DropReason::ControlPlaneEtherType); + } + + None +} + +fn is_lan_control_plane_frame(frame: EthernetFrame<'_>) -> bool { + matches!( + frame.ethertype_or_len(), + ETHERTYPE_EAPOL | ETHERTYPE_SLOW_PROTOCOLS | ETHERTYPE_LLDP + ) || is_link_local_control_destination(frame.destination()) +} + +fn is_link_local_control_destination(mac: MacAddr) -> bool { + let [a, b, c, d, e, f] = mac.octets(); + + [a, b, c, d, e] == [0x01, 0x80, 0xc2, 0x00, 0x00] && f <= 0x0f +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum GatewaySendOutcome { Sent, @@ -997,7 +1034,7 @@ mod tests { let ControlMessage::Stats(stats) = stats_message else { panic!("expected gateway stats event"); }; - assert_eq!(stats, TunnelStats::new(1, 1, 1, 1, 2, 1)); + assert_eq!(stats, TunnelStats::new(1, 1, 1, 1, 4, 1)); stats_received_tx.send(()).unwrap(); let mut disconnect_recv = connection.accept_uni().await.unwrap(); @@ -1044,6 +1081,16 @@ mod tests { assert_eq!(peer.role(), Role::Client); assert_eq!(peer.mac(), Some(MacAddr::new([0x02, 0, 0, 0, 0, 9]))); + assert!( + gateway + .send_ethernet(&control_plane_ethernet_frame()) + .is_err() + ); + assert!( + gateway + .send_ethernet(ðernet_frame_from(MacAddr::BROADCAST, b"invalid source")) + .is_err() + ); gateway.send_ethernet(ðernet_frame(b"to relay")).unwrap(); let received = tokio::time::timeout(Duration::from_secs(5), gateway.recv_ethernet()) .await @@ -1060,7 +1107,7 @@ mod tests { .is_err() ); let stats = gateway.stats_snapshot(); - assert_eq!(stats, TunnelStats::new(1, 1, 1, 1, 2, 1)); + assert_eq!(stats, TunnelStats::new(1, 1, 1, 1, 4, 1)); gateway.send_stats_snapshot().await.unwrap(); tokio::time::timeout(Duration::from_secs(5), stats_received_rx) @@ -1099,6 +1146,32 @@ mod tests { assert_eq!(snapshot.malformed_frames(), 1); } + #[test] + fn classifies_gateway_lan_local_drops() { + let normal_bytes = ethernet_frame(b"normal"); + let normal = EthernetFrame::parse(&normal_bytes).unwrap(); + assert_eq!(gateway_lan_drop_reason(normal), None); + + let invalid_source_bytes = ethernet_frame_from(MacAddr::BROADCAST, b"invalid"); + let invalid_source = EthernetFrame::parse(&invalid_source_bytes).unwrap(); + assert_eq!( + gateway_lan_drop_reason(invalid_source), + Some(DropReason::InvalidSourceMac) + ); + + let jumbo_payload = vec![0; lanparty_proto::MAX_STANDARD_ETHERNET_PAYLOAD_LEN + 1]; + let jumbo_bytes = ethernet_frame(&jumbo_payload); + let jumbo = EthernetFrame::parse(&jumbo_bytes).unwrap(); + assert_eq!(gateway_lan_drop_reason(jumbo), Some(DropReason::JumboFrame)); + + let control_plane_bytes = control_plane_ethernet_frame(); + let control_plane = EthernetFrame::parse(&control_plane_bytes).unwrap(); + assert_eq!( + gateway_lan_drop_reason(control_plane), + Some(DropReason::ControlPlaneEtherType) + ); + } + #[cfg(target_os = "linux")] #[test] fn builds_padded_cam_refresh_frame() { @@ -1247,10 +1320,28 @@ mod tests { } fn ethernet_frame_from(source: MacAddr, payload: &[u8]) -> Vec { + ethernet_frame_with_addresses(MacAddr::new([0x02, 0, 0, 0, 0, 2]), source, 0x0800, payload) + } + + fn control_plane_ethernet_frame() -> Vec { + ethernet_frame_with_addresses( + MacAddr::new([0x01, 0x80, 0xc2, 0, 0, 0]), + MacAddr::new([0x02, 0, 0, 0, 0, 1]), + ETHERTYPE_LLDP, + b"control", + ) + } + + fn ethernet_frame_with_addresses( + destination: MacAddr, + source: MacAddr, + ethertype: u16, + payload: &[u8], + ) -> Vec { let mut frame = Vec::new(); - frame.extend_from_slice(&[0x02, 0, 0, 0, 0, 2]); + frame.extend_from_slice(&destination.octets()); frame.extend_from_slice(&source.octets()); - frame.extend_from_slice(&0x0800_u16.to_be_bytes()); + frame.extend_from_slice(ðertype.to_be_bytes()); frame.extend_from_slice(payload); frame }