fix(client): drop misdirected relayed unicast frames

The relay should only send a remote client unicast traffic for that client's
virtual MAC, while broadcast and multicast traffic fan out to every relevant
peer. The Windows client is still the last boundary before TAP, so it should not
write a relayed unicast frame for some other MAC into the adapter if the relay
or a future code path misroutes it.

Add a receive-side destination guard in client-core. Relayed frames now reach
TAP only when their destination is the client's virtual MAC or a
broadcast/multicast address; other unicast frames are counted and skipped.

Extend the client relay-session test with a wrong-client unicast before the
valid frame, and document the client-side skip in the README.

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

Refs: PLAN.md LAN frames to matching remote clients
This commit is contained in:
2026-05-22 05:31:37 +02:00
parent da937a50c4
commit 234bece265
2 changed files with 58 additions and 10 deletions
+3 -2
View File
@@ -264,8 +264,9 @@ fragments, jumbo frames, and TAP frames whose encoded datagrams exceed the
negotiated QUIC budget are counted and dropped before relay send without negotiated QUIC budget are counted and dropped before relay send without
stopping the bridge. Relayed LAN frames are also safety-checked before TAP stopping the bridge. Relayed LAN frames are also safety-checked before TAP
writes, so switch-control traffic, invalid-source frames, and jumbo frames stay writes, so switch-control traffic, invalid-source frames, and jumbo frames stay
out of the Windows adapter even if they reached the client over QUIC; TAP out of the Windows adapter even if they reached the client over QUIC.
device read/write errors still stop the bridge. Misdirected unicast frames not addressed to the client's virtual MAC are also
counted and skipped; TAP device read/write errors still stop the bridge.
Relay lifecycle events are logged as they arrive, including gateway joins and Relay lifecycle events are logged as they arrive, including gateway joins and
peer leaves. The client remembers peer identities from join and catch-up events peer leaves. The client remembers peer identities from join and catch-up events
so later leave logs can identify a disconnected LAN gateway or client MAC when so later leave logs can identify a disconnected LAN gateway or client MAC when
+55 -8
View File
@@ -449,6 +449,10 @@ impl ClientRelayIo {
self.stats.record_dropped_frame(); self.stats.record_dropped_frame();
continue; continue;
} }
if !is_accepted_relay_destination(ethernet_frame, self.virtual_mac) {
self.stats.record_dropped_frame();
continue;
}
return Ok(ReceivedEthernetFrame { return Ok(ReceivedEthernetFrame {
source_peer_id: header.peer_id(), source_peer_id: header.peer_id(),
@@ -468,6 +472,11 @@ impl ClientRelayIo {
} }
} }
fn is_accepted_relay_destination(frame: EthernetFrame<'_>, virtual_mac: MacAddr) -> bool {
let destination = frame.destination();
destination == virtual_mac || destination.is_multicast()
}
#[derive(Debug, Default)] #[derive(Debug, Default)]
struct ClientTunnelStats { struct ClientTunnelStats {
ethernet_frames_tx: AtomicU64, ethernet_frames_tx: AtomicU64,
@@ -823,9 +832,26 @@ mod tests {
.send_datagram(Bytes::from(filtered_response)) .send_datagram(Bytes::from(filtered_response))
.unwrap(); .unwrap();
let response = let misdirected_response = encode_datagram(
encode_datagram(FrameType::Ethernet, 7, 1, 0, &ethernet_frame(b"from relay")) FrameType::Ethernet,
.unwrap(); 7,
1,
0,
&misdirected_unicast_ethernet_frame(),
)
.unwrap();
connection
.send_datagram(Bytes::from(misdirected_response))
.unwrap();
let response = encode_datagram(
FrameType::Ethernet,
7,
1,
0,
&relay_ethernet_frame(b"from relay"),
)
.unwrap();
connection.send_datagram(Bytes::from(response)).unwrap(); connection.send_datagram(Bytes::from(response)).unwrap();
let event = encode_control_message(&ControlMessage::PeerJoined( let event = encode_control_message(&ControlMessage::PeerJoined(
@@ -842,7 +868,7 @@ mod tests {
let ControlMessage::Stats(stats) = stats_message else { let ControlMessage::Stats(stats) = stats_message else {
panic!("expected client stats event"); panic!("expected client stats event");
}; };
assert_eq!(stats, TunnelStats::new(1, 2, 1, 2, 8, 1)); assert_eq!(stats, TunnelStats::new(1, 3, 1, 3, 9, 1));
stats_received_tx.send(()).unwrap(); stats_received_tx.send(()).unwrap();
let mut disconnect_recv = connection.accept_uni().await.unwrap(); let mut disconnect_recv = connection.accept_uni().await.unwrap();
@@ -906,7 +932,10 @@ mod tests {
.unwrap() .unwrap()
.unwrap(); .unwrap();
assert_eq!(received.source_peer_id(), 1); assert_eq!(received.source_peer_id(), 1);
assert_eq!(received.payload(), ethernet_frame(b"from relay").as_slice()); assert_eq!(
received.payload(),
relay_ethernet_frame(b"from relay").as_slice()
);
let event = tokio::time::timeout(Duration::from_secs(5), client.recv_control_event()) let event = tokio::time::timeout(Duration::from_secs(5), client.recv_control_event())
.await .await
@@ -968,12 +997,12 @@ mod tests {
); );
let stats = relay_io.stats_snapshot(); let stats = relay_io.stats_snapshot();
assert_eq!(stats.ethernet_frames_tx(), 1); assert_eq!(stats.ethernet_frames_tx(), 1);
assert_eq!(stats.ethernet_frames_rx(), 2); assert_eq!(stats.ethernet_frames_rx(), 3);
assert_eq!(stats.broadcast_frames_tx(), 0); assert_eq!(stats.broadcast_frames_tx(), 0);
assert_eq!(stats.broadcast_frames_rx(), 0); assert_eq!(stats.broadcast_frames_rx(), 0);
assert_eq!(stats.datagrams_tx(), 1); assert_eq!(stats.datagrams_tx(), 1);
assert_eq!(stats.datagrams_rx(), 2); assert_eq!(stats.datagrams_rx(), 3);
assert_eq!(stats.dropped_frames(), 8); assert_eq!(stats.dropped_frames(), 9);
assert_eq!(stats.malformed_frames(), 1); assert_eq!(stats.malformed_frames(), 1);
assert_eq!(client.stats_snapshot(), stats); assert_eq!(client.stats_snapshot(), stats);
@@ -1075,6 +1104,24 @@ mod tests {
) )
} }
fn relay_ethernet_frame(payload: &[u8]) -> Vec<u8> {
ethernet_frame_with_headers(
MacAddr::new([0x02, 0, 0, 0, 0, 1]),
MacAddr::new([0x02, 0, 0, 0, 0, 2]),
lanparty_proto::ETHERTYPE_IPV4,
payload,
)
}
fn misdirected_unicast_ethernet_frame() -> Vec<u8> {
ethernet_frame_with_headers(
MacAddr::new([0x02, 0, 0, 0, 0, 9]),
MacAddr::new([0x02, 0, 0, 0, 0, 2]),
lanparty_proto::ETHERTYPE_IPV4,
b"wrong client",
)
}
fn ethernet_frame_with_headers( fn ethernet_frame_with_headers(
destination: MacAddr, destination: MacAddr,
source: MacAddr, source: MacAddr,