diff --git a/Cargo.lock b/Cargo.lock index 2965411..a0286f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,7 +52,9 @@ name = "lanparty-relay" version = "0.1.0" dependencies = [ "lanparty-ctrl", + "lanparty-obs", "lanparty-proto", + "thiserror", ] [[package]] diff --git a/README.md b/README.md index cc530f4..88b9489 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ Public relay binary and relay-owned room state: - room admission for clients and gateways - one gateway per room, duplicate client MAC rejection, and room limits - stable effective room MTU chosen before Ethernet datagrams flow +- Ethernet datagram forwarding decisions with no ingress reflection ## Build diff --git a/crates/lanparty-relay/Cargo.toml b/crates/lanparty-relay/Cargo.toml index 3e27090..3119434 100644 --- a/crates/lanparty-relay/Cargo.toml +++ b/crates/lanparty-relay/Cargo.toml @@ -5,4 +5,6 @@ edition.workspace = true [dependencies] lanparty-ctrl = { path = "../lanparty-ctrl" } +lanparty-obs = { path = "../lanparty-obs" } lanparty-proto = { path = "../lanparty-proto" } +thiserror.workspace = true diff --git a/crates/lanparty-relay/src/lib.rs b/crates/lanparty-relay/src/lib.rs index b009cf1..972e4d0 100644 --- a/crates/lanparty-relay/src/lib.rs +++ b/crates/lanparty-relay/src/lib.rs @@ -8,7 +8,9 @@ use std::collections::HashMap; use lanparty_ctrl::{ ControlError, EndpointHello, PeerInfo, Reject, RejectReason, Role, RoomCode, ServerWelcome, }; -use lanparty_proto::{MacAddr, recommended_tap_mtu}; +use lanparty_obs::{DropReason, FrameAction}; +use lanparty_proto::{EthernetFrame, MacAddr, recommended_tap_mtu}; +use thiserror::Error; pub const DEFAULT_MAX_CLIENTS_PER_ROOM: usize = 16; @@ -65,6 +67,56 @@ impl RoomSnapshot { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ForwardingDecision { + targets: Vec, + action: FrameAction, + drop_reason: Option, +} + +impl ForwardingDecision { + #[must_use] + fn forwarded(targets: Vec) -> Self { + Self { + targets, + action: FrameAction::Forwarded, + drop_reason: None, + } + } + + #[must_use] + fn dropped(drop_reason: DropReason) -> Self { + Self { + targets: Vec::new(), + action: FrameAction::Dropped, + drop_reason: Some(drop_reason), + } + } + + #[must_use] + pub fn targets(&self) -> &[u32] { + &self.targets + } + + #[must_use] + pub const fn action(&self) -> FrameAction { + self.action + } + + #[must_use] + pub const fn drop_reason(&self) -> Option { + self.drop_reason + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum ForwardingError { + #[error("room {0} does not exist")] + UnknownRoom(RoomCode), + #[error("ingress peer {peer_id} is not present in room {room}")] + UnknownIngressPeer { room: RoomCode, peer_id: u32 }, +} + #[derive(Debug, Clone)] pub struct RoomRegistry { rooms: HashMap, @@ -125,6 +177,18 @@ impl RoomRegistry { self.rooms.get(room).map(Room::snapshot) } + pub fn forward_ethernet( + &self, + room: &RoomCode, + ingress_peer_id: u32, + frame_bytes: &[u8], + ) -> Result { + self.rooms + .get(room) + .ok_or_else(|| ForwardingError::UnknownRoom(room.clone()))? + .forward_ethernet(room, ingress_peer_id, frame_bytes) + } + fn allocate_room_id(&mut self) -> Result { let room_id = self.next_room_id; self.next_room_id = self @@ -260,6 +324,78 @@ impl Room { clients, } } + + fn forward_ethernet( + &self, + room_code: &RoomCode, + ingress_peer_id: u32, + frame_bytes: &[u8], + ) -> Result { + let ingress = + self.peer(ingress_peer_id) + .ok_or_else(|| ForwardingError::UnknownIngressPeer { + room: room_code.clone(), + peer_id: ingress_peer_id, + })?; + let frame = match EthernetFrame::parse(frame_bytes) { + Ok(frame) => frame, + Err(_) => return Ok(ForwardingDecision::dropped(DropReason::Malformed)), + }; + + if ingress.role() == Role::Client { + let expected_source = ingress.mac().expect("client peers have MAC addresses"); + if frame.source() != expected_source { + return Ok(ForwardingDecision::dropped( + DropReason::UnauthorizedSourceMac, + )); + } + } + + 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()) { + if *client_peer_id == ingress_peer_id { + Vec::new() + } else { + vec![*client_peer_id] + } + } else { + self.all_peer_ids_except(ingress_peer_id) + }; + + if targets.is_empty() { + return Ok(ForwardingDecision::dropped(DropReason::UnknownDestination)); + } + + Ok(ForwardingDecision::forwarded(targets)) + } + + fn peer(&self, peer_id: u32) -> Option<&PeerInfo> { + self.gateway + .as_ref() + .filter(|peer| peer.peer_id() == peer_id) + .or_else(|| self.clients.get(&peer_id)) + } + + fn all_peer_ids_except(&self, ingress_peer_id: u32) -> Vec { + let mut peer_ids = + Vec::with_capacity(self.clients.len() + usize::from(self.gateway.is_some())); + + if let Some(gateway) = &self.gateway + && gateway.peer_id() != ingress_peer_id + { + peer_ids.push(gateway.peer_id()); + } + + peer_ids.extend( + self.clients + .keys() + .copied() + .filter(|peer_id| *peer_id != ingress_peer_id), + ); + peer_ids.sort_unstable(); + peer_ids + } } fn reject_control_error(error: ControlError) -> Reject { @@ -299,6 +435,14 @@ mod tests { EndpointHello::gateway(room(), 1400).unwrap() } + fn ethernet(destination: MacAddr, source: MacAddr) -> 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 + } + #[test] fn accepts_gateway_and_client_into_room() { let mut registry = RoomRegistry::default(); @@ -371,4 +515,104 @@ mod tests { assert_eq!(gateway.welcome().effective_tap_mtu(), 972); } + + #[test] + fn forwards_unknown_client_unicast_to_gateway() { + let mut registry = RoomRegistry::default(); + let gateway = registry.join(gateway_hello()).unwrap(); + let client = registry.join(client_hello(1)).unwrap(); + let frame = ethernet(MacAddr::new([0x00, 1, 2, 3, 4, 5]), mac(1)); + + let decision = registry + .forward_ethernet(&room(), client.peer().peer_id(), &frame) + .unwrap(); + + assert_eq!(decision.action(), FrameAction::Forwarded); + assert_eq!(decision.targets(), &[gateway.peer().peer_id()]); + assert_eq!(decision.drop_reason(), None); + } + + #[test] + fn forwards_gateway_unicast_to_matching_client() { + let mut registry = RoomRegistry::default(); + let gateway = registry.join(gateway_hello()).unwrap(); + let client_one = registry.join(client_hello(1)).unwrap(); + let client_two = registry.join(client_hello(2)).unwrap(); + let frame = ethernet(mac(2), MacAddr::new([0x00, 1, 2, 3, 4, 5])); + + let decision = registry + .forward_ethernet(&room(), gateway.peer().peer_id(), &frame) + .unwrap(); + + assert_eq!(decision.action(), FrameAction::Forwarded); + assert_eq!(decision.targets(), &[client_two.peer().peer_id()]); + assert!(!decision.targets().contains(&client_one.peer().peer_id())); + } + + #[test] + fn floods_broadcast_without_reflecting_ingress() { + let mut registry = RoomRegistry::default(); + let gateway = registry.join(gateway_hello()).unwrap(); + let client_one = registry.join(client_hello(1)).unwrap(); + let client_two = registry.join(client_hello(2)).unwrap(); + let frame = ethernet(MacAddr::BROADCAST, mac(1)); + + let decision = registry + .forward_ethernet(&room(), client_one.peer().peer_id(), &frame) + .unwrap(); + + assert_eq!(decision.action(), FrameAction::Forwarded); + assert_eq!( + decision.targets(), + &[gateway.peer().peer_id(), client_two.peer().peer_id()] + ); + } + + #[test] + fn drops_client_frames_with_forged_source_mac() { + let mut registry = RoomRegistry::default(); + let client = registry.join(client_hello(1)).unwrap(); + let frame = ethernet(MacAddr::BROADCAST, mac(2)); + + let decision = registry + .forward_ethernet(&room(), client.peer().peer_id(), &frame) + .unwrap(); + + assert_eq!(decision.action(), FrameAction::Dropped); + assert_eq!( + decision.drop_reason(), + Some(DropReason::UnauthorizedSourceMac) + ); + assert!(decision.targets().is_empty()); + } + + #[test] + fn drops_malformed_frames() { + let mut registry = RoomRegistry::default(); + let client = registry.join(client_hello(1)).unwrap(); + + let decision = registry + .forward_ethernet(&room(), client.peer().peer_id(), &[0; 4]) + .unwrap(); + + assert_eq!(decision.action(), FrameAction::Dropped); + assert_eq!(decision.drop_reason(), Some(DropReason::Malformed)); + } + + #[test] + fn reports_unknown_ingress_peer() { + let mut registry = RoomRegistry::default(); + registry.join(client_hello(1)).unwrap(); + let frame = ethernet(MacAddr::BROADCAST, mac(1)); + + let error = registry.forward_ethernet(&room(), 99, &frame).unwrap_err(); + + assert_eq!( + error, + ForwardingError::UnknownIngressPeer { + room: room(), + peer_id: 99, + } + ); + } }