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:
@@ -47,6 +47,7 @@ Public relay binary and relay-owned room state:
|
||||
- one gateway per room, duplicate client MAC rejection, and room limits
|
||||
- stable effective room MTU chosen before Ethernet datagrams flow
|
||||
- live Ethernet datagram forwarding with no ingress reflection
|
||||
- L2 safety filters for jumbo, switch-control, DHCP-server, and IPv6-RA frames
|
||||
- peer leave cleanup for room membership and MAC indexes
|
||||
|
||||
## Build
|
||||
|
||||
@@ -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(ðertype_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();
|
||||
|
||||
Reference in New Issue
Block a user