test(relay): cover DHCP-shaped session frames

The MVP pass criteria include the Windows TAP adapter receiving a real LAN DHCP
address. Unit tests covered DHCP filtering policy, but the real client, relay,
and gateway session path did not explicitly carry DHCP-shaped request and reply
traffic.

Add a session test that sends a DHCPv4 discover-shaped broadcast from the client
side to the gateway and a DHCPv4 offer-shaped broadcast from the gateway side
back to the client. The test checks the DHCP UDP ports, DHCP message types, and
broadcast counters in both directions, so it exercises the same L2/L3 shape the
manual TAP DHCP proof depends on without requiring Windows or LAN hardware.

Test Plan:
- cargo fmt --check
- cargo test -p lanparty-relay \
  bridges_dhcpv4_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 DHCP pass condition
This commit is contained in:
2026-05-22 09:50:58 +02:00
parent d9b6a94c9c
commit 9eded8a41e
+189
View File
@@ -1026,10 +1026,17 @@ mod tests {
const ARP_REQUEST: u16 = 1; const ARP_REQUEST: u16 = 1;
const ARP_REPLY: u16 = 2; const ARP_REPLY: u16 = 2;
const IPV4_HEADER_LEN: usize = 20; const IPV4_HEADER_LEN: usize = 20;
const UDP_HEADER_LEN: usize = 8;
const IP_PROTOCOL_ICMPV4: u8 = 1; const IP_PROTOCOL_ICMPV4: u8 = 1;
const IP_PROTOCOL_UDP: u8 = 17; 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 DHCPV4_SERVER_PORT: u16 = 67;
const DHCPV4_CLIENT_PORT: u16 = 68;
const DHCPV4_BOOTREQUEST: u8 = 1;
const DHCPV4_BOOTREPLY: u8 = 2;
const DHCPV4_DISCOVER: u8 = 1;
const DHCPV4_OFFER: u8 = 2;
const LAN_GAME_DISCOVERY_PORT: u16 = 27015; const LAN_GAME_DISCOVERY_PORT: u16 = 27015;
#[tokio::test] #[tokio::test]
@@ -1771,6 +1778,151 @@ mod tests {
assert!(sessions.lock().await.is_empty()); assert!(sessions.lock().await.is_empty());
} }
#[tokio::test]
async fn bridges_dhcpv4_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 client_ip = Ipv4Addr::new(10, 73, 42, 51);
let dhcp_server_mac = lan_host_mac();
let dhcp_server_ip = Ipv4Addr::new(10, 73, 42, 1);
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 discover_payload =
dhcpv4_payload(DHCPV4_BOOTREQUEST, DHCPV4_DISCOVER, client_mac, None);
let discover = udp_ipv4_frame(
MacAddr::BROADCAST,
client_mac,
Ipv4Addr::new(0, 0, 0, 0),
Ipv4Addr::new(255, 255, 255, 255),
DHCPV4_CLIENT_PORT,
DHCPV4_SERVER_PORT,
&discover_payload,
);
let discover_header = EthernetFrame::parse(&discover).unwrap();
assert!(discover_header.is_broadcast());
assert_eq!(discover_header.ethertype_or_len(), ETHERTYPE_IPV4);
assert_udp_ports(&discover, DHCPV4_CLIENT_PORT, DHCPV4_SERVER_PORT);
assert_eq!(dhcpv4_message_type(&discover), Some(DHCPV4_DISCOVER));
assert_eq!(
client
.relay_io()
.send_ethernet_with_outcome(&discover)
.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(), discover.as_slice());
let offer_payload =
dhcpv4_payload(DHCPV4_BOOTREPLY, DHCPV4_OFFER, client_mac, Some(client_ip));
let offer = udp_ipv4_frame(
MacAddr::BROADCAST,
dhcp_server_mac,
dhcp_server_ip,
Ipv4Addr::new(255, 255, 255, 255),
DHCPV4_SERVER_PORT,
DHCPV4_CLIENT_PORT,
&offer_payload,
);
let offer_header = EthernetFrame::parse(&offer).unwrap();
assert!(offer_header.is_broadcast());
assert_eq!(offer_header.ethertype_or_len(), ETHERTYPE_IPV4);
assert_udp_ports(&offer, DHCPV4_SERVER_PORT, DHCPV4_CLIENT_PORT);
assert_eq!(dhcpv4_message_type(&offer), Some(DHCPV4_OFFER));
gateway.send_ethernet(&offer).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(), offer.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 bridges_icmpv4_ping_frames_between_client_and_gateway_sessions() { async fn bridges_icmpv4_ping_frames_between_client_and_gateway_sessions() {
let (server, certificate) = bind_test_server(DEFAULT_MAX_CLIENTS_PER_ROOM); let (server, certificate) = bind_test_server(DEFAULT_MAX_CLIENTS_PER_ROOM);
@@ -2513,6 +2665,43 @@ mod tests {
ethernet_frame_with_payload(destination, source, ETHERTYPE_IPV4, &ipv4) ethernet_frame_with_payload(destination, source, ETHERTYPE_IPV4, &ipv4)
} }
fn dhcpv4_payload(
operation: u8,
message_type: u8,
client_mac: MacAddr,
offered_ip: Option<Ipv4Addr>,
) -> Vec<u8> {
let mut payload = vec![0_u8; 240];
payload[0] = operation;
payload[1] = 1;
payload[2] = 6;
payload[4..8].copy_from_slice(&0x1234_5678_u32.to_be_bytes());
payload[10..12].copy_from_slice(&0x8000_u16.to_be_bytes());
if let Some(offered_ip) = offered_ip {
payload[16..20].copy_from_slice(&offered_ip.octets());
}
payload[28..34].copy_from_slice(&client_mac.octets());
payload[236..240].copy_from_slice(&[99, 130, 83, 99]);
payload.extend_from_slice(&[53, 1, message_type, 255]);
payload
}
fn assert_udp_ports(frame: &[u8], source_port: u16, destination_port: u16) {
let ipv4 = &frame[ETHERNET_HEADER_LEN..];
assert_eq!(ipv4[9], IP_PROTOCOL_UDP);
let udp = &ipv4[IPV4_HEADER_LEN..];
assert_eq!(u16::from_be_bytes([udp[0], udp[1]]), source_port);
assert_eq!(u16::from_be_bytes([udp[2], udp[3]]), destination_port);
}
fn dhcpv4_message_type(frame: &[u8]) -> Option<u8> {
let payload = &frame[ETHERNET_HEADER_LEN + IPV4_HEADER_LEN + UDP_HEADER_LEN..];
payload
.windows(3)
.find_map(|option| (option[0] == 53 && option[1] == 1).then_some(option[2]))
}
fn ipv4_packet( fn ipv4_packet(
protocol: u8, protocol: u8,
source_ip: Ipv4Addr, source_ip: Ipv4Addr,