feat(relay): filter unsafe Ethernet control traffic

Relay forwarding now applies the MVP L2 safety policy before choosing output
peers. It drops jumbo frames, link-local switch-control destinations, EAPOL,
LLDP, and slow-protocol frames in both directions, and it blocks remote clients
from sending DHCP server replies or IPv6 router advertisements toward the LAN.

The filters live in the room forwarding path so the pure admission/forwarding
tests and live QUIC datagram path share the same policy. Gateway-origin DHCP
server replies remain allowed, which preserves the plan's goal that remote TAP
clients can receive LAN DHCP through the tunnel.

Test Plan:
- cargo fmt --check
- cargo test --workspace
- cargo clippy --workspace --all-targets -- -D warnings

Refs: PLAN.md L2 control-plane safety filters
This commit is contained in:
2026-05-21 18:00:11 +02:00
parent 756523927a
commit 956650ea8a
2 changed files with 206 additions and 2 deletions
+205 -2
View File
@@ -13,13 +13,23 @@ use lanparty_ctrl::{
ControlError, EndpointHello, PeerInfo, Reject, RejectReason, Role, RoomCode, ServerWelcome,
};
use lanparty_obs::{DropReason, FrameAction};
use lanparty_proto::{EthernetFrame, MacAddr, recommended_tap_mtu};
use lanparty_proto::{ETHERNET_HEADER_LEN, EthernetFrame, MacAddr, recommended_tap_mtu};
use thiserror::Error;
pub use config::{ConfigError, DEFAULT_RELAY_PORT, ListenEndpoint, RelayArgs, RelayConfig};
pub use server::RelayServer;
pub const DEFAULT_MAX_CLIENTS_PER_ROOM: usize = 16;
const ETHERTYPE_IPV4: u16 = 0x0800;
const ETHERTYPE_EAPOL: u16 = 0x888e;
const ETHERTYPE_SLOW_PROTOCOLS: u16 = 0x8809;
const ETHERTYPE_LLDP: u16 = 0x88cc;
const ETHERTYPE_IPV6: u16 = 0x86dd;
const IPV4_PROTOCOL_UDP: u8 = 17;
const IPV6_NEXT_HEADER_ICMPV6: u8 = 58;
const DHCP_SERVER_PORT: u16 = 67;
const DHCP_CLIENT_PORT: u16 = 68;
const ICMPV6_ROUTER_ADVERTISEMENT: u8 = 134;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JoinAccepted {
@@ -422,6 +432,10 @@ impl Room {
}
}
if let Some(drop_reason) = safety_drop_reason(ingress.role(), frame) {
return Ok(ForwardingDecision::dropped(drop_reason));
}
let targets = if frame.destination().is_multicast() {
self.all_peer_ids_except(ingress_peer_id)
} else if let Some(client_peer_id) = self.clients_by_mac.get(&frame.destination()) {
@@ -469,6 +483,78 @@ impl Room {
}
}
fn safety_drop_reason(ingress_role: Role, frame: EthernetFrame<'_>) -> Option<DropReason> {
if frame.is_jumbo() {
return Some(DropReason::JumboFrame);
}
if is_control_plane_frame(frame) {
return Some(DropReason::ControlPlaneEtherType);
}
if ingress_role == Role::Client && is_dhcp_server_reply(frame) {
return Some(DropReason::DhcpServerReply);
}
if ingress_role == Role::Client && is_ipv6_router_advertisement(frame) {
return Some(DropReason::Ipv6RouterAdvertisement);
}
None
}
fn is_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
}
fn is_dhcp_server_reply(frame: EthernetFrame<'_>) -> bool {
let bytes = frame.bytes();
if frame.ethertype_or_len() != ETHERTYPE_IPV4 || bytes.len() < ETHERNET_HEADER_LEN + 20 {
return false;
}
let ipv4 = &bytes[ETHERNET_HEADER_LEN..];
let version = ipv4[0] >> 4;
let header_len = usize::from(ipv4[0] & 0x0f) * 4;
if version != 4
|| header_len < 20
|| ipv4.len() < header_len + 8
|| ipv4[9] != IPV4_PROTOCOL_UDP
{
return false;
}
let udp = &ipv4[header_len..];
let source_port = u16::from_be_bytes([udp[0], udp[1]]);
let destination_port = u16::from_be_bytes([udp[2], udp[3]]);
source_port == DHCP_SERVER_PORT && destination_port == DHCP_CLIENT_PORT
}
fn is_ipv6_router_advertisement(frame: EthernetFrame<'_>) -> bool {
let bytes = frame.bytes();
if frame.ethertype_or_len() != ETHERTYPE_IPV6 || bytes.len() < ETHERNET_HEADER_LEN + 40 + 1 {
return false;
}
let ipv6 = &bytes[ETHERNET_HEADER_LEN..];
let version = ipv6[0] >> 4;
let next_header = ipv6[6];
version == 6
&& next_header == IPV6_NEXT_HEADER_ICMPV6
&& ipv6[40] == ICMPV6_ROUTER_ADVERTISEMENT
}
fn reject_control_error(error: ControlError) -> Reject {
let reason = match error {
ControlError::UnsupportedVersion { .. } => RejectReason::UnsupportedVersion,
@@ -489,6 +575,7 @@ fn reject_control_error(error: ControlError) -> Reject {
#[cfg(test)]
mod tests {
use super::*;
use lanparty_proto::MAX_STANDARD_ETHERNET_FRAME_LEN;
fn room() -> RoomCode {
RoomCode::new("ABCD").unwrap()
@@ -507,13 +594,50 @@ mod tests {
}
fn ethernet(destination: MacAddr, source: MacAddr) -> Vec<u8> {
ethernet_with_payload(destination, source, ETHERTYPE_IPV4, &[])
}
fn ethernet_with_payload(
destination: MacAddr,
source: MacAddr,
ethertype_or_len: u16,
payload: &[u8],
) -> Vec<u8> {
let mut frame = Vec::new();
frame.extend_from_slice(&destination.octets());
frame.extend_from_slice(&source.octets());
frame.extend_from_slice(&0x0800_u16.to_be_bytes());
frame.extend_from_slice(&ethertype_or_len.to_be_bytes());
frame.extend_from_slice(payload);
frame
}
fn ipv4_udp_payload(source_port: u16, destination_port: u16) -> Vec<u8> {
let mut packet = vec![0; 28];
packet[0] = 0x45;
packet[9] = IPV4_PROTOCOL_UDP;
packet[20..22].copy_from_slice(&source_port.to_be_bytes());
packet[22..24].copy_from_slice(&destination_port.to_be_bytes());
packet
}
fn ipv6_router_advertisement_payload() -> Vec<u8> {
let mut packet = vec![0; 41];
packet[0] = 0x60;
packet[6] = IPV6_NEXT_HEADER_ICMPV6;
packet[40] = ICMPV6_ROUTER_ADVERTISEMENT;
packet
}
fn physical_mac() -> MacAddr {
MacAddr::new([0x00, 1, 2, 3, 4, 5])
}
fn assert_dropped(decision: &ForwardingDecision, reason: DropReason) {
assert_eq!(decision.action(), FrameAction::Dropped);
assert_eq!(decision.drop_reason(), Some(reason));
assert!(decision.targets().is_empty());
}
#[test]
fn accepts_gateway_and_client_into_room() {
let mut registry = RoomRegistry::default();
@@ -717,6 +841,85 @@ mod tests {
assert!(decision.targets().is_empty());
}
#[test]
fn drops_jumbo_frames() {
let mut registry = RoomRegistry::default();
registry.join(gateway_hello()).unwrap();
let client = registry.join(client_hello(1)).unwrap();
let mut frame = ethernet(MacAddr::BROADCAST, mac(1));
frame.resize(MAX_STANDARD_ETHERNET_FRAME_LEN + 1, 0);
let decision = registry
.forward_ethernet(&room(), client.peer().peer_id(), &frame)
.unwrap();
assert_dropped(&decision, DropReason::JumboFrame);
}
#[test]
fn drops_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();
let stp_destination = MacAddr::new([0x01, 0x80, 0xc2, 0, 0, 0]);
let client_frame = ethernet_with_payload(stp_destination, mac(1), 0x0026, &[]);
let gateway_frame =
ethernet_with_payload(stp_destination, physical_mac(), ETHERTYPE_LLDP, &[]);
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_dropped(&client_decision, DropReason::ControlPlaneEtherType);
assert_dropped(&gateway_decision, DropReason::ControlPlaneEtherType);
}
#[test]
fn drops_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();
let payload = ipv4_udp_payload(DHCP_SERVER_PORT, DHCP_CLIENT_PORT);
let client_frame =
ethernet_with_payload(MacAddr::BROADCAST, mac(1), ETHERTYPE_IPV4, &payload);
let gateway_frame =
ethernet_with_payload(MacAddr::BROADCAST, physical_mac(), ETHERTYPE_IPV4, &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_dropped(&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() {
let mut registry = RoomRegistry::default();
registry.join(gateway_hello()).unwrap();
let client = registry.join(client_hello(1)).unwrap();
let destination = MacAddr::new([0x33, 0x33, 0, 0, 0, 1]);
let frame = ethernet_with_payload(
destination,
mac(1),
ETHERTYPE_IPV6,
&ipv6_router_advertisement_payload(),
);
let decision = registry
.forward_ethernet(&room(), client.peer().peer_id(), &frame)
.unwrap();
assert_dropped(&decision, DropReason::Ipv6RouterAdvertisement);
}
#[test]
fn drops_malformed_frames() {
let mut registry = RoomRegistry::default();