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
+4 -1
View File
@@ -260,7 +260,10 @@ read from TAP, invalid or unauthorized source-MAC frames, L2 control-plane
traffic, remote VLAN tags, DHCP server replies, IPv6 Router Advertisements, IPv6
fragments, jumbo frames, and TAP frames whose encoded datagrams exceed the
negotiated QUIC budget are counted and dropped before relay send without
stopping the bridge; TAP device read/write errors still stop the bridge.
stopping the bridge. Relayed LAN frames are also safety-checked before TAP
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
device read/write errors still stop the bridge.
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
so later leave logs can identify a disconnected LAN gateway or client MAC when
+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);