diff --git a/README.md b/README.md index 65ccf9c..829613d 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,8 @@ Platform-neutral remote client relay session: - QUIC DATAGRAM support and negotiated datagram budget diagnostics - relay RTT diagnostics from the active QUIC connection - reliable relay control-event reads for peer lifecycle messages -- Ethernet frame send/receive helpers over QUIC DATAGRAM with budget and source - MAC checks plus local drop outcomes for malformed or oversized sends +- Ethernet frame send/receive helpers over QUIC DATAGRAM with budget, source + MAC, and remote-to-LAN safety checks plus local drop outcomes - 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 @@ -252,10 +252,11 @@ after bridging starts become visible in later status lines. Each snapshot also emits short user-facing lines such as relay/gateway connection status, relay-route and TAP readiness warnings, DHCP address presence, relay RTT, and broadcast-flow confirmation when those signals are observed. Malformed frames -read from TAP, invalid or unauthorized source-MAC frames, jumbo frames, and TAP -frames whose encoded datagrams exceed the negotiated QUIC budget are counted and -dropped before relay send without stopping the bridge; TAP device read/write -errors still stop the bridge. +read from TAP, invalid or unauthorized source-MAC frames, L2 control-plane +traffic, remote VLAN tags, DHCP server replies, IPv6 Router Advertisements, IPv6 +fragments, jumbo frames, and TAP frames whose encoded datagrams exceed the +negotiated QUIC budget are counted and dropped before relay send without +stopping the bridge; TAP device read/write errors still stop the bridge. Relay lifecycle events are logged as they arrive, including gateway joins and peer leaves. The client remembers peer identities from join and catch-up events so later leave logs can identify a disconnected LAN gateway or client MAC when diff --git a/TESTING.md b/TESTING.md index 043a067..68ec516 100644 --- a/TESTING.md +++ b/TESTING.md @@ -214,6 +214,10 @@ drop_reason=Malformed drop_reason=InvalidSourceMac drop_reason=UnauthorizedSourceMac drop_reason=ControlPlaneEtherType +drop_reason=VlanTaggedFrame +drop_reason=DhcpServerReply +drop_reason=Ipv6RouterAdvertisement +drop_reason=Ipv6Fragment ``` ## Troubleshooting diff --git a/crates/lanparty-client-core/src/lib.rs b/crates/lanparty-client-core/src/lib.rs index 516079b..1f31292 100644 --- a/crates/lanparty-client-core/src/lib.rs +++ b/crates/lanparty-client-core/src/lib.rs @@ -27,7 +27,8 @@ use lanparty_ctrl::{ }; use lanparty_obs::{DropReason, QuicDiagnostics, TunnelStats}; use lanparty_proto::{ - EthernetFrame, FrameType, MacAddr, decode_datagram, encode_datagram, validate_datagram_budget, + EthernetFrame, FrameType, MacAddr, decode_datagram, encode_datagram, + remote_client_safety_drop_reason, validate_datagram_budget, }; use quinn::{ClientConfig, Endpoint, crypto::rustls::QuicClientConfig}; use rustls::pki_types::CertificateDer; @@ -381,10 +382,6 @@ impl ClientRelayIo { return Ok(ClientSendOutcome::Dropped(DropReason::Malformed)); } }; - if ethernet_frame.is_jumbo() { - self.stats.record_dropped_frame(); - return Ok(ClientSendOutcome::Dropped(DropReason::JumboFrame)); - } if !ethernet_frame.source().is_valid_unicast() { self.stats.record_dropped_frame(); return Ok(ClientSendOutcome::Dropped(DropReason::InvalidSourceMac)); @@ -395,6 +392,10 @@ impl ClientRelayIo { DropReason::UnauthorizedSourceMac, )); } + if let Some(drop_reason) = remote_client_safety_drop_reason(ethernet_frame) { + self.stats.record_dropped_frame(); + return Ok(ClientSendOutcome::Dropped(DropReason::from(drop_reason))); + } let datagram = encode_datagram( FrameType::Ethernet, @@ -824,7 +825,7 @@ mod tests { let ControlMessage::Stats(stats) = stats_message else { panic!("expected client stats event"); }; - assert_eq!(stats, TunnelStats::new(1, 1, 1, 1, 4, 1)); + assert_eq!(stats, TunnelStats::new(1, 1, 1, 1, 7, 1)); stats_received_tx.send(()).unwrap(); let mut disconnect_recv = connection.accept_uni().await.unwrap(); @@ -929,6 +930,25 @@ mod tests { .unwrap(), ClientSendOutcome::Dropped(DropReason::UnauthorizedSourceMac) ); + let jumbo_payload = vec![0; lanparty_proto::MAX_STANDARD_ETHERNET_PAYLOAD_LEN + 1]; + assert_eq!( + relay_io + .send_ethernet_with_outcome(ðernet_frame(&jumbo_payload)) + .unwrap(), + ClientSendOutcome::Dropped(DropReason::JumboFrame) + ); + assert_eq!( + relay_io + .send_ethernet_with_outcome(&control_plane_ethernet_frame()) + .unwrap(), + ClientSendOutcome::Dropped(DropReason::ControlPlaneEtherType) + ); + assert_eq!( + relay_io + .send_ethernet_with_outcome(&vlan_tagged_ethernet_frame()) + .unwrap(), + ClientSendOutcome::Dropped(DropReason::VlanTaggedFrame) + ); let stats = relay_io.stats_snapshot(); assert_eq!(stats.ethernet_frames_tx(), 1); assert_eq!(stats.ethernet_frames_rx(), 1); @@ -936,7 +956,7 @@ mod tests { assert_eq!(stats.broadcast_frames_rx(), 0); assert_eq!(stats.datagrams_tx(), 1); assert_eq!(stats.datagrams_rx(), 1); - assert_eq!(stats.dropped_frames(), 4); + assert_eq!(stats.dropped_frames(), 7); assert_eq!(stats.malformed_frames(), 1); assert_eq!(client.stats_snapshot(), stats); @@ -1012,10 +1032,42 @@ mod tests { } fn ethernet_frame_from(source: MacAddr, payload: &[u8]) -> Vec { + ethernet_frame_with_headers( + MacAddr::new([0x02, 0, 0, 0, 0, 2]), + source, + lanparty_proto::ETHERTYPE_IPV4, + payload, + ) + } + + fn control_plane_ethernet_frame() -> Vec { + ethernet_frame_with_headers( + MacAddr::new([0x01, 0x80, 0xc2, 0, 0, 0]), + MacAddr::new([0x02, 0, 0, 0, 0, 1]), + lanparty_proto::ETHERTYPE_LLDP, + b"control", + ) + } + + fn vlan_tagged_ethernet_frame() -> Vec { + ethernet_frame_with_headers( + MacAddr::BROADCAST, + MacAddr::new([0x02, 0, 0, 0, 0, 1]), + lanparty_proto::ETHERTYPE_8021Q, + &[0; 4], + ) + } + + fn ethernet_frame_with_headers( + destination: MacAddr, + source: MacAddr, + ethertype_or_len: 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_or_len.to_be_bytes()); frame.extend_from_slice(payload); frame } diff --git a/crates/lanparty-gateway/src/lib.rs b/crates/lanparty-gateway/src/lib.rs index 37e97f8..47e882e 100644 --- a/crates/lanparty-gateway/src/lib.rs +++ b/crates/lanparty-gateway/src/lib.rs @@ -32,7 +32,8 @@ use lanparty_obs::{DropReason, TunnelStats}; #[cfg(target_os = "linux")] use lanparty_obs::{FrameAction, FrameDirection, FrameLog}; use lanparty_proto::{ - EthernetFrame, FrameType, MacAddr, decode_datagram, encode_datagram, validate_datagram_budget, + EthernetFrame, FrameType, MacAddr, decode_datagram, encode_datagram, + gateway_lan_safety_drop_reason, validate_datagram_budget, }; use quinn::{ClientConfig, Endpoint, crypto::rustls::QuicClientConfig}; use rustls::pki_types::CertificateDer; @@ -44,9 +45,6 @@ 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")] @@ -430,9 +428,9 @@ fn send_gateway_ethernet( return Err(error).context("gateway Ethernet frame is malformed"); } }; - if let Some(drop_reason) = gateway_lan_drop_reason(ethernet_frame) { + if let Some(drop_reason) = gateway_lan_safety_drop_reason(ethernet_frame) { stats.record_dropped_frame(); - return Ok(GatewaySendOutcome::Dropped(drop_reason)); + return Ok(GatewaySendOutcome::Dropped(DropReason::from(drop_reason))); } let datagram = encode_datagram( @@ -456,35 +454,6 @@ 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, @@ -1146,32 +1115,6 @@ 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() { @@ -1327,7 +1270,7 @@ mod tests { ethernet_frame_with_addresses( MacAddr::new([0x01, 0x80, 0xc2, 0, 0, 0]), MacAddr::new([0x02, 0, 0, 0, 0, 1]), - ETHERTYPE_LLDP, + lanparty_proto::ETHERTYPE_LLDP, b"control", ) } diff --git a/crates/lanparty-obs/src/lib.rs b/crates/lanparty-obs/src/lib.rs index b8c23a5..c2d29ef 100644 --- a/crates/lanparty-obs/src/lib.rs +++ b/crates/lanparty-obs/src/lib.rs @@ -5,7 +5,7 @@ use std::net::IpAddr; -use lanparty_proto::{EthernetFrame, MacAddr}; +use lanparty_proto::{EthernetFrame, EthernetSafetyDrop, MacAddr}; #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)] #[serde(rename_all = "snake_case")] @@ -43,6 +43,20 @@ pub enum DropReason { RateLimit, } +impl From for DropReason { + fn from(value: EthernetSafetyDrop) -> Self { + match value { + EthernetSafetyDrop::InvalidSourceMac => Self::InvalidSourceMac, + EthernetSafetyDrop::JumboFrame => Self::JumboFrame, + EthernetSafetyDrop::ControlPlaneEtherType => Self::ControlPlaneEtherType, + EthernetSafetyDrop::VlanTaggedFrame => Self::VlanTaggedFrame, + EthernetSafetyDrop::DhcpServerReply => Self::DhcpServerReply, + EthernetSafetyDrop::Ipv6RouterAdvertisement => Self::Ipv6RouterAdvertisement, + EthernetSafetyDrop::Ipv6Fragment => Self::Ipv6Fragment, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)] pub struct FrameLog { direction: FrameDirection, diff --git a/crates/lanparty-proto/src/lib.rs b/crates/lanparty-proto/src/lib.rs index d678f3c..64b8a80 100644 --- a/crates/lanparty-proto/src/lib.rs +++ b/crates/lanparty-proto/src/lib.rs @@ -8,6 +8,7 @@ mod ethernet; mod mac; mod mtu; mod overlay; +mod safety; pub use ethernet::{ ETHERNET_HEADER_LEN, EthernetFrame, MAX_STANDARD_ETHERNET_FRAME_LEN, @@ -22,3 +23,8 @@ pub use overlay::{ FrameType, OVERLAY_HEADER_LEN, OVERLAY_MAGIC, OVERLAY_VERSION, OverlayHeader, OverlayPacket, ProtoError, decode_datagram, encode_datagram, validate_datagram_budget, }; +pub use safety::{ + ETHERTYPE_8021AD, ETHERTYPE_8021Q, ETHERTYPE_EAPOL, ETHERTYPE_IPV4, ETHERTYPE_IPV6, + ETHERTYPE_LLDP, ETHERTYPE_QINQ, ETHERTYPE_SLOW_PROTOCOLS, EthernetSafetyDrop, + gateway_lan_safety_drop_reason, remote_client_safety_drop_reason, +}; diff --git a/crates/lanparty-proto/src/safety.rs b/crates/lanparty-proto/src/safety.rs new file mode 100644 index 0000000..1b401fa --- /dev/null +++ b/crates/lanparty-proto/src/safety.rs @@ -0,0 +1,418 @@ +use crate::{ETHERNET_HEADER_LEN, EthernetFrame, MacAddr}; + +pub const ETHERTYPE_IPV4: u16 = 0x0800; +pub const ETHERTYPE_EAPOL: u16 = 0x888e; +pub const ETHERTYPE_SLOW_PROTOCOLS: u16 = 0x8809; +pub const ETHERTYPE_LLDP: u16 = 0x88cc; +pub const ETHERTYPE_IPV6: u16 = 0x86dd; +pub const ETHERTYPE_8021Q: u16 = 0x8100; +pub const ETHERTYPE_8021AD: u16 = 0x88a8; +pub const ETHERTYPE_QINQ: u16 = 0x9100; + +const IP_PROTOCOL_UDP: u8 = 17; +const IPV6_NEXT_HEADER_HOP_BY_HOP: u8 = 0; +const IPV6_NEXT_HEADER_ROUTING: u8 = 43; +const IPV6_NEXT_HEADER_FRAGMENT: u8 = 44; +const IPV6_NEXT_HEADER_AH: u8 = 51; +const IPV6_NEXT_HEADER_NO_NEXT: u8 = 59; +const IPV6_NEXT_HEADER_DESTINATION_OPTIONS: u8 = 60; +const IPV6_NEXT_HEADER_ICMPV6: u8 = 58; +const DHCPV4_SERVER_PORT: u16 = 67; +const DHCPV4_CLIENT_PORT: u16 = 68; +const DHCPV6_CLIENT_PORT: u16 = 546; +const DHCPV6_SERVER_PORT: u16 = 547; +const ICMPV6_ROUTER_ADVERTISEMENT: u8 = 134; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EthernetSafetyDrop { + InvalidSourceMac, + JumboFrame, + ControlPlaneEtherType, + VlanTaggedFrame, + DhcpServerReply, + Ipv6RouterAdvertisement, + Ipv6Fragment, +} + +pub fn gateway_lan_safety_drop_reason(frame: EthernetFrame<'_>) -> Option { + if !frame.source().is_valid_unicast() { + return Some(EthernetSafetyDrop::InvalidSourceMac); + } + + common_safety_drop_reason(frame) +} + +pub fn remote_client_safety_drop_reason(frame: EthernetFrame<'_>) -> Option { + if let Some(drop_reason) = common_safety_drop_reason(frame) { + return Some(drop_reason); + } + + if is_vlan_tagged_frame(frame) { + return Some(EthernetSafetyDrop::VlanTaggedFrame); + } + + if is_dhcp_server_reply(frame) { + return Some(EthernetSafetyDrop::DhcpServerReply); + } + + if is_ipv6_router_advertisement(frame) { + return Some(EthernetSafetyDrop::Ipv6RouterAdvertisement); + } + + if is_ipv6_fragment(frame) { + return Some(EthernetSafetyDrop::Ipv6Fragment); + } + + None +} + +fn common_safety_drop_reason(frame: EthernetFrame<'_>) -> Option { + if frame.is_jumbo() { + return Some(EthernetSafetyDrop::JumboFrame); + } + + if is_control_plane_frame(frame) { + return Some(EthernetSafetyDrop::ControlPlaneEtherType); + } + + None +} + +fn is_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_vlan_tagged_frame(frame: EthernetFrame<'_>) -> bool { + matches!( + frame.ethertype_or_len(), + ETHERTYPE_8021Q | ETHERTYPE_8021AD | ETHERTYPE_QINQ + ) +} + +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 +} + +fn is_dhcp_server_reply(frame: EthernetFrame<'_>) -> bool { + is_ipv4_dhcp_server_reply(frame) || is_ipv6_dhcp_server_reply(frame) +} + +fn is_ipv4_dhcp_server_reply(frame: EthernetFrame<'_>) -> bool { + let bytes = frame.bytes(); + if frame.ethertype_or_len() != ETHERTYPE_IPV4 || bytes.len() < ETHERNET_HEADER_LEN + 20 { + return false; + } + + let ipv4 = &bytes[ETHERNET_HEADER_LEN..]; + let version = ipv4[0] >> 4; + let header_len = usize::from(ipv4[0] & 0x0f) * 4; + if version != 4 || header_len < 20 || ipv4.len() < header_len + 8 || ipv4[9] != IP_PROTOCOL_UDP + { + return false; + } + + let udp = &ipv4[header_len..]; + let source_port = u16::from_be_bytes([udp[0], udp[1]]); + let destination_port = u16::from_be_bytes([udp[2], udp[3]]); + + source_port == DHCPV4_SERVER_PORT && destination_port == DHCPV4_CLIENT_PORT +} + +fn is_ipv6_dhcp_server_reply(frame: EthernetFrame<'_>) -> bool { + let bytes = frame.bytes(); + if frame.ethertype_or_len() != ETHERTYPE_IPV6 || bytes.len() < ETHERNET_HEADER_LEN + 40 { + return false; + } + + let ipv6 = &bytes[ETHERNET_HEADER_LEN..]; + if !is_ipv6_packet(ipv6) { + return false; + } + + let Some(udp_offset) = ipv6_upper_layer_payload_offset(ipv6, IP_PROTOCOL_UDP) else { + return false; + }; + let Some(udp) = ipv6.get(udp_offset..udp_offset.saturating_add(4)) else { + return false; + }; + let source_port = u16::from_be_bytes([udp[0], udp[1]]); + let destination_port = u16::from_be_bytes([udp[2], udp[3]]); + + source_port == DHCPV6_SERVER_PORT && destination_port == DHCPV6_CLIENT_PORT +} + +fn is_ipv6_router_advertisement(frame: EthernetFrame<'_>) -> bool { + let bytes = frame.bytes(); + if frame.ethertype_or_len() != ETHERTYPE_IPV6 || bytes.len() < ETHERNET_HEADER_LEN + 40 { + return false; + } + + let ipv6 = &bytes[ETHERNET_HEADER_LEN..]; + if !is_ipv6_packet(ipv6) { + return false; + } + + let Some(icmpv6_offset) = ipv6_upper_layer_payload_offset(ipv6, IPV6_NEXT_HEADER_ICMPV6) else { + return false; + }; + + ipv6.get(icmpv6_offset) + .is_some_and(|message_type| *message_type == ICMPV6_ROUTER_ADVERTISEMENT) +} + +fn is_ipv6_fragment(frame: EthernetFrame<'_>) -> bool { + let bytes = frame.bytes(); + if frame.ethertype_or_len() != ETHERTYPE_IPV6 || bytes.len() < ETHERNET_HEADER_LEN + 40 { + return false; + } + + let ipv6 = &bytes[ETHERNET_HEADER_LEN..]; + is_ipv6_packet(ipv6) && ipv6_has_extension_header(ipv6, IPV6_NEXT_HEADER_FRAGMENT) +} + +fn is_ipv6_packet(ipv6: &[u8]) -> bool { + ipv6.first().is_some_and(|first| first >> 4 == 6) +} + +fn ipv6_has_extension_header(ipv6: &[u8], expected_next_header: u8) -> bool { + ipv6_upper_layer_payload_offset(ipv6, expected_next_header).is_some() +} + +fn ipv6_upper_layer_payload_offset(ipv6: &[u8], expected_next_header: u8) -> Option { + let mut next_header = *ipv6.get(6)?; + let mut offset = 40_usize; + + loop { + match next_header { + next_header if next_header == expected_next_header => return Some(offset), + IPV6_NEXT_HEADER_NO_NEXT => return None, + IPV6_NEXT_HEADER_HOP_BY_HOP + | IPV6_NEXT_HEADER_ROUTING + | IPV6_NEXT_HEADER_DESTINATION_OPTIONS => { + let extension = ipv6.get(offset..offset.checked_add(2)?)?; + next_header = extension[0]; + let extension_len = (usize::from(extension[1]) + 1) * 8; + offset = offset.checked_add(extension_len)?; + if offset > ipv6.len() { + return None; + } + } + IPV6_NEXT_HEADER_FRAGMENT => { + let fragment = ipv6.get(offset..offset.checked_add(8)?)?; + next_header = fragment[0]; + offset = offset.checked_add(8)?; + } + IPV6_NEXT_HEADER_AH => { + let extension = ipv6.get(offset..offset.checked_add(2)?)?; + next_header = extension[0]; + let extension_len = (usize::from(extension[1]) + 2) * 4; + offset = offset.checked_add(extension_len)?; + if offset > ipv6.len() { + return None; + } + } + _ => return None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::MAX_STANDARD_ETHERNET_PAYLOAD_LEN; + + #[test] + fn classifies_gateway_lan_safety_drops() { + assert_eq!(gateway_lan_safety_drop_reason(ethernet()), None); + let jumbo_payload = vec![0; MAX_STANDARD_ETHERNET_PAYLOAD_LEN + 1]; + assert_eq!( + gateway_lan_safety_drop_reason(frame_with_source(MacAddr::BROADCAST)), + Some(EthernetSafetyDrop::InvalidSourceMac) + ); + assert_eq!( + gateway_lan_safety_drop_reason(ethernet_with_payload( + MacAddr::new([0x02, 0, 0, 0, 0, 2]), + MacAddr::new([0x02, 0, 0, 0, 0, 1]), + ETHERTYPE_IPV4, + &jumbo_payload, + )), + Some(EthernetSafetyDrop::JumboFrame) + ); + assert_eq!( + gateway_lan_safety_drop_reason(control_plane_frame()), + Some(EthernetSafetyDrop::ControlPlaneEtherType) + ); + assert_eq!( + gateway_lan_safety_drop_reason(ethernet_with_payload( + MacAddr::new([0x02, 0, 0, 0, 0, 2]), + MacAddr::new([0x02, 0, 0, 0, 0, 1]), + ETHERTYPE_8021Q, + &[0; 4], + )), + None + ); + } + + #[test] + fn classifies_remote_client_safety_drops() { + assert_eq!(remote_client_safety_drop_reason(ethernet()), None); + assert_eq!( + remote_client_safety_drop_reason(control_plane_frame()), + Some(EthernetSafetyDrop::ControlPlaneEtherType) + ); + assert_eq!( + remote_client_safety_drop_reason(ethernet_with_payload( + MacAddr::BROADCAST, + MacAddr::new([0x02, 0, 0, 0, 0, 1]), + ETHERTYPE_8021Q, + &[0; 4], + )), + Some(EthernetSafetyDrop::VlanTaggedFrame) + ); + assert_eq!( + remote_client_safety_drop_reason(ethernet_with_payload( + MacAddr::BROADCAST, + MacAddr::new([0x02, 0, 0, 0, 0, 1]), + ETHERTYPE_IPV4, + &ipv4_udp_payload(DHCPV4_SERVER_PORT, DHCPV4_CLIENT_PORT), + )), + Some(EthernetSafetyDrop::DhcpServerReply) + ); + assert_eq!( + remote_client_safety_drop_reason(ethernet_with_payload( + MacAddr::BROADCAST, + MacAddr::new([0x02, 0, 0, 0, 0, 1]), + ETHERTYPE_IPV6, + &ipv6_udp_after_destination_options_payload(DHCPV6_SERVER_PORT, DHCPV6_CLIENT_PORT,), + )), + Some(EthernetSafetyDrop::DhcpServerReply) + ); + assert_eq!( + remote_client_safety_drop_reason(ethernet_with_payload( + MacAddr::BROADCAST, + MacAddr::new([0x02, 0, 0, 0, 0, 1]), + ETHERTYPE_IPV6, + &ipv6_router_advertisement_after_destination_options_payload(), + )), + Some(EthernetSafetyDrop::Ipv6RouterAdvertisement) + ); + assert_eq!( + remote_client_safety_drop_reason(ethernet_with_payload( + MacAddr::BROADCAST, + MacAddr::new([0x02, 0, 0, 0, 0, 1]), + ETHERTYPE_IPV6, + &ipv6_payload( + IPV6_NEXT_HEADER_FRAGMENT, + &ipv6_fragment_payload(IP_PROTOCOL_UDP, b"fragment"), + ), + )), + Some(EthernetSafetyDrop::Ipv6Fragment) + ); + } + + fn ethernet() -> EthernetFrame<'static> { + ethernet_with_payload( + MacAddr::new([0x02, 0, 0, 0, 0, 2]), + MacAddr::new([0x02, 0, 0, 0, 0, 1]), + ETHERTYPE_IPV4, + &[], + ) + } + + fn frame_with_source(source: MacAddr) -> EthernetFrame<'static> { + ethernet_with_payload( + MacAddr::new([0x02, 0, 0, 0, 0, 2]), + source, + ETHERTYPE_IPV4, + &[], + ) + } + + fn control_plane_frame() -> EthernetFrame<'static> { + ethernet_with_payload( + MacAddr::new([0x01, 0x80, 0xc2, 0, 0, 0]), + MacAddr::new([0x02, 0, 0, 0, 0, 1]), + ETHERTYPE_LLDP, + &[], + ) + } + + fn ethernet_with_payload( + destination: MacAddr, + source: MacAddr, + ethertype_or_len: u16, + payload: &[u8], + ) -> EthernetFrame<'static> { + let mut frame = Vec::new(); + frame.extend_from_slice(&destination.octets()); + frame.extend_from_slice(&source.octets()); + frame.extend_from_slice(ðertype_or_len.to_be_bytes()); + frame.extend_from_slice(payload); + EthernetFrame::parse(frame.leak()).unwrap() + } + + fn ipv4_udp_payload(source_port: u16, destination_port: u16) -> Vec { + let mut packet = vec![0; 28]; + packet[0] = 0x45; + packet[9] = IP_PROTOCOL_UDP; + packet[20..22].copy_from_slice(&source_port.to_be_bytes()); + packet[22..24].copy_from_slice(&destination_port.to_be_bytes()); + packet + } + + fn udp_payload(source_port: u16, destination_port: u16) -> Vec { + let mut packet = vec![0; 8]; + packet[0..2].copy_from_slice(&source_port.to_be_bytes()); + packet[2..4].copy_from_slice(&destination_port.to_be_bytes()); + packet + } + + fn ipv6_udp_after_destination_options_payload( + source_port: u16, + destination_port: u16, + ) -> Vec { + ipv6_payload( + IPV6_NEXT_HEADER_DESTINATION_OPTIONS, + &ipv6_extension_payload(IP_PROTOCOL_UDP, &udp_payload(source_port, destination_port)), + ) + } + + fn ipv6_router_advertisement_after_destination_options_payload() -> Vec { + ipv6_payload( + IPV6_NEXT_HEADER_DESTINATION_OPTIONS, + &ipv6_extension_payload(IPV6_NEXT_HEADER_ICMPV6, &[ICMPV6_ROUTER_ADVERTISEMENT]), + ) + } + + fn ipv6_fragment_payload(next_header: u8, fragment_payload: &[u8]) -> Vec { + let mut packet = Vec::with_capacity(8 + fragment_payload.len()); + packet.push(next_header); + packet.push(0); + packet.extend_from_slice(&0_u16.to_be_bytes()); + packet.extend_from_slice(&1_u32.to_be_bytes()); + packet.extend_from_slice(fragment_payload); + packet + } + + fn ipv6_payload(next_header: u8, upper_payload: &[u8]) -> Vec { + let mut packet = vec![0; 40]; + packet[0] = 0x60; + packet[6] = next_header; + packet.extend_from_slice(upper_payload); + packet + } + + fn ipv6_extension_payload(next_header: u8, upper_payload: &[u8]) -> Vec { + let mut packet = Vec::with_capacity(8 + upper_payload.len()); + packet.push(next_header); + packet.push(0); + packet.extend_from_slice(&[0; 6]); + packet.extend_from_slice(upper_payload); + packet + } +} diff --git a/crates/lanparty-relay/src/lib.rs b/crates/lanparty-relay/src/lib.rs index 7a39593..1762c0c 100644 --- a/crates/lanparty-relay/src/lib.rs +++ b/crates/lanparty-relay/src/lib.rs @@ -13,7 +13,10 @@ use lanparty_ctrl::{ ControlError, EndpointHello, PeerInfo, Reject, RejectReason, Role, RoomCode, ServerWelcome, }; use lanparty_obs::{DropReason, FrameAction}; -use lanparty_proto::{ETHERNET_HEADER_LEN, EthernetFrame, MacAddr, recommended_tap_mtu}; +use lanparty_proto::{ + EthernetFrame, MacAddr, gateway_lan_safety_drop_reason, recommended_tap_mtu, + remote_client_safety_drop_reason, +}; use thiserror::Error; pub use config::{ConfigError, DEFAULT_RELAY_PORT, ListenEndpoint, RelayArgs, RelayConfig}; @@ -27,26 +30,35 @@ const CLIENT_UNKNOWN_UNICAST_BURST_FRAMES: u32 = 64; 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; -const ETHERTYPE_EAPOL: u16 = 0x888e; -const ETHERTYPE_SLOW_PROTOCOLS: u16 = 0x8809; +#[cfg(test)] const ETHERTYPE_LLDP: u16 = 0x88cc; +#[cfg(test)] const ETHERTYPE_IPV6: u16 = 0x86dd; +#[cfg(test)] const ETHERTYPE_8021Q: u16 = 0x8100; +#[cfg(test)] const ETHERTYPE_8021AD: u16 = 0x88a8; -const ETHERTYPE_QINQ: u16 = 0x9100; +#[cfg(test)] const IP_PROTOCOL_UDP: u8 = 17; -const IPV6_NEXT_HEADER_HOP_BY_HOP: u8 = 0; -const IPV6_NEXT_HEADER_ROUTING: u8 = 43; +#[cfg(test)] const IPV6_NEXT_HEADER_FRAGMENT: u8 = 44; -const IPV6_NEXT_HEADER_AH: u8 = 51; -const IPV6_NEXT_HEADER_NO_NEXT: u8 = 59; +#[cfg(test)] const IPV6_NEXT_HEADER_DESTINATION_OPTIONS: u8 = 60; +#[cfg(test)] const IPV6_NEXT_HEADER_ICMPV6: u8 = 58; +#[cfg(test)] const DHCPV4_SERVER_PORT: u16 = 67; +#[cfg(test)] const DHCPV4_CLIENT_PORT: u16 = 68; +#[cfg(test)] const DHCPV6_CLIENT_PORT: u16 = 546; +#[cfg(test)] const DHCPV6_SERVER_PORT: u16 = 547; +#[cfg(test)] const ICMPV6_ROUTER_ADVERTISEMENT: u8 = 134; #[derive(Debug, Clone, PartialEq, Eq)] @@ -542,8 +554,12 @@ impl Room { self.mark_peer_seen(ingress_peer_id, now); - if let Some(drop_reason) = safety_drop_reason(ingress_role, frame) { - return Ok(ForwardingDecision::filtered(drop_reason)); + let safety_drop_reason = match ingress_role { + Role::Client => remote_client_safety_drop_reason(frame), + Role::Gateway => gateway_lan_safety_drop_reason(frame), + }; + if let Some(drop_reason) = safety_drop_reason { + return Ok(ForwardingDecision::filtered(DropReason::from(drop_reason))); } if ingress_role == Role::Client @@ -726,177 +742,6 @@ fn client_total_bandwidth_limit() -> TokenBucket { ) } -fn safety_drop_reason(ingress_role: Role, frame: EthernetFrame<'_>) -> Option { - if frame.is_jumbo() { - return Some(DropReason::JumboFrame); - } - - if is_control_plane_frame(frame) { - return Some(DropReason::ControlPlaneEtherType); - } - - if ingress_role == Role::Client && is_vlan_tagged_frame(frame) { - return Some(DropReason::VlanTaggedFrame); - } - - if ingress_role == Role::Client && is_dhcp_server_reply(frame) { - return Some(DropReason::DhcpServerReply); - } - - if ingress_role == Role::Client && is_ipv6_router_advertisement(frame) { - return Some(DropReason::Ipv6RouterAdvertisement); - } - - if ingress_role == Role::Client && is_ipv6_fragment(frame) { - return Some(DropReason::Ipv6Fragment); - } - - None -} - -fn is_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_vlan_tagged_frame(frame: EthernetFrame<'_>) -> bool { - matches!( - frame.ethertype_or_len(), - ETHERTYPE_8021Q | ETHERTYPE_8021AD | ETHERTYPE_QINQ - ) -} - -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 -} - -fn is_dhcp_server_reply(frame: EthernetFrame<'_>) -> bool { - is_ipv4_dhcp_server_reply(frame) || is_ipv6_dhcp_server_reply(frame) -} - -fn is_ipv4_dhcp_server_reply(frame: EthernetFrame<'_>) -> bool { - let bytes = frame.bytes(); - if frame.ethertype_or_len() != ETHERTYPE_IPV4 || bytes.len() < ETHERNET_HEADER_LEN + 20 { - return false; - } - - let ipv4 = &bytes[ETHERNET_HEADER_LEN..]; - let version = ipv4[0] >> 4; - let header_len = usize::from(ipv4[0] & 0x0f) * 4; - if version != 4 || header_len < 20 || ipv4.len() < header_len + 8 || ipv4[9] != IP_PROTOCOL_UDP - { - return false; - } - - let udp = &ipv4[header_len..]; - let source_port = u16::from_be_bytes([udp[0], udp[1]]); - let destination_port = u16::from_be_bytes([udp[2], udp[3]]); - - source_port == DHCPV4_SERVER_PORT && destination_port == DHCPV4_CLIENT_PORT -} - -fn is_ipv6_dhcp_server_reply(frame: EthernetFrame<'_>) -> bool { - let bytes = frame.bytes(); - if frame.ethertype_or_len() != ETHERTYPE_IPV6 || bytes.len() < ETHERNET_HEADER_LEN + 40 { - return false; - } - - let ipv6 = &bytes[ETHERNET_HEADER_LEN..]; - if !is_ipv6_packet(ipv6) { - return false; - } - - let Some(udp_offset) = ipv6_upper_layer_payload_offset(ipv6, IP_PROTOCOL_UDP) else { - return false; - }; - let Some(udp) = ipv6.get(udp_offset..udp_offset.saturating_add(4)) else { - return false; - }; - let source_port = u16::from_be_bytes([udp[0], udp[1]]); - let destination_port = u16::from_be_bytes([udp[2], udp[3]]); - - source_port == DHCPV6_SERVER_PORT && destination_port == DHCPV6_CLIENT_PORT -} - -fn is_ipv6_router_advertisement(frame: EthernetFrame<'_>) -> bool { - let bytes = frame.bytes(); - if frame.ethertype_or_len() != ETHERTYPE_IPV6 || bytes.len() < ETHERNET_HEADER_LEN + 40 { - return false; - } - - let ipv6 = &bytes[ETHERNET_HEADER_LEN..]; - if !is_ipv6_packet(ipv6) { - return false; - } - - let Some(icmpv6_offset) = ipv6_upper_layer_payload_offset(ipv6, IPV6_NEXT_HEADER_ICMPV6) else { - return false; - }; - - ipv6.get(icmpv6_offset) - .is_some_and(|message_type| *message_type == ICMPV6_ROUTER_ADVERTISEMENT) -} - -fn is_ipv6_fragment(frame: EthernetFrame<'_>) -> bool { - let bytes = frame.bytes(); - if frame.ethertype_or_len() != ETHERTYPE_IPV6 || bytes.len() < ETHERNET_HEADER_LEN + 40 { - return false; - } - - let ipv6 = &bytes[ETHERNET_HEADER_LEN..]; - is_ipv6_packet(ipv6) && ipv6_has_extension_header(ipv6, IPV6_NEXT_HEADER_FRAGMENT) -} - -fn is_ipv6_packet(ipv6: &[u8]) -> bool { - ipv6.first().is_some_and(|first| first >> 4 == 6) -} - -fn ipv6_has_extension_header(ipv6: &[u8], expected_next_header: u8) -> bool { - ipv6_upper_layer_payload_offset(ipv6, expected_next_header).is_some() -} - -fn ipv6_upper_layer_payload_offset(ipv6: &[u8], expected_next_header: u8) -> Option { - let mut next_header = *ipv6.get(6)?; - let mut offset = 40; - - loop { - match next_header { - next_header if next_header == expected_next_header => return Some(offset), - IPV6_NEXT_HEADER_NO_NEXT => return None, - IPV6_NEXT_HEADER_HOP_BY_HOP - | IPV6_NEXT_HEADER_ROUTING - | IPV6_NEXT_HEADER_DESTINATION_OPTIONS => { - let extension = ipv6.get(offset..offset.checked_add(2)?)?; - next_header = extension[0]; - let extension_len = (usize::from(extension[1]) + 1) * 8; - offset = offset.checked_add(extension_len)?; - if offset > ipv6.len() { - return None; - } - } - IPV6_NEXT_HEADER_FRAGMENT => { - let fragment = ipv6.get(offset..offset.checked_add(8)?)?; - next_header = fragment[0]; - offset = offset.checked_add(8)?; - } - IPV6_NEXT_HEADER_AH => { - let extension = ipv6.get(offset..offset.checked_add(2)?)?; - next_header = extension[0]; - let extension_len = (usize::from(extension[1]) + 2) * 4; - offset = offset.checked_add(extension_len)?; - if offset > ipv6.len() { - return None; - } - } - _ => return None, - } - } -} - fn reject_control_error(error: ControlError) -> Reject { let reason = match error { ControlError::UnsupportedVersion { .. } => RejectReason::UnsupportedVersion,