refactor(proto): share Ethernet safety classification

Safety filtering now applies at several tunnel boundaries. The relay remains
the trust boundary, while the client and gateway also drop unsafe frames before
spending relay bandwidth. Duplicating EtherType and IPv4/IPv6 parsers across
crates would make those rules drift as the MVP grows.

Move the Ethernet safety classifiers into lanparty-proto, expose typed safety
drop reasons, and map them back into the existing DropReason vocabulary. The
relay now uses the shared client and gateway classifiers, the gateway keeps its
local LAN-send drops through the shared classifier, and the client drops the
same remote-to-LAN safety cases before QUIC DATAGRAM encoding.

Document the client-side local drops and list the additional suspicious drop
reasons in the manual MVP test guide.

Test Plan:
- cargo test -p lanparty-proto safety
- cargo test -p lanparty-client-core connects_to_relay_control_stream_as_client
- cargo test -p lanparty-gateway connects_to_relay_control_stream_as_gateway
- cargo test -p lanparty-relay
- cargo fmt --check
- cargo test --workspace
- cargo clippy --workspace --all-targets -- -D warnings
- cargo check -p lanparty-client-tap --target x86_64-pc-windows-gnu --tests
- cargo check -p lanparty-client-route --target x86_64-pc-windows-gnu --tests
- cargo check -p lanparty-client-tap --target x86_64-pc-windows-msvc --tests
- cargo check -p lanparty-client-route --target x86_64-pc-windows-msvc --tests
- git diff --check

Refs: PLAN.md safety filters and client source-MAC isolation
This commit is contained in:
2026-05-22 05:16:33 +02:00
parent 985e4d9eed
commit a3ff75b29f
8 changed files with 542 additions and 259 deletions
+5 -62
View File
@@ -32,7 +32,8 @@ use lanparty_obs::{DropReason, TunnelStats};
#[cfg(target_os = "linux")]
use lanparty_obs::{FrameAction, FrameDirection, FrameLog};
use lanparty_proto::{
EthernetFrame, FrameType, MacAddr, decode_datagram, encode_datagram, validate_datagram_budget,
EthernetFrame, FrameType, MacAddr, decode_datagram, encode_datagram,
gateway_lan_safety_drop_reason, validate_datagram_budget,
};
use quinn::{ClientConfig, Endpoint, crypto::rustls::QuicClientConfig};
use rustls::pki_types::CertificateDer;
@@ -44,9 +45,6 @@ pub use packet::PacketSocket;
const MAX_CONTROL_FRAME_LEN: usize = CONTROL_LENGTH_PREFIX_LEN + MAX_CONTROL_MESSAGE_LEN;
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")]
const CAM_REFRESH_INTERVAL: Duration = Duration::from_secs(60);
#[cfg(target_os = "linux")]
@@ -430,9 +428,9 @@ fn send_gateway_ethernet(
return Err(error).context("gateway Ethernet frame is malformed");
}
};
if let Some(drop_reason) = gateway_lan_drop_reason(ethernet_frame) {
if let Some(drop_reason) = gateway_lan_safety_drop_reason(ethernet_frame) {
stats.record_dropped_frame();
return Ok(GatewaySendOutcome::Dropped(drop_reason));
return Ok(GatewaySendOutcome::Dropped(DropReason::from(drop_reason)));
}
let datagram = encode_datagram(
@@ -456,35 +454,6 @@ fn send_gateway_ethernet(
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)]
enum GatewaySendOutcome {
Sent,
@@ -1146,32 +1115,6 @@ mod tests {
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")]
#[test]
fn builds_padded_cam_refresh_frame() {
@@ -1327,7 +1270,7 @@ mod tests {
ethernet_frame_with_addresses(
MacAddr::new([0x01, 0x80, 0xc2, 0, 0, 0]),
MacAddr::new([0x02, 0, 0, 0, 0, 1]),
ETHERTYPE_LLDP,
lanparty_proto::ETHERTYPE_LLDP,
b"control",
)
}