fix(client): filter relayed LAN frames before TAP writes

LAN-to-remote switch-control filtering is enforced by the gateway and relay,
but the Windows client is the final boundary before frames enter the TAP
adapter. A malformed or buggy relay path should not be able to make the client
write LAN control traffic, invalid-source frames, or jumbo frames into TAP.

Reuse the shared gateway/LAN safety classifier on received relay Ethernet
frames. Filtered frames are counted and skipped, and recv_ethernet only returns
frames that are safe to hand to the platform TAP writer.

Extend the client relay-session test so the mock relay sends a filtered frame
before the valid one, then document the receive-side TAP boundary 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-to-remote control-plane filtering
This commit is contained in:
2026-05-22 05:25:53 +02:00
parent 4d100ce800
commit 0a97b77ad9
2 changed files with 26 additions and 6 deletions
+22 -5
View File
@@ -28,7 +28,7 @@ use lanparty_ctrl::{
use lanparty_obs::{DropReason, QuicDiagnostics, TunnelStats};
use lanparty_proto::{
EthernetFrame, FrameType, MacAddr, decode_datagram, encode_datagram,
remote_client_safety_drop_reason, validate_datagram_budget,
gateway_lan_safety_drop_reason, remote_client_safety_drop_reason, validate_datagram_budget,
};
use quinn::{ClientConfig, Endpoint, crypto::rustls::QuicClientConfig};
use rustls::pki_types::CertificateDer;
@@ -445,6 +445,11 @@ impl ClientRelayIo {
};
self.stats.record_ethernet_rx(ethernet_frame);
if gateway_lan_safety_drop_reason(ethernet_frame).is_some() {
self.stats.record_dropped_frame();
continue;
}
return Ok(ReceivedEthernetFrame {
source_peer_id: header.peer_id(),
payload: Bytes::copy_from_slice(packet.payload()),
@@ -806,6 +811,18 @@ mod tests {
assert_eq!(header.peer_id(), 2);
assert_eq!(packet.payload(), ethernet_frame(b"to relay").as_slice());
let filtered_response = encode_datagram(
FrameType::Ethernet,
7,
1,
0,
&control_plane_ethernet_frame(),
)
.unwrap();
connection
.send_datagram(Bytes::from(filtered_response))
.unwrap();
let response =
encode_datagram(FrameType::Ethernet, 7, 1, 0, &ethernet_frame(b"from relay"))
.unwrap();
@@ -825,7 +842,7 @@ mod tests {
let ControlMessage::Stats(stats) = stats_message else {
panic!("expected client stats event");
};
assert_eq!(stats, TunnelStats::new(1, 1, 1, 1, 7, 1));
assert_eq!(stats, TunnelStats::new(1, 2, 1, 2, 8, 1));
stats_received_tx.send(()).unwrap();
let mut disconnect_recv = connection.accept_uni().await.unwrap();
@@ -951,12 +968,12 @@ mod tests {
);
let stats = relay_io.stats_snapshot();
assert_eq!(stats.ethernet_frames_tx(), 1);
assert_eq!(stats.ethernet_frames_rx(), 1);
assert_eq!(stats.ethernet_frames_rx(), 2);
assert_eq!(stats.broadcast_frames_tx(), 0);
assert_eq!(stats.broadcast_frames_rx(), 0);
assert_eq!(stats.datagrams_tx(), 1);
assert_eq!(stats.datagrams_rx(), 1);
assert_eq!(stats.dropped_frames(), 7);
assert_eq!(stats.datagrams_rx(), 2);
assert_eq!(stats.dropped_frames(), 8);
assert_eq!(stats.malformed_frames(), 1);
assert_eq!(client.stats_snapshot(), stats);