feat(ctrl): report gateway presence in welcome

ServerWelcome now carries an initial gateway_connected flag with serde defaulting
for older welcome payloads. The relay sets it from room admission state so a
gateway sees itself as connected, clients joining behind an existing gateway see
yes, and clients that arrive first see no.

The Windows client prints that handshake fact at startup. This does not replace
the later peer-event stream; it gives phase-1 diagnostics an immediate answer
for whether the relay already has a LAN gateway in the room.

Test Plan:
- cargo fmt --check
- cargo test -p lanparty-ctrl -p lanparty-relay -p lanparty-client-core \
  -p lanparty-client-win
- cargo clippy -p lanparty-ctrl -p lanparty-relay -p lanparty-client-core \
  -p lanparty-client-win --all-targets -- -D warnings
- cargo test --workspace
- cargo clippy --workspace --all-targets -- -D warnings
- git diff --check

Refs: PLAN.md
This commit is contained in:
2026-05-21 20:24:14 +02:00
parent c6dbb78cfc
commit 0824f60548
5 changed files with 44 additions and 4 deletions
+3
View File
@@ -29,6 +29,7 @@ Reliable control-plane schema shared by the QUIC stream handlers:
- endpoint hello messages with role, room, MAC, and datagram budget - endpoint hello messages with role, room, MAC, and datagram budget
- server welcome, reject, peer lifecycle, stats, and disconnect messages - server welcome, reject, peer lifecycle, stats, and disconnect messages
- initial room gateway-presence status in server welcomes
- room-code, role/MAC, peer-id, and effective-MTU validation - room-code, role/MAC, peer-id, and effective-MTU validation
- length-prefixed JSON control frames for reliable QUIC streams - length-prefixed JSON control frames for reliable QUIC streams
@@ -148,6 +149,8 @@ handshake, pins a host route for the relay IP on the current pre-TAP interface,
verifies that the relay route still uses that pinned host route after TAP verifies that the relay route still uses that pinned host route after TAP
activation, and then bridges Ethernet frames between the relay and the first activation, and then bridges Ethernet frames between the relay and the first
TAP-Windows6 adapter until shutdown. TAP-Windows6 adapter until shutdown.
The startup status reports whether the relay already has a LAN gateway for the
room.
`--virtual-mac` can still override the stored identity for manual testing. On `--virtual-mac` can still override the stored identity for manual testing. On
Windows it sets the TAP IP interface MTU to the relay-selected MTU, marks the Windows it sets the TAP IP interface MTU to the relay-selected MTU, marks the
TAP media connected, and reports the driver MAC/MTU before forwarding frames, TAP media connected, and reports the driver MAC/MTU before forwarding frames,
+1
View File
@@ -674,6 +674,7 @@ mod tests {
); );
assert_eq!(client.welcome().room_id(), 7); assert_eq!(client.welcome().room_id(), 7);
assert_eq!(client.welcome().peer_id(), 2); assert_eq!(client.welcome().peer_id(), 2);
assert!(!client.welcome().gateway_connected());
assert!(client.quic_max_datagram_size() <= 1400); assert!(client.quic_max_datagram_size() <= 1400);
assert!(client.quic_diagnostics().datagram_supported()); assert!(client.quic_diagnostics().datagram_supported());
assert_eq!( assert_eq!(
+3 -2
View File
@@ -109,10 +109,11 @@ async fn main() -> Result<()> {
let session = connect_client(config).await?; let session = connect_client(config).await?;
println!( println!(
"lanparty-client-win connected as peer {} in room id {} with TAP MTU {}", "lanparty-client-win connected as peer {} in room id {} with TAP MTU {}; LAN gateway connected {}",
session.welcome().peer_id(), session.welcome().peer_id(),
session.welcome().room_id(), session.welcome().room_id(),
session.welcome().effective_tap_mtu() session.welcome().effective_tap_mtu(),
yes_no(session.welcome().gateway_connected())
); );
#[cfg(windows)] #[cfg(windows)]
let relay_route_pin = match pin_relay_route_before_tap(session.config().relay_addr().ip()) { let relay_route_pin = match pin_relay_route_before_tap(session.config().relay_addr().ip()) {
+22
View File
@@ -189,6 +189,8 @@ pub struct ServerWelcome {
room_id: u64, room_id: u64,
peer_id: u32, peer_id: u32,
effective_tap_mtu: u16, effective_tap_mtu: u16,
#[serde(default)]
gateway_connected: bool,
} }
impl ServerWelcome { impl ServerWelcome {
@@ -209,9 +211,16 @@ impl ServerWelcome {
room_id, room_id,
peer_id, peer_id,
effective_tap_mtu, effective_tap_mtu,
gateway_connected: false,
}) })
} }
#[must_use]
pub const fn with_gateway_connected(mut self, gateway_connected: bool) -> Self {
self.gateway_connected = gateway_connected;
self
}
pub fn validate(&self) -> Result<(), ControlError> { pub fn validate(&self) -> Result<(), ControlError> {
if self.protocol_version != CONTROL_PROTOCOL_VERSION { if self.protocol_version != CONTROL_PROTOCOL_VERSION {
return Err(ControlError::UnsupportedVersion { return Err(ControlError::UnsupportedVersion {
@@ -253,6 +262,11 @@ impl ServerWelcome {
pub const fn effective_tap_mtu(&self) -> u16 { pub const fn effective_tap_mtu(&self) -> u16 {
self.effective_tap_mtu self.effective_tap_mtu
} }
#[must_use]
pub const fn gateway_connected(&self) -> bool {
self.gateway_connected
}
} }
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)] #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
@@ -489,6 +503,14 @@ mod tests {
)); ));
} }
#[test]
fn server_welcome_reports_gateway_presence() {
let welcome = ServerWelcome::new(1, 2, MIN_USEFUL_TAP_MTU as u16).unwrap();
assert!(!welcome.gateway_connected());
assert!(welcome.with_gateway_connected(true).gateway_connected());
}
#[test] #[test]
fn peer_info_enforces_role_mac_rules() { fn peer_info_enforces_role_mac_rules() {
let client = PeerInfo::new(7, Role::Client, Some(client_mac())).unwrap(); let client = PeerInfo::new(7, Role::Client, Some(client_mac())).unwrap();
+15 -2
View File
@@ -327,8 +327,10 @@ impl Room {
format!("invalid peer: {error}"), format!("invalid peer: {error}"),
) )
})?; })?;
let welcome = let gateway_connected = matches!(peer.role(), Role::Gateway) || self.gateway.is_some();
ServerWelcome::new(self.room_id, peer_id, effective_tap_mtu).map_err(|error| { let welcome = ServerWelcome::new(self.room_id, peer_id, effective_tap_mtu)
.map(|welcome| welcome.with_gateway_connected(gateway_connected))
.map_err(|error| {
Reject::new( Reject::new(
RejectReason::InternalError, RejectReason::InternalError,
format!("welcome failed: {error}"), format!("welcome failed: {error}"),
@@ -817,13 +819,24 @@ mod tests {
assert_eq!(registry.room_count(), 1); assert_eq!(registry.room_count(), 1);
assert_eq!(gateway.peer().role(), Role::Gateway); assert_eq!(gateway.peer().role(), Role::Gateway);
assert_eq!(gateway.welcome().peer_id(), 1); assert_eq!(gateway.welcome().peer_id(), 1);
assert!(gateway.welcome().gateway_connected());
assert_eq!(client.peer().role(), Role::Client); assert_eq!(client.peer().role(), Role::Client);
assert_eq!(client.welcome().peer_id(), 2); assert_eq!(client.welcome().peer_id(), 2);
assert!(client.welcome().gateway_connected());
assert_eq!(snapshot.gateway().unwrap().peer_id(), 1); assert_eq!(snapshot.gateway().unwrap().peer_id(), 1);
assert_eq!(snapshot.clients().len(), 1); assert_eq!(snapshot.clients().len(), 1);
assert_eq!(snapshot.effective_tap_mtu(), 1200); assert_eq!(snapshot.effective_tap_mtu(), 1200);
} }
#[test]
fn reports_missing_gateway_to_client_joining_first() {
let mut registry = RoomRegistry::default();
let client = registry.join(client_hello(1)).unwrap();
assert!(!client.welcome().gateway_connected());
}
#[test] #[test]
fn rejects_second_gateway() { fn rejects_second_gateway() {
let mut registry = RoomRegistry::default(); let mut registry = RoomRegistry::default();