diff --git a/Cargo.lock b/Cargo.lock index ded73c3..5f37d1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,6 +13,11 @@ version = "0.1.0" [[package]] name = "lanparty-ctrl" version = "0.1.0" +dependencies = [ + "lanparty-proto", + "serde", + "thiserror", +] [[package]] name = "lanparty-gateway" diff --git a/README.md b/README.md index 0219fc6..7a1ecec 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,14 @@ Transport-agnostic tunnel contract shared by all binaries: - MAC address parsing and identity validation - QUIC datagram to TAP MTU budget helpers +### `lanparty-ctrl` + +Reliable control-plane schema shared by the QUIC stream handlers: + +- endpoint hello messages with role, room, MAC, and datagram budget +- server welcome, reject, peer lifecycle, stats, and disconnect messages +- room-code, role/MAC, peer-id, and effective-MTU validation + ## Build ```bash diff --git a/crates/lanparty-ctrl/Cargo.toml b/crates/lanparty-ctrl/Cargo.toml index 52092ee..9ddbae6 100644 --- a/crates/lanparty-ctrl/Cargo.toml +++ b/crates/lanparty-ctrl/Cargo.toml @@ -4,3 +4,6 @@ version.workspace = true edition.workspace = true [dependencies] +lanparty-proto = { path = "../lanparty-proto" } +serde.workspace = true +thiserror.workspace = true diff --git a/crates/lanparty-ctrl/src/lib.rs b/crates/lanparty-ctrl/src/lib.rs index b93cf3f..14684a0 100644 --- a/crates/lanparty-ctrl/src/lib.rs +++ b/crates/lanparty-ctrl/src/lib.rs @@ -1,14 +1,572 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right +//! Reliable control-plane messages for the LAN party tunnel. +//! +//! QUIC streams will carry these messages. The crate does not choose a stream +//! codec yet; it defines the typed handshake and status model that client, +//! relay, and gateway must validate consistently. + +use std::{fmt, str::FromStr}; + +use lanparty_proto::{MIN_USEFUL_TAP_MTU, MacAddr, MtuError, recommended_tap_mtu}; +use thiserror::Error; + +pub const CONTROL_PROTOCOL_VERSION: u16 = 1; +pub const MIN_ROOM_CODE_LEN: usize = 1; +pub const MAX_ROOM_CODE_LEN: usize = 64; + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum ControlError { + #[error("room code cannot be empty")] + EmptyRoomCode, + #[error("room code is too long: got {len}, maximum is {max}")] + RoomCodeTooLong { len: usize, max: usize }, + #[error("invalid room code character at byte {index}: {byte:#04x}")] + InvalidRoomCodeByte { index: usize, byte: u8 }, + #[error("unsupported control protocol version {actual}; supported version is {supported}")] + UnsupportedVersion { actual: u16, supported: u16 }, + #[error("client hello must announce a MAC address")] + MissingClientMac, + #[error("gateway hello must not announce a client MAC address")] + UnexpectedGatewayMac, + #[error("client MAC {mac} is not a locally administered unicast address")] + InvalidClientMac { mac: MacAddr }, + #[error("peer id 0 is reserved")] + InvalidPeerId, + #[error("effective TAP MTU {mtu} is below the minimum useful MTU {minimum}")] + EffectiveMtuTooSmall { mtu: u16, minimum: usize }, + #[error(transparent)] + Mtu(#[from] MtuError), +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Deserialize, serde::Serialize)] +#[serde(try_from = "String", into = "String")] +pub struct RoomCode(String); + +impl RoomCode { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + validate_room_code(&value)?; + + Ok(Self(value)) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for RoomCode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl FromStr for RoomCode { + type Err = ControlError; + + fn from_str(value: &str) -> Result { + Self::new(value) + } +} + +impl TryFrom for RoomCode { + type Error = ControlError; + + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl From for String { + fn from(value: RoomCode) -> Self { + value.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Role { + Client, + Gateway, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +pub struct EndpointHello { + protocol_version: u16, + role: Role, + room: RoomCode, + announced_mac: Option, + max_datagram_size: u16, +} + +impl EndpointHello { + pub fn client( + room: RoomCode, + announced_mac: MacAddr, + max_datagram_size: u16, + ) -> Result { + let hello = Self { + protocol_version: CONTROL_PROTOCOL_VERSION, + role: Role::Client, + room, + announced_mac: Some(announced_mac), + max_datagram_size, + }; + hello.validate()?; + + Ok(hello) + } + + pub fn gateway(room: RoomCode, max_datagram_size: u16) -> Result { + let hello = Self { + protocol_version: CONTROL_PROTOCOL_VERSION, + role: Role::Gateway, + room, + announced_mac: None, + max_datagram_size, + }; + hello.validate()?; + + Ok(hello) + } + + pub fn validate(&self) -> Result<(), ControlError> { + if self.protocol_version != CONTROL_PROTOCOL_VERSION { + return Err(ControlError::UnsupportedVersion { + actual: self.protocol_version, + supported: CONTROL_PROTOCOL_VERSION, + }); + } + + recommended_tap_mtu(usize::from(self.max_datagram_size))?; + + match (self.role, self.announced_mac) { + (Role::Client, Some(mac)) if mac.is_valid_client_identity() => Ok(()), + (Role::Client, Some(mac)) => Err(ControlError::InvalidClientMac { mac }), + (Role::Client, None) => Err(ControlError::MissingClientMac), + (Role::Gateway, Some(_)) => Err(ControlError::UnexpectedGatewayMac), + (Role::Gateway, None) => Ok(()), + } + } + + #[must_use] + pub const fn protocol_version(&self) -> u16 { + self.protocol_version + } + + #[must_use] + pub const fn role(&self) -> Role { + self.role + } + + #[must_use] + pub fn room(&self) -> &RoomCode { + &self.room + } + + #[must_use] + pub const fn announced_mac(&self) -> Option { + self.announced_mac + } + + #[must_use] + pub const fn max_datagram_size(&self) -> u16 { + self.max_datagram_size + } +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +pub struct ServerWelcome { + protocol_version: u16, + room_id: u64, + peer_id: u32, + effective_tap_mtu: u16, +} + +impl ServerWelcome { + pub fn new(room_id: u64, peer_id: u32, effective_tap_mtu: u16) -> Result { + if peer_id == 0 { + return Err(ControlError::InvalidPeerId); + } + + if usize::from(effective_tap_mtu) < MIN_USEFUL_TAP_MTU { + return Err(ControlError::EffectiveMtuTooSmall { + mtu: effective_tap_mtu, + minimum: MIN_USEFUL_TAP_MTU, + }); + } + + Ok(Self { + protocol_version: CONTROL_PROTOCOL_VERSION, + room_id, + peer_id, + effective_tap_mtu, + }) + } + + pub fn validate(&self) -> Result<(), ControlError> { + if self.protocol_version != CONTROL_PROTOCOL_VERSION { + return Err(ControlError::UnsupportedVersion { + actual: self.protocol_version, + supported: CONTROL_PROTOCOL_VERSION, + }); + } + + if self.peer_id == 0 { + return Err(ControlError::InvalidPeerId); + } + + if usize::from(self.effective_tap_mtu) < MIN_USEFUL_TAP_MTU { + return Err(ControlError::EffectiveMtuTooSmall { + mtu: self.effective_tap_mtu, + minimum: MIN_USEFUL_TAP_MTU, + }); + } + + Ok(()) + } + + #[must_use] + pub const fn protocol_version(&self) -> u16 { + self.protocol_version + } + + #[must_use] + pub const fn room_id(&self) -> u64 { + self.room_id + } + + #[must_use] + pub const fn peer_id(&self) -> u32 { + self.peer_id + } + + #[must_use] + pub const fn effective_tap_mtu(&self) -> u16 { + self.effective_tap_mtu + } +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +pub struct PeerInfo { + peer_id: u32, + role: Role, + mac: Option, +} + +impl PeerInfo { + pub fn new(peer_id: u32, role: Role, mac: Option) -> Result { + if peer_id == 0 { + return Err(ControlError::InvalidPeerId); + } + + match (role, mac) { + (Role::Client, Some(mac)) if mac.is_valid_client_identity() => Ok(Self { + peer_id, + role, + mac: Some(mac), + }), + (Role::Client, Some(mac)) => Err(ControlError::InvalidClientMac { mac }), + (Role::Client, None) => Err(ControlError::MissingClientMac), + (Role::Gateway, Some(_)) => Err(ControlError::UnexpectedGatewayMac), + (Role::Gateway, None) => Ok(Self { + peer_id, + role, + mac: None, + }), + } + } + + pub fn validate(&self) -> Result<(), ControlError> { + if self.peer_id == 0 { + return Err(ControlError::InvalidPeerId); + } + + match (self.role, self.mac) { + (Role::Client, Some(mac)) if mac.is_valid_client_identity() => Ok(()), + (Role::Client, Some(mac)) => Err(ControlError::InvalidClientMac { mac }), + (Role::Client, None) => Err(ControlError::MissingClientMac), + (Role::Gateway, Some(_)) => Err(ControlError::UnexpectedGatewayMac), + (Role::Gateway, None) => Ok(()), + } + } + + #[must_use] + pub const fn peer_id(&self) -> u32 { + self.peer_id + } + + #[must_use] + pub const fn role(&self) -> Role { + self.role + } + + #[must_use] + pub const fn mac(&self) -> Option { + self.mac + } +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RejectReason { + UnsupportedVersion, + RoomFull, + GatewayAlreadyConnected, + DuplicateMac, + InvalidMac, + MtuTooSmall, + MalformedHello, + Unauthorized, + InternalError, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum DisconnectReason { + Normal, + RelayShutdown, + ProtocolError, + TimedOut, + Replaced, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +pub struct Reject { + reason: RejectReason, + message: String, +} + +impl Reject { + #[must_use] + pub fn new(reason: RejectReason, message: impl Into) -> Self { + Self { + reason, + message: message.into(), + } + } + + #[must_use] + pub const fn reason(&self) -> &RejectReason { + &self.reason + } + + #[must_use] + pub fn message(&self) -> &str { + &self.message + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Deserialize, serde::Serialize)] +pub struct TunnelStats { + ethernet_frames_tx: u64, + ethernet_frames_rx: u64, + datagrams_tx: u64, + datagrams_rx: u64, + dropped_frames: u64, + malformed_frames: u64, +} + +impl TunnelStats { + #[must_use] + pub const fn new( + ethernet_frames_tx: u64, + ethernet_frames_rx: u64, + datagrams_tx: u64, + datagrams_rx: u64, + dropped_frames: u64, + malformed_frames: u64, + ) -> Self { + Self { + ethernet_frames_tx, + ethernet_frames_rx, + datagrams_tx, + datagrams_rx, + dropped_frames, + malformed_frames, + } + } + + #[must_use] + pub const fn ethernet_frames_tx(&self) -> u64 { + self.ethernet_frames_tx + } + + #[must_use] + pub const fn ethernet_frames_rx(&self) -> u64 { + self.ethernet_frames_rx + } + + #[must_use] + pub const fn datagrams_tx(&self) -> u64 { + self.datagrams_tx + } + + #[must_use] + pub const fn datagrams_rx(&self) -> u64 { + self.datagrams_rx + } + + #[must_use] + pub const fn dropped_frames(&self) -> u64 { + self.dropped_frames + } + + #[must_use] + pub const fn malformed_frames(&self) -> u64 { + self.malformed_frames + } +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +#[serde(tag = "type", content = "payload", rename_all = "snake_case")] +pub enum ControlMessage { + Hello(EndpointHello), + Welcome(ServerWelcome), + Reject(Reject), + PeerJoined(PeerInfo), + PeerLeft { + peer_id: u32, + reason: DisconnectReason, + }, + Stats(TunnelStats), + Disconnect { + reason: DisconnectReason, + message: String, + }, +} + +impl ControlMessage { + pub fn validate(&self) -> Result<(), ControlError> { + match self { + Self::Hello(hello) => hello.validate(), + Self::Welcome(welcome) => welcome.validate(), + Self::Reject(_) | Self::Stats(_) | Self::Disconnect { .. } => Ok(()), + Self::PeerJoined(peer) => peer.validate(), + Self::PeerLeft { peer_id, .. } if *peer_id == 0 => Err(ControlError::InvalidPeerId), + Self::PeerLeft { .. } => Ok(()), + } + } +} + +fn validate_room_code(value: &str) -> Result<(), ControlError> { + if value.len() < MIN_ROOM_CODE_LEN { + return Err(ControlError::EmptyRoomCode); + } + + if value.len() > MAX_ROOM_CODE_LEN { + return Err(ControlError::RoomCodeTooLong { + len: value.len(), + max: MAX_ROOM_CODE_LEN, + }); + } + + for (index, byte) in value.bytes().enumerate() { + if !byte.is_ascii_alphanumeric() && byte != b'-' && byte != b'_' { + return Err(ControlError::InvalidRoomCodeByte { index, byte }); + } + } + + Ok(()) } #[cfg(test)] mod tests { use super::*; + fn room() -> RoomCode { + RoomCode::new("ABCD-1234").unwrap() + } + + fn client_mac() -> MacAddr { + MacAddr::new([0x02, 0xaa, 0xbb, 0xcc, 0xdd, 0xee]) + } + #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); + fn validates_room_codes() { + assert_eq!(RoomCode::new("").unwrap_err(), ControlError::EmptyRoomCode); + assert!(RoomCode::new("ABCD_1234-z").is_ok()); + assert!(matches!( + RoomCode::new("bad room").unwrap_err(), + ControlError::InvalidRoomCodeByte { + index: 3, + byte: b' ' + } + )); + assert!(matches!( + RoomCode::new("a".repeat(MAX_ROOM_CODE_LEN + 1)).unwrap_err(), + ControlError::RoomCodeTooLong { .. } + )); + } + + #[test] + fn builds_valid_client_hello() { + let hello = EndpointHello::client(room(), client_mac(), 1400).unwrap(); + + assert_eq!(hello.protocol_version(), CONTROL_PROTOCOL_VERSION); + assert_eq!(hello.role(), Role::Client); + assert_eq!(hello.announced_mac(), Some(client_mac())); + assert_eq!(hello.max_datagram_size(), 1400); + } + + #[test] + fn client_hello_rejects_invalid_mac_and_tiny_datagram_budget() { + assert!(matches!( + EndpointHello::client(room(), MacAddr::BROADCAST, 1400).unwrap_err(), + ControlError::InvalidClientMac { .. } + )); + + assert!(matches!( + EndpointHello::client(room(), client_mac(), 128).unwrap_err(), + ControlError::Mtu(MtuError::DatagramTooSmall { .. }) + )); + } + + #[test] + fn builds_valid_gateway_hello() { + let hello = EndpointHello::gateway(room(), 1400).unwrap(); + + assert_eq!(hello.role(), Role::Gateway); + assert_eq!(hello.announced_mac(), None); + } + + #[test] + fn server_welcome_rejects_reserved_peer_id_and_tiny_mtu() { + assert_eq!( + ServerWelcome::new(1, 0, MIN_USEFUL_TAP_MTU as u16).unwrap_err(), + ControlError::InvalidPeerId + ); + assert!(matches!( + ServerWelcome::new(1, 2, (MIN_USEFUL_TAP_MTU - 1) as u16).unwrap_err(), + ControlError::EffectiveMtuTooSmall { .. } + )); + } + + #[test] + fn peer_info_enforces_role_mac_rules() { + let client = PeerInfo::new(7, Role::Client, Some(client_mac())).unwrap(); + assert_eq!(client.peer_id(), 7); + assert_eq!(client.mac(), Some(client_mac())); + + assert!(matches!( + PeerInfo::new(8, Role::Client, None).unwrap_err(), + ControlError::MissingClientMac + )); + assert!(matches!( + PeerInfo::new(9, Role::Gateway, Some(client_mac())).unwrap_err(), + ControlError::UnexpectedGatewayMac + )); + } + + #[test] + fn exposes_stats_counters() { + let stats = TunnelStats::new(1, 2, 3, 4, 5, 6); + + assert_eq!(stats.ethernet_frames_tx(), 1); + assert_eq!(stats.ethernet_frames_rx(), 2); + assert_eq!(stats.datagrams_tx(), 3); + assert_eq!(stats.datagrams_rx(), 4); + assert_eq!(stats.dropped_frames(), 5); + assert_eq!(stats.malformed_frames(), 6); } }