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
+2
View File
@@ -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 so the gateway and client can pin it in development. Production
certificate handling remains future work. Ethernet forwarding decisions are certificate handling remains future work. Ethernet forwarding decisions are
logged with room, peer, MAC, ethertype, action, drop reason, and target count. 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 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. 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 When a peer joins or leaves, the relay sends a reliable lifecycle control event
+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] #[must_use]
fn rate_limited() -> Self { fn rate_limited() -> Self {
Self { Self {
@@ -510,7 +519,7 @@ impl Room {
if ingress_role == Role::Client { if ingress_role == Role::Client {
let expected_source = ingress_mac.expect("client peers have MAC addresses"); let expected_source = ingress_mac.expect("client peers have MAC addresses");
if frame.source() != expected_source { if frame.source() != expected_source {
return Ok(ForwardingDecision::dropped( return Ok(ForwardingDecision::filtered(
DropReason::UnauthorizedSourceMac, DropReason::UnauthorizedSourceMac,
)); ));
} }
@@ -519,7 +528,7 @@ impl Room {
self.mark_peer_seen(ingress_peer_id, now); self.mark_peer_seen(ingress_peer_id, now);
if let Some(drop_reason) = safety_drop_reason(ingress_role, frame) { 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 if ingress_role == Role::Client
@@ -859,6 +868,12 @@ mod tests {
assert!(decision.targets().is_empty()); 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) { fn assert_rate_limited(decision: &ForwardingDecision) {
assert_eq!(decision.action(), FrameAction::RateLimited); assert_eq!(decision.action(), FrameAction::RateLimited);
assert_eq!(decision.drop_reason(), Some(DropReason::RateLimit)); assert_eq!(decision.drop_reason(), Some(DropReason::RateLimit));
@@ -1139,7 +1154,7 @@ mod tests {
before + Duration::from_secs(5), before + Duration::from_secs(5),
) )
.unwrap(); .unwrap();
assert_dropped(&decision, DropReason::UnauthorizedSourceMac); assert_filtered(&decision, DropReason::UnauthorizedSourceMac);
assert_eq!( assert_eq!(
registry registry
@@ -1275,7 +1290,7 @@ mod tests {
} }
#[test] #[test]
fn drops_client_frames_with_forged_source_mac() { fn filters_client_frames_with_forged_source_mac() {
let mut registry = RoomRegistry::default(); let mut registry = RoomRegistry::default();
let client = registry.join(client_hello(1)).unwrap(); let client = registry.join(client_hello(1)).unwrap();
let frame = ethernet(MacAddr::BROADCAST, mac(2)); let frame = ethernet(MacAddr::BROADCAST, mac(2));
@@ -1284,16 +1299,11 @@ mod tests {
.forward_ethernet(&room(), client.peer().peer_id(), &frame) .forward_ethernet(&room(), client.peer().peer_id(), &frame)
.unwrap(); .unwrap();
assert_eq!(decision.action(), FrameAction::Dropped); assert_filtered(&decision, DropReason::UnauthorizedSourceMac);
assert_eq!(
decision.drop_reason(),
Some(DropReason::UnauthorizedSourceMac)
);
assert!(decision.targets().is_empty());
} }
#[test] #[test]
fn drops_jumbo_frames() { fn filters_jumbo_frames() {
let mut registry = RoomRegistry::default(); let mut registry = RoomRegistry::default();
registry.join(gateway_hello()).unwrap(); registry.join(gateway_hello()).unwrap();
let client = registry.join(client_hello(1)).unwrap(); let client = registry.join(client_hello(1)).unwrap();
@@ -1304,11 +1314,11 @@ mod tests {
.forward_ethernet(&room(), client.peer().peer_id(), &frame) .forward_ethernet(&room(), client.peer().peer_id(), &frame)
.unwrap(); .unwrap();
assert_dropped(&decision, DropReason::JumboFrame); assert_filtered(&decision, DropReason::JumboFrame);
} }
#[test] #[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 mut registry = RoomRegistry::default();
let gateway = registry.join(gateway_hello()).unwrap(); let gateway = registry.join(gateway_hello()).unwrap();
let client = registry.join(client_hello(1)).unwrap(); let client = registry.join(client_hello(1)).unwrap();
@@ -1324,12 +1334,12 @@ mod tests {
.forward_ethernet(&room(), gateway.peer().peer_id(), &gateway_frame) .forward_ethernet(&room(), gateway.peer().peer_id(), &gateway_frame)
.unwrap(); .unwrap();
assert_dropped(&client_decision, DropReason::ControlPlaneEtherType); assert_filtered(&client_decision, DropReason::ControlPlaneEtherType);
assert_dropped(&gateway_decision, DropReason::ControlPlaneEtherType); assert_filtered(&gateway_decision, DropReason::ControlPlaneEtherType);
} }
#[test] #[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 mut registry = RoomRegistry::default();
let gateway = registry.join(gateway_hello()).unwrap(); let gateway = registry.join(gateway_hello()).unwrap();
let client = registry.join(client_hello(1)).unwrap(); let client = registry.join(client_hello(1)).unwrap();
@@ -1346,13 +1356,13 @@ mod tests {
.forward_ethernet(&room(), gateway.peer().peer_id(), &gateway_frame) .forward_ethernet(&room(), gateway.peer().peer_id(), &gateway_frame)
.unwrap(); .unwrap();
assert_dropped(&client_decision, DropReason::DhcpServerReply); assert_filtered(&client_decision, DropReason::DhcpServerReply);
assert_eq!(gateway_decision.action(), FrameAction::Forwarded); assert_eq!(gateway_decision.action(), FrameAction::Forwarded);
assert_eq!(gateway_decision.targets(), &[client.peer().peer_id()]); assert_eq!(gateway_decision.targets(), &[client.peer().peer_id()]);
} }
#[test] #[test]
fn drops_remote_ipv6_router_advertisements() { fn filters_remote_ipv6_router_advertisements() {
let mut registry = RoomRegistry::default(); let mut registry = RoomRegistry::default();
registry.join(gateway_hello()).unwrap(); registry.join(gateway_hello()).unwrap();
let client = registry.join(client_hello(1)).unwrap(); let client = registry.join(client_hello(1)).unwrap();
@@ -1368,7 +1378,7 @@ mod tests {
.forward_ethernet(&room(), client.peer().peer_id(), &frame) .forward_ethernet(&room(), client.peer().peer_id(), &frame)
.unwrap(); .unwrap();
assert_dropped(&decision, DropReason::Ipv6RouterAdvertisement); assert_filtered(&decision, DropReason::Ipv6RouterAdvertisement);
} }
#[test] #[test]