test(relay): cover LAN-game broadcast frames

The MVP pass criteria include a real LAN game discovering or joining a LAN
server through the TAP adapter. Many games use UDP broadcast discovery, but the
real-session relay tests did not pin that traffic shape to the client, relay,
and gateway session path.

Add a session test that sends an IPv4 UDP broadcast query from the client side
to the gateway and a broadcast reply from the gateway side back to the client.
The test uses non-DHCP UDP ports, asserts the frames stay broadcast IPv4/UDP,
and checks the client and gateway broadcast counters in both directions. This
keeps the proof close to LAN-game discovery without needing real game or LAN
hardware.

Test Plan:
- cargo fmt --check
- cargo test -p lanparty-relay \
  bridges_udp_broadcast_discovery_frames_between_client_and_gateway_sessions \
  -- --nocapture
- cargo test -p lanparty-relay
- cargo test --workspace
- cargo clippy --workspace --all-targets -- -D warnings
- git diff --check

Refs: MVP LAN-game discovery pass condition
This commit is contained in:
2026-05-22 09:47:06 +02:00
parent a4f6cb3f4c
commit d9b6a94c9c
+180 -5
View File
@@ -1027,8 +1027,10 @@ mod tests {
const ARP_REPLY: u16 = 2; const ARP_REPLY: u16 = 2;
const IPV4_HEADER_LEN: usize = 20; const IPV4_HEADER_LEN: usize = 20;
const IP_PROTOCOL_ICMPV4: u8 = 1; const IP_PROTOCOL_ICMPV4: u8 = 1;
const IP_PROTOCOL_UDP: u8 = 17;
const ICMPV4_ECHO_REPLY: u8 = 0; const ICMPV4_ECHO_REPLY: u8 = 0;
const ICMPV4_ECHO_REQUEST: u8 = 8; const ICMPV4_ECHO_REQUEST: u8 = 8;
const LAN_GAME_DISCOVERY_PORT: u16 = 27015;
#[tokio::test] #[tokio::test]
async fn binds_quic_endpoint_on_configured_address() { async fn binds_quic_endpoint_on_configured_address() {
@@ -1903,6 +1905,148 @@ mod tests {
assert!(sessions.lock().await.is_empty()); assert!(sessions.lock().await.is_empty());
} }
#[tokio::test]
async fn bridges_udp_broadcast_discovery_frames_between_client_and_gateway_sessions() {
let (server, certificate) = bind_test_server(DEFAULT_MAX_CLIENTS_PER_ROOM);
let rooms = Arc::clone(&server.rooms);
let sessions = Arc::clone(&server.sessions);
let server_addr = server.local_addr().unwrap();
let server_task = tokio::spawn(async move {
let handles = server.accept_many_for_test(2).await.unwrap();
let mut accepted = Vec::with_capacity(handles.len());
for handle in handles {
accepted.push(handle.await.unwrap().unwrap().unwrap());
}
server.shutdown("test complete").await;
accepted
});
let cert_der = certificate.as_ref().to_vec();
let room = RoomCode::new("TESTROOM").unwrap();
let client_mac = client_mac(1);
let lan_host_mac = lan_host_mac();
let client_ip = Ipv4Addr::new(10, 73, 42, 51);
let lan_host_ip = Ipv4Addr::new(10, 73, 42, 10);
let subnet_broadcast_ip = Ipv4Addr::new(10, 73, 42, 255);
let gateway = connect_gateway(
GatewayConfig::new(
server_addr,
"lanparty-relay.local",
cert_der.clone(),
room.clone(),
"eth0",
1400,
)
.unwrap(),
)
.await
.unwrap();
let client = connect_client(
ClientSessionConfig::new(
server_addr,
"lanparty-relay.local",
cert_der,
room,
client_mac,
1400,
)
.unwrap(),
)
.await
.unwrap();
let ControlMessage::PeerJoined(peer) =
tokio::time::timeout(Duration::from_secs(5), gateway.recv_control_event())
.await
.unwrap()
.unwrap()
else {
panic!("expected gateway to observe client join");
};
assert_eq!(peer.peer_id(), client.welcome().peer_id());
assert_eq!(peer.role(), Role::Client);
assert_eq!(peer.mac(), Some(client_mac));
let ControlMessage::PeerJoined(peer) =
tokio::time::timeout(Duration::from_secs(5), client.recv_control_event())
.await
.unwrap()
.unwrap()
else {
panic!("expected client to receive gateway catch-up event");
};
assert_eq!(peer.peer_id(), gateway.welcome().peer_id());
assert_eq!(peer.role(), Role::Gateway);
let discovery_query = udp_ipv4_frame(
MacAddr::BROADCAST,
client_mac,
client_ip,
subnet_broadcast_ip,
LAN_GAME_DISCOVERY_PORT,
LAN_GAME_DISCOVERY_PORT,
b"lan-game-query",
);
let query_header = EthernetFrame::parse(&discovery_query).unwrap();
let query_ipv4 = &discovery_query[ETHERNET_HEADER_LEN..];
assert_eq!(query_header.ethertype_or_len(), ETHERTYPE_IPV4);
assert!(query_header.is_broadcast());
assert_eq!(query_ipv4[9], IP_PROTOCOL_UDP);
assert_eq!(
client
.relay_io()
.send_ethernet_with_outcome(&discovery_query)
.unwrap(),
lanparty_client_core::ClientSendOutcome::Sent
);
let received = tokio::time::timeout(Duration::from_secs(5), gateway.recv_ethernet())
.await
.unwrap()
.unwrap();
assert_eq!(received.source_peer_id(), client.welcome().peer_id());
assert_eq!(received.payload(), discovery_query.as_slice());
let discovery_reply = udp_ipv4_frame(
MacAddr::BROADCAST,
lan_host_mac,
lan_host_ip,
subnet_broadcast_ip,
LAN_GAME_DISCOVERY_PORT,
LAN_GAME_DISCOVERY_PORT,
b"lan-game-reply",
);
let reply_header = EthernetFrame::parse(&discovery_reply).unwrap();
let reply_ipv4 = &discovery_reply[ETHERNET_HEADER_LEN..];
assert_eq!(reply_header.ethertype_or_len(), ETHERTYPE_IPV4);
assert!(reply_header.is_broadcast());
assert_eq!(reply_ipv4[9], IP_PROTOCOL_UDP);
gateway.send_ethernet(&discovery_reply).unwrap();
let received =
tokio::time::timeout(Duration::from_secs(5), client.relay_io().recv_ethernet())
.await
.unwrap()
.unwrap();
assert_eq!(received.source_peer_id(), gateway.welcome().peer_id());
assert_eq!(received.payload(), discovery_reply.as_slice());
assert_eq!(client.stats_snapshot().broadcast_frames_tx(), 1);
assert_eq!(client.stats_snapshot().broadcast_frames_rx(), 1);
assert_eq!(gateway.stats_snapshot().broadcast_frames_tx(), 1);
assert_eq!(gateway.stats_snapshot().broadcast_frames_rx(), 1);
client.shutdown("test client done").await;
gateway.shutdown("test gateway done").await;
let accepted = tokio::time::timeout(Duration::from_secs(5), server_task)
.await
.unwrap()
.unwrap();
assert_eq!(accepted.len(), 2);
assert_eq!(rooms.lock().await.room_count(), 0);
assert!(sessions.lock().await.is_empty());
}
#[tokio::test] #[tokio::test]
async fn reconnects_gateway_while_client_stays_joined() { async fn reconnects_gateway_while_client_stays_joined() {
let (server, certificate) = bind_test_server(DEFAULT_MAX_CLIENTS_PER_ROOM); let (server, certificate) = bind_test_server(DEFAULT_MAX_CLIENTS_PER_ROOM);
@@ -2344,23 +2488,54 @@ mod tests {
let checksum = internet_checksum(&icmp); let checksum = internet_checksum(&icmp);
icmp[2..4].copy_from_slice(&checksum.to_be_bytes()); icmp[2..4].copy_from_slice(&checksum.to_be_bytes());
let mut ipv4 = Vec::with_capacity(IPV4_HEADER_LEN + icmp.len()); let ipv4 = ipv4_packet(IP_PROTOCOL_ICMPV4, source_ip, destination_ip, &icmp);
ethernet_frame_with_payload(destination, source, ETHERTYPE_IPV4, &ipv4)
}
fn udp_ipv4_frame(
destination: MacAddr,
source: MacAddr,
source_ip: Ipv4Addr,
destination_ip: Ipv4Addr,
source_port: u16,
destination_port: u16,
payload: &[u8],
) -> Vec<u8> {
let mut udp = Vec::with_capacity(8 + payload.len());
udp.extend_from_slice(&source_port.to_be_bytes());
udp.extend_from_slice(&destination_port.to_be_bytes());
let udp_len = u16::try_from(8 + payload.len()).unwrap();
udp.extend_from_slice(&udp_len.to_be_bytes());
udp.extend_from_slice(&0_u16.to_be_bytes());
udp.extend_from_slice(payload);
let ipv4 = ipv4_packet(IP_PROTOCOL_UDP, source_ip, destination_ip, &udp);
ethernet_frame_with_payload(destination, source, ETHERTYPE_IPV4, &ipv4)
}
fn ipv4_packet(
protocol: u8,
source_ip: Ipv4Addr,
destination_ip: Ipv4Addr,
payload: &[u8],
) -> Vec<u8> {
let mut ipv4 = Vec::with_capacity(IPV4_HEADER_LEN + payload.len());
ipv4.push(0x45); ipv4.push(0x45);
ipv4.push(0); ipv4.push(0);
let total_len = u16::try_from(IPV4_HEADER_LEN + icmp.len()).unwrap(); let total_len = u16::try_from(IPV4_HEADER_LEN + payload.len()).unwrap();
ipv4.extend_from_slice(&total_len.to_be_bytes()); ipv4.extend_from_slice(&total_len.to_be_bytes());
ipv4.extend_from_slice(&0x1234_u16.to_be_bytes()); ipv4.extend_from_slice(&0x1234_u16.to_be_bytes());
ipv4.extend_from_slice(&0_u16.to_be_bytes()); ipv4.extend_from_slice(&0_u16.to_be_bytes());
ipv4.push(64); ipv4.push(64);
ipv4.push(IP_PROTOCOL_ICMPV4); ipv4.push(protocol);
ipv4.extend_from_slice(&0_u16.to_be_bytes()); ipv4.extend_from_slice(&0_u16.to_be_bytes());
ipv4.extend_from_slice(&source_ip.octets()); ipv4.extend_from_slice(&source_ip.octets());
ipv4.extend_from_slice(&destination_ip.octets()); ipv4.extend_from_slice(&destination_ip.octets());
let checksum = internet_checksum(&ipv4); let checksum = internet_checksum(&ipv4);
ipv4[10..12].copy_from_slice(&checksum.to_be_bytes()); ipv4[10..12].copy_from_slice(&checksum.to_be_bytes());
ipv4.extend_from_slice(&icmp); ipv4.extend_from_slice(payload);
ethernet_frame_with_payload(destination, source, ETHERTYPE_IPV4, &ipv4) ipv4
} }
fn internet_checksum(bytes: &[u8]) -> u16 { fn internet_checksum(bytes: &[u8]) -> u16 {