From d4cb119b19e909582ea1ca6579cb4fe62e85c77a Mon Sep 17 00:00:00 2001 From: ddidderr Date: Thu, 21 May 2026 22:06:49 +0200 Subject: [PATCH] feat(relay): classify safety rejects as filtered PLAN.md describes relay frame logs with separate forwarded, dropped, filtered, and rate-limited actions. The relay had the shared `Filtered` action in its log vocabulary, but safety-policy rejects were still reported as generic drops. Classify forged client source MACs and L2 safety-filter matches as filtered forwarding decisions. Malformed frames and unknown destinations remain drops, while rate limits continue to use the rate-limited action. Document the distinction in the relay README section. Test Plan: - cargo fmt --check - cargo test -p lanparty-relay - cargo test --workspace - cargo clippy --workspace --all-targets -- -D warnings - git diff --check Refs: PLAN.md --- README.md | 2 ++ crates/lanparty-relay/src/lib.rs | 48 +++++++++++++++++++------------- 2 files changed, 31 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 35b8d9e..765d821 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,8 @@ self-signed development certificate; `--dev-cert-der-out` writes that certificate so the gateway and client can pin it in development. Production certificate handling remains future work. Ethernet forwarding decisions are logged with room, peer, MAC, ethertype, action, drop reason, and target count. +Safety-policy rejects use the `filtered` action so they are distinguishable +from malformed/unknown-destination drops and rate limits. Unknown unicast from a client is forwarded only to the gateway port; unknown unicast from the gateway is dropped instead of flooded to every remote client. When a peer joins or leaves, the relay sends a reliable lifecycle control event diff --git a/crates/lanparty-relay/src/lib.rs b/crates/lanparty-relay/src/lib.rs index 6f65c77..4d273c5 100644 --- a/crates/lanparty-relay/src/lib.rs +++ b/crates/lanparty-relay/src/lib.rs @@ -146,6 +146,15 @@ impl ForwardingDecision { } } + #[must_use] + fn filtered(drop_reason: DropReason) -> Self { + Self { + targets: Vec::new(), + action: FrameAction::Filtered, + drop_reason: Some(drop_reason), + } + } + #[must_use] fn rate_limited() -> Self { Self { @@ -510,7 +519,7 @@ impl Room { if ingress_role == Role::Client { let expected_source = ingress_mac.expect("client peers have MAC addresses"); if frame.source() != expected_source { - return Ok(ForwardingDecision::dropped( + return Ok(ForwardingDecision::filtered( DropReason::UnauthorizedSourceMac, )); } @@ -519,7 +528,7 @@ impl Room { self.mark_peer_seen(ingress_peer_id, now); if let Some(drop_reason) = safety_drop_reason(ingress_role, frame) { - return Ok(ForwardingDecision::dropped(drop_reason)); + return Ok(ForwardingDecision::filtered(drop_reason)); } if ingress_role == Role::Client @@ -859,6 +868,12 @@ mod tests { assert!(decision.targets().is_empty()); } + fn assert_filtered(decision: &ForwardingDecision, reason: DropReason) { + assert_eq!(decision.action(), FrameAction::Filtered); + assert_eq!(decision.drop_reason(), Some(reason)); + assert!(decision.targets().is_empty()); + } + fn assert_rate_limited(decision: &ForwardingDecision) { assert_eq!(decision.action(), FrameAction::RateLimited); assert_eq!(decision.drop_reason(), Some(DropReason::RateLimit)); @@ -1139,7 +1154,7 @@ mod tests { before + Duration::from_secs(5), ) .unwrap(); - assert_dropped(&decision, DropReason::UnauthorizedSourceMac); + assert_filtered(&decision, DropReason::UnauthorizedSourceMac); assert_eq!( registry @@ -1275,7 +1290,7 @@ mod tests { } #[test] - fn drops_client_frames_with_forged_source_mac() { + fn filters_client_frames_with_forged_source_mac() { let mut registry = RoomRegistry::default(); let client = registry.join(client_hello(1)).unwrap(); let frame = ethernet(MacAddr::BROADCAST, mac(2)); @@ -1284,16 +1299,11 @@ mod tests { .forward_ethernet(&room(), client.peer().peer_id(), &frame) .unwrap(); - assert_eq!(decision.action(), FrameAction::Dropped); - assert_eq!( - decision.drop_reason(), - Some(DropReason::UnauthorizedSourceMac) - ); - assert!(decision.targets().is_empty()); + assert_filtered(&decision, DropReason::UnauthorizedSourceMac); } #[test] - fn drops_jumbo_frames() { + fn filters_jumbo_frames() { let mut registry = RoomRegistry::default(); registry.join(gateway_hello()).unwrap(); let client = registry.join(client_hello(1)).unwrap(); @@ -1304,11 +1314,11 @@ mod tests { .forward_ethernet(&room(), client.peer().peer_id(), &frame) .unwrap(); - assert_dropped(&decision, DropReason::JumboFrame); + assert_filtered(&decision, DropReason::JumboFrame); } #[test] - fn drops_l2_control_plane_frames_from_clients_and_gateway() { + fn filters_l2_control_plane_frames_from_clients_and_gateway() { let mut registry = RoomRegistry::default(); let gateway = registry.join(gateway_hello()).unwrap(); let client = registry.join(client_hello(1)).unwrap(); @@ -1324,12 +1334,12 @@ mod tests { .forward_ethernet(&room(), gateway.peer().peer_id(), &gateway_frame) .unwrap(); - assert_dropped(&client_decision, DropReason::ControlPlaneEtherType); - assert_dropped(&gateway_decision, DropReason::ControlPlaneEtherType); + assert_filtered(&client_decision, DropReason::ControlPlaneEtherType); + assert_filtered(&gateway_decision, DropReason::ControlPlaneEtherType); } #[test] - fn drops_remote_dhcp_server_replies_but_allows_lan_replies() { + fn filters_remote_dhcp_server_replies_but_allows_lan_replies() { let mut registry = RoomRegistry::default(); let gateway = registry.join(gateway_hello()).unwrap(); let client = registry.join(client_hello(1)).unwrap(); @@ -1346,13 +1356,13 @@ mod tests { .forward_ethernet(&room(), gateway.peer().peer_id(), &gateway_frame) .unwrap(); - assert_dropped(&client_decision, DropReason::DhcpServerReply); + assert_filtered(&client_decision, DropReason::DhcpServerReply); assert_eq!(gateway_decision.action(), FrameAction::Forwarded); assert_eq!(gateway_decision.targets(), &[client.peer().peer_id()]); } #[test] - fn drops_remote_ipv6_router_advertisements() { + fn filters_remote_ipv6_router_advertisements() { let mut registry = RoomRegistry::default(); registry.join(gateway_hello()).unwrap(); let client = registry.join(client_hello(1)).unwrap(); @@ -1368,7 +1378,7 @@ mod tests { .forward_ethernet(&room(), client.peer().peer_id(), &frame) .unwrap(); - assert_dropped(&decision, DropReason::Ipv6RouterAdvertisement); + assert_filtered(&decision, DropReason::Ipv6RouterAdvertisement); } #[test]