fix(relay): filter remote VLAN-tagged frames

The MVP bridge treats each remote player as a normal host on the LAN, not as a
trunk port. Allowing client-origin VLAN-tagged frames would let a remote client
send traffic outside the simple untagged Ethernet model, and could also hide
IPv4/IPv6 control traffic behind an outer VLAN EtherType that the existing
safety filters do not parse.

Filter 802.1Q, 802.1ad, and common QinQ-tagged frames from remote clients before
they can reach the physical LAN. LAN-origin tagged frames are still allowed back
toward clients so the gateway remains a transparent receiver for whatever the
local wired network emits. Add a dedicated drop reason so relay logs make the
policy clear.

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

Refs: MVP relay L2 safety filters
This commit is contained in:
2026-05-21 23:40:44 +02:00
parent efda797ae6
commit 0784e73f30
3 changed files with 45 additions and 3 deletions
+41
View File
@@ -32,6 +32,9 @@ const ETHERTYPE_EAPOL: u16 = 0x888e;
const ETHERTYPE_SLOW_PROTOCOLS: u16 = 0x8809;
const ETHERTYPE_LLDP: u16 = 0x88cc;
const ETHERTYPE_IPV6: u16 = 0x86dd;
const ETHERTYPE_8021Q: u16 = 0x8100;
const ETHERTYPE_8021AD: u16 = 0x88a8;
const ETHERTYPE_QINQ: u16 = 0x9100;
const IP_PROTOCOL_UDP: u8 = 17;
const IPV6_NEXT_HEADER_HOP_BY_HOP: u8 = 0;
const IPV6_NEXT_HEADER_ROUTING: u8 = 43;
@@ -732,6 +735,10 @@ fn safety_drop_reason(ingress_role: Role, frame: EthernetFrame<'_>) -> Option<Dr
return Some(DropReason::ControlPlaneEtherType);
}
if ingress_role == Role::Client && is_vlan_tagged_frame(frame) {
return Some(DropReason::VlanTaggedFrame);
}
if ingress_role == Role::Client && is_dhcp_server_reply(frame) {
return Some(DropReason::DhcpServerReply);
}
@@ -754,6 +761,13 @@ fn is_control_plane_frame(frame: EthernetFrame<'_>) -> bool {
) || is_link_local_control_destination(frame.destination())
}
fn is_vlan_tagged_frame(frame: EthernetFrame<'_>) -> bool {
matches!(
frame.ethertype_or_len(),
ETHERTYPE_8021Q | ETHERTYPE_8021AD | ETHERTYPE_QINQ
)
}
fn is_link_local_control_destination(mac: MacAddr) -> bool {
let [a, b, c, d, e, f] = mac.octets();
@@ -1515,6 +1529,33 @@ mod tests {
assert_filtered(&gateway_decision, DropReason::ControlPlaneEtherType);
}
#[test]
fn filters_remote_vlan_tagged_frames_but_allows_lan_tags() {
let mut registry = RoomRegistry::default();
let gateway = registry.join(gateway_hello()).unwrap();
let client = registry.join(client_hello(1)).unwrap();
let payload = [0, 42, 0x08, 0x00, 1, 2, 3, 4];
let client_frame =
ethernet_with_payload(MacAddr::BROADCAST, mac(1), ETHERTYPE_8021Q, &payload);
let gateway_frame = ethernet_with_payload(
MacAddr::BROADCAST,
physical_mac(),
ETHERTYPE_8021AD,
&payload,
);
let client_decision = registry
.forward_ethernet(&room(), client.peer().peer_id(), &client_frame)
.unwrap();
let gateway_decision = registry
.forward_ethernet(&room(), gateway.peer().peer_id(), &gateway_frame)
.unwrap();
assert_filtered(&client_decision, DropReason::VlanTaggedFrame);
assert_eq!(gateway_decision.action(), FrameAction::Forwarded);
assert_eq!(gateway_decision.targets(), &[client.peer().peer_id()]);
}
#[test]
fn filters_remote_dhcp_server_replies_but_allows_lan_replies() {
let mut registry = RoomRegistry::default();