diff --git a/Cargo.lock b/Cargo.lock index 399edc3..782182d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,6 +43,10 @@ dependencies = [ [[package]] name = "lanparty-relay" version = "0.1.0" +dependencies = [ + "lanparty-ctrl", + "lanparty-proto", +] [[package]] name = "proc-macro2" diff --git a/README.md b/README.md index 04e404e..de72bdb 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,14 @@ Shared diagnostics and structured logging vocabulary: - tunnel counters shared by control messages and runtime diagnostics - client connectivity/TAP diagnostics and user-facing status messages +### `lanparty-relay` + +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 + ## Build ```bash diff --git a/crates/lanparty-relay/Cargo.toml b/crates/lanparty-relay/Cargo.toml index 0704a04..3e27090 100644 --- a/crates/lanparty-relay/Cargo.toml +++ b/crates/lanparty-relay/Cargo.toml @@ -4,3 +4,5 @@ version.workspace = true edition.workspace = true [dependencies] +lanparty-ctrl = { path = "../lanparty-ctrl" } +lanparty-proto = { path = "../lanparty-proto" } diff --git a/crates/lanparty-relay/src/lib.rs b/crates/lanparty-relay/src/lib.rs new file mode 100644 index 0000000..b009cf1 --- /dev/null +++ b/crates/lanparty-relay/src/lib.rs @@ -0,0 +1,374 @@ +//! Relay room state and admission control. +//! +//! QUIC accept loops will sit above this crate layer. Keeping room admission +//! separate makes the important relay invariants testable without sockets. + +use std::collections::HashMap; + +use lanparty_ctrl::{ + ControlError, EndpointHello, PeerInfo, Reject, RejectReason, Role, RoomCode, ServerWelcome, +}; +use lanparty_proto::{MacAddr, recommended_tap_mtu}; + +pub const DEFAULT_MAX_CLIENTS_PER_ROOM: usize = 16; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JoinAccepted { + welcome: ServerWelcome, + peer: PeerInfo, +} + +impl JoinAccepted { + #[must_use] + const fn new(welcome: ServerWelcome, peer: PeerInfo) -> Self { + Self { welcome, peer } + } + + #[must_use] + pub const fn welcome(&self) -> &ServerWelcome { + &self.welcome + } + + #[must_use] + pub const fn peer(&self) -> &PeerInfo { + &self.peer + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RoomSnapshot { + room_id: u64, + effective_tap_mtu: u16, + gateway: Option, + clients: Vec, +} + +impl RoomSnapshot { + #[must_use] + pub const fn room_id(&self) -> u64 { + self.room_id + } + + #[must_use] + pub const fn effective_tap_mtu(&self) -> u16 { + self.effective_tap_mtu + } + + #[must_use] + pub const fn gateway(&self) -> Option<&PeerInfo> { + self.gateway.as_ref() + } + + #[must_use] + pub fn clients(&self) -> &[PeerInfo] { + &self.clients + } +} + +#[derive(Debug, Clone)] +pub struct RoomRegistry { + rooms: HashMap, + next_room_id: u64, + max_clients_per_room: usize, +} + +impl Default for RoomRegistry { + fn default() -> Self { + Self::new(DEFAULT_MAX_CLIENTS_PER_ROOM) + } +} + +impl RoomRegistry { + #[must_use] + pub fn new(max_clients_per_room: usize) -> Self { + assert!( + max_clients_per_room > 0, + "max_clients_per_room must be greater than zero" + ); + + Self { + rooms: HashMap::new(), + next_room_id: 1, + max_clients_per_room, + } + } + + pub fn join(&mut self, hello: EndpointHello) -> Result { + hello.validate().map_err(reject_control_error)?; + + let supported_tap_mtu = recommended_tap_mtu(usize::from(hello.max_datagram_size())) + .map_err(|error| Reject::new(RejectReason::MtuTooSmall, error.to_string()))? + as u16; + let room_code = hello.room().clone(); + + if !self.rooms.contains_key(&room_code) { + let room_id = self.allocate_room_id()?; + self.rooms.insert( + room_code.clone(), + Room::new(room_id, self.max_clients_per_room), + ); + } + + self.rooms + .get_mut(&room_code) + .expect("room was inserted before lookup") + .join(hello, supported_tap_mtu) + } + + #[must_use] + pub fn room_count(&self) -> usize { + self.rooms.len() + } + + #[must_use] + pub fn snapshot(&self, room: &RoomCode) -> Option { + self.rooms.get(room).map(Room::snapshot) + } + + fn allocate_room_id(&mut self) -> Result { + let room_id = self.next_room_id; + self.next_room_id = self + .next_room_id + .checked_add(1) + .ok_or_else(|| Reject::new(RejectReason::InternalError, "room id space exhausted"))?; + + Ok(room_id) + } +} + +#[derive(Debug, Clone)] +struct Room { + room_id: u64, + next_peer_id: u32, + max_clients: usize, + effective_tap_mtu: Option, + gateway: Option, + clients: HashMap, + clients_by_mac: HashMap, +} + +impl Room { + fn new(room_id: u64, max_clients: usize) -> Self { + Self { + room_id, + next_peer_id: 1, + max_clients, + effective_tap_mtu: None, + gateway: None, + clients: HashMap::new(), + clients_by_mac: HashMap::new(), + } + } + + fn join( + &mut self, + hello: EndpointHello, + supported_tap_mtu: u16, + ) -> Result { + self.validate_role_capacity(&hello)?; + let effective_tap_mtu = self.accept_effective_mtu(supported_tap_mtu)?; + + let peer_id = self.allocate_peer_id()?; + let peer = + PeerInfo::new(peer_id, hello.role(), hello.announced_mac()).map_err(|error| { + Reject::new( + RejectReason::MalformedHello, + format!("invalid peer: {error}"), + ) + })?; + let welcome = + ServerWelcome::new(self.room_id, peer_id, effective_tap_mtu).map_err(|error| { + Reject::new( + RejectReason::InternalError, + format!("welcome failed: {error}"), + ) + })?; + + match peer.role() { + Role::Gateway => { + self.gateway = Some(peer.clone()); + } + Role::Client => { + let mac = peer.mac().expect("client peer info has MAC"); + self.clients_by_mac.insert(mac, peer.peer_id()); + self.clients.insert(peer.peer_id(), peer.clone()); + } + } + + Ok(JoinAccepted::new(welcome, peer)) + } + + fn accept_effective_mtu(&mut self, supported_tap_mtu: u16) -> Result { + match self.effective_tap_mtu { + Some(existing) if supported_tap_mtu < existing => Err(Reject::new( + RejectReason::MtuTooSmall, + format!("peer supports TAP MTU {supported_tap_mtu}, room requires {existing}"), + )), + Some(existing) => Ok(existing), + None => { + self.effective_tap_mtu = Some(supported_tap_mtu); + Ok(supported_tap_mtu) + } + } + } + + fn validate_role_capacity(&self, hello: &EndpointHello) -> Result<(), Reject> { + match hello.role() { + Role::Gateway if self.gateway.is_some() => Err(Reject::new( + RejectReason::GatewayAlreadyConnected, + "room already has a gateway", + )), + Role::Gateway => Ok(()), + Role::Client if self.clients.len() >= self.max_clients => { + Err(Reject::new(RejectReason::RoomFull, "room is full")) + } + Role::Client => { + let mac = hello + .announced_mac() + .expect("validated client hello has MAC address"); + + if self.clients_by_mac.contains_key(&mac) { + return Err(Reject::new( + RejectReason::DuplicateMac, + format!("client MAC {mac} is already present"), + )); + } + + Ok(()) + } + } + } + + fn allocate_peer_id(&mut self) -> Result { + let peer_id = self.next_peer_id; + self.next_peer_id = self + .next_peer_id + .checked_add(1) + .ok_or_else(|| Reject::new(RejectReason::InternalError, "peer id space exhausted"))?; + + Ok(peer_id) + } + + fn snapshot(&self) -> RoomSnapshot { + let mut clients: Vec<_> = self.clients.values().cloned().collect(); + clients.sort_by_key(PeerInfo::peer_id); + + RoomSnapshot { + room_id: self.room_id, + effective_tap_mtu: self.effective_tap_mtu.unwrap_or_default(), + gateway: self.gateway.clone(), + clients, + } + } +} + +fn reject_control_error(error: ControlError) -> Reject { + let reason = match error { + ControlError::UnsupportedVersion { .. } => RejectReason::UnsupportedVersion, + ControlError::InvalidClientMac { .. } => RejectReason::InvalidMac, + ControlError::Mtu(_) => RejectReason::MtuTooSmall, + ControlError::EmptyRoomCode + | ControlError::RoomCodeTooLong { .. } + | ControlError::InvalidRoomCodeByte { .. } + | ControlError::MissingClientMac + | ControlError::UnexpectedGatewayMac + | ControlError::InvalidPeerId + | ControlError::EffectiveMtuTooSmall { .. } => RejectReason::MalformedHello, + }; + + Reject::new(reason, error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn room() -> RoomCode { + RoomCode::new("ABCD").unwrap() + } + + fn mac(last: u8) -> MacAddr { + MacAddr::new([0x02, 0, 0, 0, 0, last]) + } + + fn client_hello(last_mac_octet: u8) -> EndpointHello { + EndpointHello::client(room(), mac(last_mac_octet), 1400).unwrap() + } + + fn gateway_hello() -> EndpointHello { + EndpointHello::gateway(room(), 1400).unwrap() + } + + #[test] + fn accepts_gateway_and_client_into_room() { + let mut registry = RoomRegistry::default(); + + let gateway = registry.join(gateway_hello()).unwrap(); + let client = registry.join(client_hello(1)).unwrap(); + let snapshot = registry.snapshot(&room()).unwrap(); + + assert_eq!(registry.room_count(), 1); + assert_eq!(gateway.peer().role(), Role::Gateway); + assert_eq!(gateway.welcome().peer_id(), 1); + assert_eq!(client.peer().role(), Role::Client); + assert_eq!(client.welcome().peer_id(), 2); + assert_eq!(snapshot.gateway().unwrap().peer_id(), 1); + assert_eq!(snapshot.clients().len(), 1); + assert_eq!(snapshot.effective_tap_mtu(), 1200); + } + + #[test] + fn rejects_second_gateway() { + let mut registry = RoomRegistry::default(); + registry.join(gateway_hello()).unwrap(); + + let reject = registry.join(gateway_hello()).unwrap_err(); + + assert_eq!(reject.reason(), &RejectReason::GatewayAlreadyConnected); + } + + #[test] + fn rejects_duplicate_client_mac() { + let mut registry = RoomRegistry::default(); + registry.join(client_hello(1)).unwrap(); + + let reject = registry.join(client_hello(1)).unwrap_err(); + + assert_eq!(reject.reason(), &RejectReason::DuplicateMac); + } + + #[test] + fn enforces_client_limit() { + let mut registry = RoomRegistry::new(1); + registry.join(client_hello(1)).unwrap(); + + let reject = registry.join(client_hello(2)).unwrap_err(); + + assert_eq!(reject.reason(), &RejectReason::RoomFull); + } + + #[test] + fn keeps_room_mtu_stable_after_first_peer() { + let mut registry = RoomRegistry::default(); + let first = EndpointHello::client(room(), mac(1), 1024).unwrap(); + registry.join(first).unwrap(); + + let reject = registry + .join(EndpointHello::gateway(room(), 900).unwrap()) + .unwrap_err(); + + assert_eq!(reject.reason(), &RejectReason::MtuTooSmall); + assert_eq!(registry.snapshot(&room()).unwrap().effective_tap_mtu(), 972); + } + + #[test] + fn second_peer_uses_existing_lower_room_mtu() { + let mut registry = RoomRegistry::default(); + let first = EndpointHello::client(room(), mac(1), 1024).unwrap(); + registry.join(first).unwrap(); + + let gateway = registry.join(gateway_hello()).unwrap(); + + assert_eq!(gateway.welcome().effective_tap_mtu(), 972); + } +}