diff --git a/README.md b/README.md index 28a0c58..46b4afa 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ Public relay binary and relay-owned room state: - one gateway per room, duplicate client MAC rejection, and room limits - stable effective room MTU chosen before Ethernet datagrams flow - live Ethernet datagram forwarding with no ingress reflection +- L2 safety filters for jumbo, switch-control, DHCP-server, and IPv6-RA frames - peer leave cleanup for room membership and MAC indexes ## Build diff --git a/crates/lanparty-relay/src/lib.rs b/crates/lanparty-relay/src/lib.rs index 13886b1..c829074 100644 --- a/crates/lanparty-relay/src/lib.rs +++ b/crates/lanparty-relay/src/lib.rs @@ -13,13 +13,23 @@ use lanparty_ctrl::{ ControlError, EndpointHello, PeerInfo, Reject, RejectReason, Role, RoomCode, ServerWelcome, }; use lanparty_obs::{DropReason, FrameAction}; -use lanparty_proto::{EthernetFrame, MacAddr, recommended_tap_mtu}; +use lanparty_proto::{ETHERNET_HEADER_LEN, EthernetFrame, MacAddr, recommended_tap_mtu}; use thiserror::Error; pub use config::{ConfigError, DEFAULT_RELAY_PORT, ListenEndpoint, RelayArgs, RelayConfig}; pub use server::RelayServer; pub const DEFAULT_MAX_CLIENTS_PER_ROOM: usize = 16; +const ETHERTYPE_IPV4: u16 = 0x0800; +const ETHERTYPE_EAPOL: u16 = 0x888e; +const ETHERTYPE_SLOW_PROTOCOLS: u16 = 0x8809; +const ETHERTYPE_LLDP: u16 = 0x88cc; +const ETHERTYPE_IPV6: u16 = 0x86dd; +const IPV4_PROTOCOL_UDP: u8 = 17; +const IPV6_NEXT_HEADER_ICMPV6: u8 = 58; +const DHCP_SERVER_PORT: u16 = 67; +const DHCP_CLIENT_PORT: u16 = 68; +const ICMPV6_ROUTER_ADVERTISEMENT: u8 = 134; #[derive(Debug, Clone, PartialEq, Eq)] pub struct JoinAccepted { @@ -422,6 +432,10 @@ impl Room { } } + if let Some(drop_reason) = safety_drop_reason(ingress.role(), frame) { + return Ok(ForwardingDecision::dropped(drop_reason)); + } + let targets = if frame.destination().is_multicast() { self.all_peer_ids_except(ingress_peer_id) } else if let Some(client_peer_id) = self.clients_by_mac.get(&frame.destination()) { @@ -469,6 +483,78 @@ impl Room { } } +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_dhcp_server_reply(frame) { + return Some(DropReason::DhcpServerReply); + } + + if ingress_role == Role::Client && is_ipv6_router_advertisement(frame) { + return Some(DropReason::Ipv6RouterAdvertisement); + } + + 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_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 { + 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] != IPV4_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 == DHCP_SERVER_PORT && destination_port == DHCP_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 + 1 { + return false; + } + + let ipv6 = &bytes[ETHERNET_HEADER_LEN..]; + let version = ipv6[0] >> 4; + let next_header = ipv6[6]; + + version == 6 + && next_header == IPV6_NEXT_HEADER_ICMPV6 + && ipv6[40] == ICMPV6_ROUTER_ADVERTISEMENT +} + fn reject_control_error(error: ControlError) -> Reject { let reason = match error { ControlError::UnsupportedVersion { .. } => RejectReason::UnsupportedVersion, @@ -489,6 +575,7 @@ fn reject_control_error(error: ControlError) -> Reject { #[cfg(test)] mod tests { use super::*; + use lanparty_proto::MAX_STANDARD_ETHERNET_FRAME_LEN; fn room() -> RoomCode { RoomCode::new("ABCD").unwrap() @@ -507,13 +594,50 @@ mod tests { } fn ethernet(destination: MacAddr, source: MacAddr) -> Vec { + ethernet_with_payload(destination, source, ETHERTYPE_IPV4, &[]) + } + + fn ethernet_with_payload( + destination: MacAddr, + source: MacAddr, + ethertype_or_len: u16, + payload: &[u8], + ) -> Vec { let mut frame = Vec::new(); 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 } + fn ipv4_udp_payload(source_port: u16, destination_port: u16) -> Vec { + let mut packet = vec![0; 28]; + packet[0] = 0x45; + packet[9] = IPV4_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 ipv6_router_advertisement_payload() -> Vec { + let mut packet = vec![0; 41]; + packet[0] = 0x60; + packet[6] = IPV6_NEXT_HEADER_ICMPV6; + packet[40] = ICMPV6_ROUTER_ADVERTISEMENT; + packet + } + + fn physical_mac() -> MacAddr { + MacAddr::new([0x00, 1, 2, 3, 4, 5]) + } + + fn assert_dropped(decision: &ForwardingDecision, reason: DropReason) { + assert_eq!(decision.action(), FrameAction::Dropped); + assert_eq!(decision.drop_reason(), Some(reason)); + assert!(decision.targets().is_empty()); + } + #[test] fn accepts_gateway_and_client_into_room() { let mut registry = RoomRegistry::default(); @@ -717,6 +841,85 @@ mod tests { assert!(decision.targets().is_empty()); } + #[test] + fn drops_jumbo_frames() { + let mut registry = RoomRegistry::default(); + registry.join(gateway_hello()).unwrap(); + let client = registry.join(client_hello(1)).unwrap(); + let mut frame = ethernet(MacAddr::BROADCAST, mac(1)); + frame.resize(MAX_STANDARD_ETHERNET_FRAME_LEN + 1, 0); + + let decision = registry + .forward_ethernet(&room(), client.peer().peer_id(), &frame) + .unwrap(); + + assert_dropped(&decision, DropReason::JumboFrame); + } + + #[test] + fn drops_l2_control_plane_frames_from_clients_and_gateway() { + let mut registry = RoomRegistry::default(); + let gateway = registry.join(gateway_hello()).unwrap(); + let client = registry.join(client_hello(1)).unwrap(); + let stp_destination = MacAddr::new([0x01, 0x80, 0xc2, 0, 0, 0]); + let client_frame = ethernet_with_payload(stp_destination, mac(1), 0x0026, &[]); + let gateway_frame = + ethernet_with_payload(stp_destination, physical_mac(), ETHERTYPE_LLDP, &[]); + + 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::ControlPlaneEtherType); + assert_dropped(&gateway_decision, DropReason::ControlPlaneEtherType); + } + + #[test] + fn drops_remote_dhcp_server_replies_but_allows_lan_replies() { + let mut registry = RoomRegistry::default(); + let gateway = registry.join(gateway_hello()).unwrap(); + let client = registry.join(client_hello(1)).unwrap(); + let payload = ipv4_udp_payload(DHCP_SERVER_PORT, DHCP_CLIENT_PORT); + let client_frame = + ethernet_with_payload(MacAddr::BROADCAST, mac(1), ETHERTYPE_IPV4, &payload); + let gateway_frame = + ethernet_with_payload(MacAddr::BROADCAST, physical_mac(), ETHERTYPE_IPV4, &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::DhcpServerReply); + assert_eq!(gateway_decision.action(), FrameAction::Forwarded); + assert_eq!(gateway_decision.targets(), &[client.peer().peer_id()]); + } + + #[test] + fn drops_remote_ipv6_router_advertisements() { + let mut registry = RoomRegistry::default(); + registry.join(gateway_hello()).unwrap(); + let client = registry.join(client_hello(1)).unwrap(); + let destination = MacAddr::new([0x33, 0x33, 0, 0, 0, 1]); + let frame = ethernet_with_payload( + destination, + mac(1), + ETHERTYPE_IPV6, + &ipv6_router_advertisement_payload(), + ); + + let decision = registry + .forward_ethernet(&room(), client.peer().peer_id(), &frame) + .unwrap(); + + assert_dropped(&decision, DropReason::Ipv6RouterAdvertisement); + } + #[test] fn drops_malformed_frames() { let mut registry = RoomRegistry::default();