feat(relay): add room admission state
Add a tested relay room layer before introducing QUIC socket handling. The relay now has a focused place to enforce room membership rules instead of mixing those rules into the future networking loop. RoomRegistry accepts validated endpoint hellos, assigns room and peer IDs, returns server welcome data, limits clients per room, permits only one gateway, rejects duplicate client MACs, and keeps the room TAP MTU stable once the first peer joins. A later peer must support the existing room MTU rather than silently shrinking it after an earlier client may already have configured its TAP adapter. The networking pieces still need to call this layer from the reliable control stream and use the resulting peer metadata for datagram forwarding. Test Plan: - cargo fmt --check - cargo test --workspace - cargo clippy --workspace --all-targets -- -D warnings Refs: PLAN.md relay responsibilities and MAC identity
This commit is contained in:
@@ -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<PeerInfo>,
|
||||
clients: Vec<PeerInfo>,
|
||||
}
|
||||
|
||||
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<RoomCode, Room>,
|
||||
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<JoinAccepted, Reject> {
|
||||
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<RoomSnapshot> {
|
||||
self.rooms.get(room).map(Room::snapshot)
|
||||
}
|
||||
|
||||
fn allocate_room_id(&mut self) -> Result<u64, Reject> {
|
||||
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<u16>,
|
||||
gateway: Option<PeerInfo>,
|
||||
clients: HashMap<u32, PeerInfo>,
|
||||
clients_by_mac: HashMap<MacAddr, u32>,
|
||||
}
|
||||
|
||||
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<JoinAccepted, Reject> {
|
||||
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<u16, Reject> {
|
||||
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<u32, Reject> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user