Files
softlan-vpn/crates/lanparty-ctrl/src/lib.rs
T
ddidderr d15031c9d1 fix(client): clear gateway status from welcome identity
The client initialized gateway connectivity from ServerWelcome, but welcome only
exposed a boolean. If a gateway disconnected before the client saw the catch-up
PeerJoined event, the later unknown PeerLeft could not be tied to the gateway
and the status could stay connected.

Carry an optional gateway peer id in ServerWelcome. The relay fills it from the
joining gateway or the existing room gateway, and the Windows client stores it
so a matching unknown PeerLeft clears gateway connectivity. The boolean remains
for wire compatibility with older welcomes that do not carry the id.

Test Plan:
- cargo fmt --check
- cargo test -p lanparty-ctrl server_welcome
- cargo test -p lanparty-relay accepts_gateway_and_client_into_room
- cargo test -p lanparty-relay reports_missing_gateway_to_client_joining_first
- cargo test -p lanparty-client-win relay_lifecycle
- cargo test -p lanparty-client-win \
  clears_gateway_status_when_welcome_gateway_leaves_before_join_event
- cargo test -p lanparty-relay bridges_real_client_and_gateway_sessions
- cargo test -p lanparty-client-core connects_to_relay_control_stream_as_client
- cargo test --workspace
- cargo clippy --workspace --all-targets -- -D warnings
- cargo build --release -p lanparty-relay -p lanparty-gateway
- git diff --check
- git diff --cached --check

Refs: MVP lifecycle cleanup
2026-05-22 07:22:06 +02:00

655 lines
19 KiB
Rust

//! Reliable control-plane messages for the LAN party tunnel.
//!
//! QUIC streams carry these messages as length-prefixed JSON frames. The crate
//! defines the typed handshake/status model and the small framing layer needed
//! by client, relay, and gateway stream handlers.
use std::{fmt, str::FromStr};
mod codec;
pub use codec::{
CONTROL_LENGTH_PREFIX_LEN, ControlCodecError, MAX_CONTROL_MESSAGE_LEN,
complete_control_frame_len, decode_control_frame, encode_control_message,
};
pub use lanparty_obs::TunnelStats;
use lanparty_proto::{MIN_USEFUL_TAP_MTU, MacAddr, MtuError, recommended_tap_mtu};
use thiserror::Error;
pub const RELAY_ALPN: &[u8] = b"lanparty-l2/1";
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<String>) -> Result<Self, ControlError> {
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, Self::Err> {
Self::new(value)
}
}
impl TryFrom<String> for RoomCode {
type Error = ControlError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<RoomCode> 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, Copy, Default, PartialEq, Eq, Hash, serde::Deserialize, serde::Serialize,
)]
pub enum ConnectionMode {
#[default]
#[serde(rename = "relay")]
Relay,
#[serde(rename = "direct-p2p")]
DirectP2p,
#[serde(rename = "direct-failed-relay-fallback")]
DirectFailedRelayFallback,
}
impl fmt::Display for ConnectionMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Relay => f.write_str("relay"),
Self::DirectP2p => f.write_str("direct-p2p"),
Self::DirectFailedRelayFallback => f.write_str("direct-failed-relay-fallback"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
pub struct EndpointHello {
protocol_version: u16,
role: Role,
room: RoomCode,
announced_mac: Option<MacAddr>,
max_datagram_size: u16,
}
impl EndpointHello {
pub fn client(
room: RoomCode,
announced_mac: MacAddr,
max_datagram_size: u16,
) -> Result<Self, ControlError> {
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<Self, ControlError> {
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<MacAddr> {
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,
#[serde(default)]
mode: ConnectionMode,
#[serde(default)]
gateway_connected: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
gateway_peer_id: Option<u32>,
}
impl ServerWelcome {
pub fn new(room_id: u64, peer_id: u32, effective_tap_mtu: u16) -> Result<Self, ControlError> {
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,
mode: ConnectionMode::Relay,
gateway_connected: false,
gateway_peer_id: None,
})
}
#[must_use]
pub const fn with_mode(mut self, mode: ConnectionMode) -> Self {
self.mode = mode;
self
}
#[must_use]
pub const fn with_gateway_connected(mut self, gateway_connected: bool) -> Self {
self.gateway_connected = gateway_connected;
if !gateway_connected {
self.gateway_peer_id = None;
}
self
}
#[must_use]
pub const fn with_gateway_peer_id(mut self, gateway_peer_id: Option<u32>) -> Self {
self.gateway_peer_id = gateway_peer_id;
if let Some(_peer_id) = gateway_peer_id {
self.gateway_connected = true;
}
self
}
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,
});
}
if self.gateway_peer_id == Some(0) {
return Err(ControlError::InvalidPeerId);
}
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
}
#[must_use]
pub const fn mode(&self) -> ConnectionMode {
self.mode
}
#[must_use]
pub const fn gateway_connected(&self) -> bool {
self.gateway_connected
}
#[must_use]
pub const fn gateway_peer_id(&self) -> Option<u32> {
self.gateway_peer_id
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
pub struct PeerInfo {
peer_id: u32,
role: Role,
mac: Option<MacAddr>,
}
impl PeerInfo {
pub fn new(peer_id: u32, role: Role, mac: Option<MacAddr>) -> Result<Self, ControlError> {
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<MacAddr> {
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<String>) -> 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, 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 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 { .. }
));
assert_eq!(
ServerWelcome::new(1, 2, MIN_USEFUL_TAP_MTU as u16)
.unwrap()
.with_gateway_peer_id(Some(0))
.validate()
.unwrap_err(),
ControlError::InvalidPeerId
);
}
#[test]
fn server_welcome_reports_gateway_presence() {
let welcome = ServerWelcome::new(1, 2, MIN_USEFUL_TAP_MTU as u16).unwrap();
assert_eq!(welcome.mode(), ConnectionMode::Relay);
assert!(!welcome.gateway_connected());
assert_eq!(welcome.gateway_peer_id(), None);
let with_gateway = welcome.with_gateway_peer_id(Some(7));
assert!(with_gateway.gateway_connected());
assert_eq!(with_gateway.gateway_peer_id(), Some(7));
let without_gateway = with_gateway.with_gateway_connected(false);
assert!(!without_gateway.gateway_connected());
assert_eq!(without_gateway.gateway_peer_id(), None);
}
#[test]
fn server_welcome_reports_connection_mode() {
let welcome = ServerWelcome::new(1, 2, MIN_USEFUL_TAP_MTU as u16)
.unwrap()
.with_mode(ConnectionMode::DirectFailedRelayFallback);
assert_eq!(welcome.mode(), ConnectionMode::DirectFailedRelayFallback);
assert_eq!(ConnectionMode::Relay.to_string(), "relay");
assert_eq!(ConnectionMode::DirectP2p.to_string(), "direct-p2p");
assert_eq!(
ConnectionMode::DirectFailedRelayFallback.to_string(),
"direct-failed-relay-fallback"
);
assert_eq!(
serde_json::to_string(&ConnectionMode::DirectP2p).unwrap(),
r#""direct-p2p""#
);
assert_eq!(
serde_json::from_str::<ConnectionMode>(r#""direct-failed-relay-fallback""#).unwrap(),
ConnectionMode::DirectFailedRelayFallback
);
}
#[test]
fn server_welcome_defaults_missing_mode_to_relay() {
let json = format!(
r#"{{"protocol_version":1,"room_id":1,"peer_id":2,"effective_tap_mtu":{}}}"#,
MIN_USEFUL_TAP_MTU
);
let welcome: ServerWelcome = serde_json::from_str(&json).unwrap();
assert_eq!(welcome.mode(), ConnectionMode::Relay);
assert_eq!(welcome.gateway_peer_id(), None);
}
#[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);
}
}