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
This commit is contained in:
2026-05-21 22:06:49 +02:00
parent 325e5651a2
commit d4cb119b19
2 changed files with 31 additions and 19 deletions
+29 -19
View File
@@ -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]