fix(gateway): drop unsafe LAN frames before relay send

The relay already filters invalid source MACs, jumbo frames, and L2 control
plane traffic. The gateway bridge was still encoding those LAN frames and
sending them to the relay first, so gateway logs could say forwarded even when
the relay would later drop the frame.

Classify that same local LAN-send subset before QUIC DATAGRAM encoding. The
gateway now records and reports these frames as local drops, keeps the relay as
the trust boundary, and avoids spending relay bandwidth on frames that can never
reach remote clients.

Document that gateway-side local drops cover invalid source MACs, L2 control
plane traffic, jumbo frames, and datagram-budget failures.

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

Refs: PLAN.md LAN-to-remote control-plane filtering
This commit is contained in:
2026-05-22 05:04:09 +02:00
parent 3c1a35ea00
commit 985e4d9eed
2 changed files with 99 additions and 7 deletions
+4 -3
View File
@@ -178,9 +178,10 @@ gateway that cannot bridge. Once both sides are ready, it bridges Ethernet
frames between the relay and wired LAN until shutdown. It captures whole LAN frames between the relay and wired LAN until shutdown. It captures whole LAN
frames up to the frames up to the
overlay payload-length ceiling before deciding whether they fit the tunnel. It overlay payload-length ceiling before deciding whether they fit the tunnel. It
never fragments Ethernet frames; LAN frames whose encoded datagrams exceed the never fragments Ethernet frames; LAN frames with invalid source MACs, L2
negotiated QUIC budget are counted, dropped, and logged instead of stopping the control-plane traffic, jumbo frames, or encoded datagrams exceeding the
bridge. negotiated QUIC budget are counted, dropped, and logged locally instead of
stopping the bridge or consuming relay bandwidth.
`--relay` accepts a DNS name or socket address; bare hosts default to UDP/443. `--relay` accepts a DNS name or socket address; bare hosts default to UDP/443.
The gateway rejects Linux interfaces that sysfs identifies as Wi-Fi, and rejects The gateway rejects Linux interfaces that sysfs identifies as Wi-Fi, and rejects
wired interfaces whose sysfs carrier state reports no link; managed wireless wired interfaces whose sysfs carrier state reports no link; managed wireless
+95 -4
View File
@@ -44,6 +44,9 @@ pub use packet::PacketSocket;
const MAX_CONTROL_FRAME_LEN: usize = CONTROL_LENGTH_PREFIX_LEN + MAX_CONTROL_MESSAGE_LEN; const MAX_CONTROL_FRAME_LEN: usize = CONTROL_LENGTH_PREFIX_LEN + MAX_CONTROL_MESSAGE_LEN;
const DISCONNECT_DRAIN_TIMEOUT: Duration = Duration::from_millis(250); const DISCONNECT_DRAIN_TIMEOUT: Duration = Duration::from_millis(250);
const ETHERTYPE_EAPOL: u16 = 0x888e;
const ETHERTYPE_SLOW_PROTOCOLS: u16 = 0x8809;
const ETHERTYPE_LLDP: u16 = 0x88cc;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
const CAM_REFRESH_INTERVAL: Duration = Duration::from_secs(60); const CAM_REFRESH_INTERVAL: Duration = Duration::from_secs(60);
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -427,6 +430,11 @@ fn send_gateway_ethernet(
return Err(error).context("gateway Ethernet frame is malformed"); return Err(error).context("gateway Ethernet frame is malformed");
} }
}; };
if let Some(drop_reason) = gateway_lan_drop_reason(ethernet_frame) {
stats.record_dropped_frame();
return Ok(GatewaySendOutcome::Dropped(drop_reason));
}
let datagram = encode_datagram( let datagram = encode_datagram(
FrameType::Ethernet, FrameType::Ethernet,
welcome.room_id(), welcome.room_id(),
@@ -448,6 +456,35 @@ fn send_gateway_ethernet(
Ok(GatewaySendOutcome::Sent) Ok(GatewaySendOutcome::Sent)
} }
fn gateway_lan_drop_reason(frame: EthernetFrame<'_>) -> Option<DropReason> {
if !frame.source().is_valid_unicast() {
return Some(DropReason::InvalidSourceMac);
}
if frame.is_jumbo() {
return Some(DropReason::JumboFrame);
}
if is_lan_control_plane_frame(frame) {
return Some(DropReason::ControlPlaneEtherType);
}
None
}
fn is_lan_control_plane_frame(frame: EthernetFrame<'_>) -> bool {
matches!(
frame.ethertype_or_len(),
ETHERTYPE_EAPOL | ETHERTYPE_SLOW_PROTOCOLS | ETHERTYPE_LLDP
) || is_link_local_control_destination(frame.destination())
}
fn is_link_local_control_destination(mac: MacAddr) -> bool {
let [a, b, c, d, e, f] = mac.octets();
[a, b, c, d, e] == [0x01, 0x80, 0xc2, 0x00, 0x00] && f <= 0x0f
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GatewaySendOutcome { enum GatewaySendOutcome {
Sent, Sent,
@@ -997,7 +1034,7 @@ mod tests {
let ControlMessage::Stats(stats) = stats_message else { let ControlMessage::Stats(stats) = stats_message else {
panic!("expected gateway stats event"); panic!("expected gateway stats event");
}; };
assert_eq!(stats, TunnelStats::new(1, 1, 1, 1, 2, 1)); assert_eq!(stats, TunnelStats::new(1, 1, 1, 1, 4, 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();
@@ -1044,6 +1081,16 @@ mod tests {
assert_eq!(peer.role(), Role::Client); assert_eq!(peer.role(), Role::Client);
assert_eq!(peer.mac(), Some(MacAddr::new([0x02, 0, 0, 0, 0, 9]))); assert_eq!(peer.mac(), Some(MacAddr::new([0x02, 0, 0, 0, 0, 9])));
assert!(
gateway
.send_ethernet(&control_plane_ethernet_frame())
.is_err()
);
assert!(
gateway
.send_ethernet(&ethernet_frame_from(MacAddr::BROADCAST, b"invalid source"))
.is_err()
);
gateway.send_ethernet(&ethernet_frame(b"to relay")).unwrap(); gateway.send_ethernet(&ethernet_frame(b"to relay")).unwrap();
let received = tokio::time::timeout(Duration::from_secs(5), gateway.recv_ethernet()) let received = tokio::time::timeout(Duration::from_secs(5), gateway.recv_ethernet())
.await .await
@@ -1060,7 +1107,7 @@ mod tests {
.is_err() .is_err()
); );
let stats = gateway.stats_snapshot(); let stats = gateway.stats_snapshot();
assert_eq!(stats, TunnelStats::new(1, 1, 1, 1, 2, 1)); assert_eq!(stats, TunnelStats::new(1, 1, 1, 1, 4, 1));
gateway.send_stats_snapshot().await.unwrap(); gateway.send_stats_snapshot().await.unwrap();
tokio::time::timeout(Duration::from_secs(5), stats_received_rx) tokio::time::timeout(Duration::from_secs(5), stats_received_rx)
@@ -1099,6 +1146,32 @@ mod tests {
assert_eq!(snapshot.malformed_frames(), 1); assert_eq!(snapshot.malformed_frames(), 1);
} }
#[test]
fn classifies_gateway_lan_local_drops() {
let normal_bytes = ethernet_frame(b"normal");
let normal = EthernetFrame::parse(&normal_bytes).unwrap();
assert_eq!(gateway_lan_drop_reason(normal), None);
let invalid_source_bytes = ethernet_frame_from(MacAddr::BROADCAST, b"invalid");
let invalid_source = EthernetFrame::parse(&invalid_source_bytes).unwrap();
assert_eq!(
gateway_lan_drop_reason(invalid_source),
Some(DropReason::InvalidSourceMac)
);
let jumbo_payload = vec![0; lanparty_proto::MAX_STANDARD_ETHERNET_PAYLOAD_LEN + 1];
let jumbo_bytes = ethernet_frame(&jumbo_payload);
let jumbo = EthernetFrame::parse(&jumbo_bytes).unwrap();
assert_eq!(gateway_lan_drop_reason(jumbo), Some(DropReason::JumboFrame));
let control_plane_bytes = control_plane_ethernet_frame();
let control_plane = EthernetFrame::parse(&control_plane_bytes).unwrap();
assert_eq!(
gateway_lan_drop_reason(control_plane),
Some(DropReason::ControlPlaneEtherType)
);
}
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[test] #[test]
fn builds_padded_cam_refresh_frame() { fn builds_padded_cam_refresh_frame() {
@@ -1247,10 +1320,28 @@ mod tests {
} }
fn ethernet_frame_from(source: MacAddr, payload: &[u8]) -> Vec<u8> { fn ethernet_frame_from(source: MacAddr, payload: &[u8]) -> Vec<u8> {
ethernet_frame_with_addresses(MacAddr::new([0x02, 0, 0, 0, 0, 2]), source, 0x0800, payload)
}
fn control_plane_ethernet_frame() -> Vec<u8> {
ethernet_frame_with_addresses(
MacAddr::new([0x01, 0x80, 0xc2, 0, 0, 0]),
MacAddr::new([0x02, 0, 0, 0, 0, 1]),
ETHERTYPE_LLDP,
b"control",
)
}
fn ethernet_frame_with_addresses(
destination: MacAddr,
source: MacAddr,
ethertype: u16,
payload: &[u8],
) -> Vec<u8> {
let mut frame = Vec::new(); let mut frame = Vec::new();
frame.extend_from_slice(&[0x02, 0, 0, 0, 0, 2]); frame.extend_from_slice(&destination.octets());
frame.extend_from_slice(&source.octets()); frame.extend_from_slice(&source.octets());
frame.extend_from_slice(&0x0800_u16.to_be_bytes()); frame.extend_from_slice(&ethertype.to_be_bytes());
frame.extend_from_slice(payload); frame.extend_from_slice(payload);
frame frame
} }