refactor(proto): share Ethernet safety classification

Safety filtering now applies at several tunnel boundaries. The relay remains
the trust boundary, while the client and gateway also drop unsafe frames before
spending relay bandwidth. Duplicating EtherType and IPv4/IPv6 parsers across
crates would make those rules drift as the MVP grows.

Move the Ethernet safety classifiers into lanparty-proto, expose typed safety
drop reasons, and map them back into the existing DropReason vocabulary. The
relay now uses the shared client and gateway classifiers, the gateway keeps its
local LAN-send drops through the shared classifier, and the client drops the
same remote-to-LAN safety cases before QUIC DATAGRAM encoding.

Document the client-side local drops and list the additional suspicious drop
reasons in the manual MVP test guide.

Test Plan:
- cargo test -p lanparty-proto safety
- cargo test -p lanparty-client-core connects_to_relay_control_stream_as_client
- cargo test -p lanparty-gateway connects_to_relay_control_stream_as_gateway
- cargo test -p lanparty-relay
- cargo fmt --check
- cargo test --workspace
- cargo clippy --workspace --all-targets -- -D warnings
- cargo check -p lanparty-client-tap --target x86_64-pc-windows-gnu --tests
- cargo check -p lanparty-client-route --target x86_64-pc-windows-gnu --tests
- cargo check -p lanparty-client-tap --target x86_64-pc-windows-msvc --tests
- cargo check -p lanparty-client-route --target x86_64-pc-windows-msvc --tests
- git diff --check

Refs: PLAN.md safety filters and client source-MAC isolation
This commit is contained in:
2026-05-22 05:16:33 +02:00
parent 985e4d9eed
commit a3ff75b29f
8 changed files with 542 additions and 259 deletions
+7 -6
View File
@@ -61,8 +61,8 @@ Platform-neutral remote client relay session:
- QUIC DATAGRAM support and negotiated datagram budget diagnostics - QUIC DATAGRAM support and negotiated datagram budget diagnostics
- relay RTT diagnostics from the active QUIC connection - relay RTT diagnostics from the active QUIC connection
- reliable relay control-event reads for peer lifecycle messages - reliable relay control-event reads for peer lifecycle messages
- Ethernet frame send/receive helpers over QUIC DATAGRAM with budget and source - Ethernet frame send/receive helpers over QUIC DATAGRAM with budget, source
MAC checks plus local drop outcomes for malformed or oversized sends MAC, and remote-to-LAN safety checks plus local drop outcomes
- client tunnel statistics for frame/datagram rx/tx and drops - client tunnel statistics for frame/datagram rx/tx and drops
- reliable client stats snapshot sends for relay diagnostics - reliable client stats snapshot sends for relay diagnostics
- best-effort graceful disconnect messages before QUIC close - best-effort graceful disconnect messages before QUIC close
@@ -252,10 +252,11 @@ after bridging starts become visible in later status lines. Each snapshot also
emits short user-facing lines such as relay/gateway connection status, emits short user-facing lines such as relay/gateway connection status,
relay-route and TAP readiness warnings, DHCP address presence, relay RTT, and relay-route and TAP readiness warnings, DHCP address presence, relay RTT, and
broadcast-flow confirmation when those signals are observed. Malformed frames broadcast-flow confirmation when those signals are observed. Malformed frames
read from TAP, invalid or unauthorized source-MAC frames, jumbo frames, and TAP read from TAP, invalid or unauthorized source-MAC frames, L2 control-plane
frames whose encoded datagrams exceed the negotiated QUIC budget are counted and traffic, remote VLAN tags, DHCP server replies, IPv6 Router Advertisements, IPv6
dropped before relay send without stopping the bridge; TAP device read/write fragments, jumbo frames, and TAP frames whose encoded datagrams exceed the
errors still stop the bridge. negotiated QUIC budget are counted and dropped before relay send without
stopping the bridge; TAP device read/write errors still stop the bridge.
Relay lifecycle events are logged as they arrive, including gateway joins and Relay lifecycle events are logged as they arrive, including gateway joins and
peer leaves. The client remembers peer identities from join and catch-up events peer leaves. The client remembers peer identities from join and catch-up events
so later leave logs can identify a disconnected LAN gateway or client MAC when so later leave logs can identify a disconnected LAN gateway or client MAC when
+4
View File
@@ -214,6 +214,10 @@ drop_reason=Malformed
drop_reason=InvalidSourceMac drop_reason=InvalidSourceMac
drop_reason=UnauthorizedSourceMac drop_reason=UnauthorizedSourceMac
drop_reason=ControlPlaneEtherType drop_reason=ControlPlaneEtherType
drop_reason=VlanTaggedFrame
drop_reason=DhcpServerReply
drop_reason=Ipv6RouterAdvertisement
drop_reason=Ipv6Fragment
``` ```
## Troubleshooting ## Troubleshooting
+61 -9
View File
@@ -27,7 +27,8 @@ use lanparty_ctrl::{
}; };
use lanparty_obs::{DropReason, QuicDiagnostics, TunnelStats}; use lanparty_obs::{DropReason, QuicDiagnostics, TunnelStats};
use lanparty_proto::{ use lanparty_proto::{
EthernetFrame, FrameType, MacAddr, decode_datagram, encode_datagram, validate_datagram_budget, EthernetFrame, FrameType, MacAddr, decode_datagram, encode_datagram,
remote_client_safety_drop_reason, validate_datagram_budget,
}; };
use quinn::{ClientConfig, Endpoint, crypto::rustls::QuicClientConfig}; use quinn::{ClientConfig, Endpoint, crypto::rustls::QuicClientConfig};
use rustls::pki_types::CertificateDer; use rustls::pki_types::CertificateDer;
@@ -381,10 +382,6 @@ impl ClientRelayIo {
return Ok(ClientSendOutcome::Dropped(DropReason::Malformed)); return Ok(ClientSendOutcome::Dropped(DropReason::Malformed));
} }
}; };
if ethernet_frame.is_jumbo() {
self.stats.record_dropped_frame();
return Ok(ClientSendOutcome::Dropped(DropReason::JumboFrame));
}
if !ethernet_frame.source().is_valid_unicast() { if !ethernet_frame.source().is_valid_unicast() {
self.stats.record_dropped_frame(); self.stats.record_dropped_frame();
return Ok(ClientSendOutcome::Dropped(DropReason::InvalidSourceMac)); return Ok(ClientSendOutcome::Dropped(DropReason::InvalidSourceMac));
@@ -395,6 +392,10 @@ impl ClientRelayIo {
DropReason::UnauthorizedSourceMac, DropReason::UnauthorizedSourceMac,
)); ));
} }
if let Some(drop_reason) = remote_client_safety_drop_reason(ethernet_frame) {
self.stats.record_dropped_frame();
return Ok(ClientSendOutcome::Dropped(DropReason::from(drop_reason)));
}
let datagram = encode_datagram( let datagram = encode_datagram(
FrameType::Ethernet, FrameType::Ethernet,
@@ -824,7 +825,7 @@ mod tests {
let ControlMessage::Stats(stats) = stats_message else { let ControlMessage::Stats(stats) = stats_message else {
panic!("expected client stats event"); panic!("expected client stats event");
}; };
assert_eq!(stats, TunnelStats::new(1, 1, 1, 1, 4, 1)); assert_eq!(stats, TunnelStats::new(1, 1, 1, 1, 7, 1));
stats_received_tx.send(()).unwrap(); stats_received_tx.send(()).unwrap();
let mut disconnect_recv = connection.accept_uni().await.unwrap(); let mut disconnect_recv = connection.accept_uni().await.unwrap();
@@ -929,6 +930,25 @@ mod tests {
.unwrap(), .unwrap(),
ClientSendOutcome::Dropped(DropReason::UnauthorizedSourceMac) ClientSendOutcome::Dropped(DropReason::UnauthorizedSourceMac)
); );
let jumbo_payload = vec![0; lanparty_proto::MAX_STANDARD_ETHERNET_PAYLOAD_LEN + 1];
assert_eq!(
relay_io
.send_ethernet_with_outcome(&ethernet_frame(&jumbo_payload))
.unwrap(),
ClientSendOutcome::Dropped(DropReason::JumboFrame)
);
assert_eq!(
relay_io
.send_ethernet_with_outcome(&control_plane_ethernet_frame())
.unwrap(),
ClientSendOutcome::Dropped(DropReason::ControlPlaneEtherType)
);
assert_eq!(
relay_io
.send_ethernet_with_outcome(&vlan_tagged_ethernet_frame())
.unwrap(),
ClientSendOutcome::Dropped(DropReason::VlanTaggedFrame)
);
let stats = relay_io.stats_snapshot(); let stats = relay_io.stats_snapshot();
assert_eq!(stats.ethernet_frames_tx(), 1); assert_eq!(stats.ethernet_frames_tx(), 1);
assert_eq!(stats.ethernet_frames_rx(), 1); assert_eq!(stats.ethernet_frames_rx(), 1);
@@ -936,7 +956,7 @@ mod tests {
assert_eq!(stats.broadcast_frames_rx(), 0); assert_eq!(stats.broadcast_frames_rx(), 0);
assert_eq!(stats.datagrams_tx(), 1); assert_eq!(stats.datagrams_tx(), 1);
assert_eq!(stats.datagrams_rx(), 1); assert_eq!(stats.datagrams_rx(), 1);
assert_eq!(stats.dropped_frames(), 4); assert_eq!(stats.dropped_frames(), 7);
assert_eq!(stats.malformed_frames(), 1); assert_eq!(stats.malformed_frames(), 1);
assert_eq!(client.stats_snapshot(), stats); assert_eq!(client.stats_snapshot(), stats);
@@ -1012,10 +1032,42 @@ mod tests {
} }
fn ethernet_frame_from(source: MacAddr, payload: &[u8]) -> Vec<u8> { fn ethernet_frame_from(source: MacAddr, payload: &[u8]) -> Vec<u8> {
ethernet_frame_with_headers(
MacAddr::new([0x02, 0, 0, 0, 0, 2]),
source,
lanparty_proto::ETHERTYPE_IPV4,
payload,
)
}
fn control_plane_ethernet_frame() -> Vec<u8> {
ethernet_frame_with_headers(
MacAddr::new([0x01, 0x80, 0xc2, 0, 0, 0]),
MacAddr::new([0x02, 0, 0, 0, 0, 1]),
lanparty_proto::ETHERTYPE_LLDP,
b"control",
)
}
fn vlan_tagged_ethernet_frame() -> Vec<u8> {
ethernet_frame_with_headers(
MacAddr::BROADCAST,
MacAddr::new([0x02, 0, 0, 0, 0, 1]),
lanparty_proto::ETHERTYPE_8021Q,
&[0; 4],
)
}
fn ethernet_frame_with_headers(
destination: MacAddr,
source: MacAddr,
ethertype_or_len: u16,
payload: &[u8],
) -> Vec<u8> {
let mut frame = Vec::new(); let mut frame = Vec::new();
frame.extend_from_slice(&[0x02, 0, 0, 0, 0, 2]); frame.extend_from_slice(&destination.octets());
frame.extend_from_slice(&source.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.extend_from_slice(payload);
frame frame
} }
+5 -62
View File
@@ -32,7 +32,8 @@ use lanparty_obs::{DropReason, TunnelStats};
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
use lanparty_obs::{FrameAction, FrameDirection, FrameLog}; use lanparty_obs::{FrameAction, FrameDirection, FrameLog};
use lanparty_proto::{ use lanparty_proto::{
EthernetFrame, FrameType, MacAddr, decode_datagram, encode_datagram, validate_datagram_budget, EthernetFrame, FrameType, MacAddr, decode_datagram, encode_datagram,
gateway_lan_safety_drop_reason, validate_datagram_budget,
}; };
use quinn::{ClientConfig, Endpoint, crypto::rustls::QuicClientConfig}; use quinn::{ClientConfig, Endpoint, crypto::rustls::QuicClientConfig};
use rustls::pki_types::CertificateDer; use rustls::pki_types::CertificateDer;
@@ -44,9 +45,6 @@ pub use packet::PacketSocket;
const MAX_CONTROL_FRAME_LEN: usize = CONTROL_LENGTH_PREFIX_LEN + MAX_CONTROL_MESSAGE_LEN; const MAX_CONTROL_FRAME_LEN: usize = CONTROL_LENGTH_PREFIX_LEN + MAX_CONTROL_MESSAGE_LEN;
const DISCONNECT_DRAIN_TIMEOUT: Duration = Duration::from_millis(250); const DISCONNECT_DRAIN_TIMEOUT: Duration = Duration::from_millis(250);
const ETHERTYPE_EAPOL: u16 = 0x888e;
const ETHERTYPE_SLOW_PROTOCOLS: u16 = 0x8809;
const ETHERTYPE_LLDP: u16 = 0x88cc;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
const CAM_REFRESH_INTERVAL: Duration = Duration::from_secs(60); const CAM_REFRESH_INTERVAL: Duration = Duration::from_secs(60);
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -430,9 +428,9 @@ fn send_gateway_ethernet(
return Err(error).context("gateway Ethernet frame is malformed"); return Err(error).context("gateway Ethernet frame is malformed");
} }
}; };
if let Some(drop_reason) = gateway_lan_drop_reason(ethernet_frame) { if let Some(drop_reason) = gateway_lan_safety_drop_reason(ethernet_frame) {
stats.record_dropped_frame(); stats.record_dropped_frame();
return Ok(GatewaySendOutcome::Dropped(drop_reason)); return Ok(GatewaySendOutcome::Dropped(DropReason::from(drop_reason)));
} }
let datagram = encode_datagram( let datagram = encode_datagram(
@@ -456,35 +454,6 @@ fn send_gateway_ethernet(
Ok(GatewaySendOutcome::Sent) Ok(GatewaySendOutcome::Sent)
} }
fn gateway_lan_drop_reason(frame: EthernetFrame<'_>) -> Option<DropReason> {
if !frame.source().is_valid_unicast() {
return Some(DropReason::InvalidSourceMac);
}
if frame.is_jumbo() {
return Some(DropReason::JumboFrame);
}
if is_lan_control_plane_frame(frame) {
return Some(DropReason::ControlPlaneEtherType);
}
None
}
fn is_lan_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
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GatewaySendOutcome { enum GatewaySendOutcome {
Sent, Sent,
@@ -1146,32 +1115,6 @@ mod tests {
assert_eq!(snapshot.malformed_frames(), 1); assert_eq!(snapshot.malformed_frames(), 1);
} }
#[test]
fn classifies_gateway_lan_local_drops() {
let normal_bytes = ethernet_frame(b"normal");
let normal = EthernetFrame::parse(&normal_bytes).unwrap();
assert_eq!(gateway_lan_drop_reason(normal), None);
let invalid_source_bytes = ethernet_frame_from(MacAddr::BROADCAST, b"invalid");
let invalid_source = EthernetFrame::parse(&invalid_source_bytes).unwrap();
assert_eq!(
gateway_lan_drop_reason(invalid_source),
Some(DropReason::InvalidSourceMac)
);
let jumbo_payload = vec![0; lanparty_proto::MAX_STANDARD_ETHERNET_PAYLOAD_LEN + 1];
let jumbo_bytes = ethernet_frame(&jumbo_payload);
let jumbo = EthernetFrame::parse(&jumbo_bytes).unwrap();
assert_eq!(gateway_lan_drop_reason(jumbo), Some(DropReason::JumboFrame));
let control_plane_bytes = control_plane_ethernet_frame();
let control_plane = EthernetFrame::parse(&control_plane_bytes).unwrap();
assert_eq!(
gateway_lan_drop_reason(control_plane),
Some(DropReason::ControlPlaneEtherType)
);
}
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[test] #[test]
fn builds_padded_cam_refresh_frame() { fn builds_padded_cam_refresh_frame() {
@@ -1327,7 +1270,7 @@ mod tests {
ethernet_frame_with_addresses( ethernet_frame_with_addresses(
MacAddr::new([0x01, 0x80, 0xc2, 0, 0, 0]), MacAddr::new([0x01, 0x80, 0xc2, 0, 0, 0]),
MacAddr::new([0x02, 0, 0, 0, 0, 1]), MacAddr::new([0x02, 0, 0, 0, 0, 1]),
ETHERTYPE_LLDP, lanparty_proto::ETHERTYPE_LLDP,
b"control", b"control",
) )
} }
+15 -1
View File
@@ -5,7 +5,7 @@
use std::net::IpAddr; use std::net::IpAddr;
use lanparty_proto::{EthernetFrame, MacAddr}; use lanparty_proto::{EthernetFrame, EthernetSafetyDrop, MacAddr};
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
@@ -43,6 +43,20 @@ pub enum DropReason {
RateLimit, RateLimit,
} }
impl From<EthernetSafetyDrop> for DropReason {
fn from(value: EthernetSafetyDrop) -> Self {
match value {
EthernetSafetyDrop::InvalidSourceMac => Self::InvalidSourceMac,
EthernetSafetyDrop::JumboFrame => Self::JumboFrame,
EthernetSafetyDrop::ControlPlaneEtherType => Self::ControlPlaneEtherType,
EthernetSafetyDrop::VlanTaggedFrame => Self::VlanTaggedFrame,
EthernetSafetyDrop::DhcpServerReply => Self::DhcpServerReply,
EthernetSafetyDrop::Ipv6RouterAdvertisement => Self::Ipv6RouterAdvertisement,
EthernetSafetyDrop::Ipv6Fragment => Self::Ipv6Fragment,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)] #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
pub struct FrameLog { pub struct FrameLog {
direction: FrameDirection, direction: FrameDirection,
+6
View File
@@ -8,6 +8,7 @@ mod ethernet;
mod mac; mod mac;
mod mtu; mod mtu;
mod overlay; mod overlay;
mod safety;
pub use ethernet::{ pub use ethernet::{
ETHERNET_HEADER_LEN, EthernetFrame, MAX_STANDARD_ETHERNET_FRAME_LEN, ETHERNET_HEADER_LEN, EthernetFrame, MAX_STANDARD_ETHERNET_FRAME_LEN,
@@ -22,3 +23,8 @@ pub use overlay::{
FrameType, OVERLAY_HEADER_LEN, OVERLAY_MAGIC, OVERLAY_VERSION, OverlayHeader, OverlayPacket, FrameType, OVERLAY_HEADER_LEN, OVERLAY_MAGIC, OVERLAY_VERSION, OverlayHeader, OverlayPacket,
ProtoError, decode_datagram, encode_datagram, validate_datagram_budget, ProtoError, decode_datagram, encode_datagram, validate_datagram_budget,
}; };
pub use safety::{
ETHERTYPE_8021AD, ETHERTYPE_8021Q, ETHERTYPE_EAPOL, ETHERTYPE_IPV4, ETHERTYPE_IPV6,
ETHERTYPE_LLDP, ETHERTYPE_QINQ, ETHERTYPE_SLOW_PROTOCOLS, EthernetSafetyDrop,
gateway_lan_safety_drop_reason, remote_client_safety_drop_reason,
};
+418
View File
@@ -0,0 +1,418 @@
use crate::{ETHERNET_HEADER_LEN, EthernetFrame, MacAddr};
pub const ETHERTYPE_IPV4: u16 = 0x0800;
pub const ETHERTYPE_EAPOL: u16 = 0x888e;
pub const ETHERTYPE_SLOW_PROTOCOLS: u16 = 0x8809;
pub const ETHERTYPE_LLDP: u16 = 0x88cc;
pub const ETHERTYPE_IPV6: u16 = 0x86dd;
pub const ETHERTYPE_8021Q: u16 = 0x8100;
pub const ETHERTYPE_8021AD: u16 = 0x88a8;
pub 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;
const IPV6_NEXT_HEADER_FRAGMENT: u8 = 44;
const IPV6_NEXT_HEADER_AH: u8 = 51;
const IPV6_NEXT_HEADER_NO_NEXT: u8 = 59;
const IPV6_NEXT_HEADER_DESTINATION_OPTIONS: u8 = 60;
const IPV6_NEXT_HEADER_ICMPV6: u8 = 58;
const DHCPV4_SERVER_PORT: u16 = 67;
const DHCPV4_CLIENT_PORT: u16 = 68;
const DHCPV6_CLIENT_PORT: u16 = 546;
const DHCPV6_SERVER_PORT: u16 = 547;
const ICMPV6_ROUTER_ADVERTISEMENT: u8 = 134;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EthernetSafetyDrop {
InvalidSourceMac,
JumboFrame,
ControlPlaneEtherType,
VlanTaggedFrame,
DhcpServerReply,
Ipv6RouterAdvertisement,
Ipv6Fragment,
}
pub fn gateway_lan_safety_drop_reason(frame: EthernetFrame<'_>) -> Option<EthernetSafetyDrop> {
if !frame.source().is_valid_unicast() {
return Some(EthernetSafetyDrop::InvalidSourceMac);
}
common_safety_drop_reason(frame)
}
pub fn remote_client_safety_drop_reason(frame: EthernetFrame<'_>) -> Option<EthernetSafetyDrop> {
if let Some(drop_reason) = common_safety_drop_reason(frame) {
return Some(drop_reason);
}
if is_vlan_tagged_frame(frame) {
return Some(EthernetSafetyDrop::VlanTaggedFrame);
}
if is_dhcp_server_reply(frame) {
return Some(EthernetSafetyDrop::DhcpServerReply);
}
if is_ipv6_router_advertisement(frame) {
return Some(EthernetSafetyDrop::Ipv6RouterAdvertisement);
}
if is_ipv6_fragment(frame) {
return Some(EthernetSafetyDrop::Ipv6Fragment);
}
None
}
fn common_safety_drop_reason(frame: EthernetFrame<'_>) -> Option<EthernetSafetyDrop> {
if frame.is_jumbo() {
return Some(EthernetSafetyDrop::JumboFrame);
}
if is_control_plane_frame(frame) {
return Some(EthernetSafetyDrop::ControlPlaneEtherType);
}
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_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();
[a, b, c, d, e] == [0x01, 0x80, 0xc2, 0x00, 0x00] && f <= 0x0f
}
fn is_dhcp_server_reply(frame: EthernetFrame<'_>) -> bool {
is_ipv4_dhcp_server_reply(frame) || is_ipv6_dhcp_server_reply(frame)
}
fn is_ipv4_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] != IP_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 == DHCPV4_SERVER_PORT && destination_port == DHCPV4_CLIENT_PORT
}
fn is_ipv6_dhcp_server_reply(frame: EthernetFrame<'_>) -> bool {
let bytes = frame.bytes();
if frame.ethertype_or_len() != ETHERTYPE_IPV6 || bytes.len() < ETHERNET_HEADER_LEN + 40 {
return false;
}
let ipv6 = &bytes[ETHERNET_HEADER_LEN..];
if !is_ipv6_packet(ipv6) {
return false;
}
let Some(udp_offset) = ipv6_upper_layer_payload_offset(ipv6, IP_PROTOCOL_UDP) else {
return false;
};
let Some(udp) = ipv6.get(udp_offset..udp_offset.saturating_add(4)) else {
return false;
};
let source_port = u16::from_be_bytes([udp[0], udp[1]]);
let destination_port = u16::from_be_bytes([udp[2], udp[3]]);
source_port == DHCPV6_SERVER_PORT && destination_port == DHCPV6_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 {
return false;
}
let ipv6 = &bytes[ETHERNET_HEADER_LEN..];
if !is_ipv6_packet(ipv6) {
return false;
}
let Some(icmpv6_offset) = ipv6_upper_layer_payload_offset(ipv6, IPV6_NEXT_HEADER_ICMPV6) else {
return false;
};
ipv6.get(icmpv6_offset)
.is_some_and(|message_type| *message_type == ICMPV6_ROUTER_ADVERTISEMENT)
}
fn is_ipv6_fragment(frame: EthernetFrame<'_>) -> bool {
let bytes = frame.bytes();
if frame.ethertype_or_len() != ETHERTYPE_IPV6 || bytes.len() < ETHERNET_HEADER_LEN + 40 {
return false;
}
let ipv6 = &bytes[ETHERNET_HEADER_LEN..];
is_ipv6_packet(ipv6) && ipv6_has_extension_header(ipv6, IPV6_NEXT_HEADER_FRAGMENT)
}
fn is_ipv6_packet(ipv6: &[u8]) -> bool {
ipv6.first().is_some_and(|first| first >> 4 == 6)
}
fn ipv6_has_extension_header(ipv6: &[u8], expected_next_header: u8) -> bool {
ipv6_upper_layer_payload_offset(ipv6, expected_next_header).is_some()
}
fn ipv6_upper_layer_payload_offset(ipv6: &[u8], expected_next_header: u8) -> Option<usize> {
let mut next_header = *ipv6.get(6)?;
let mut offset = 40_usize;
loop {
match next_header {
next_header if next_header == expected_next_header => return Some(offset),
IPV6_NEXT_HEADER_NO_NEXT => return None,
IPV6_NEXT_HEADER_HOP_BY_HOP
| IPV6_NEXT_HEADER_ROUTING
| IPV6_NEXT_HEADER_DESTINATION_OPTIONS => {
let extension = ipv6.get(offset..offset.checked_add(2)?)?;
next_header = extension[0];
let extension_len = (usize::from(extension[1]) + 1) * 8;
offset = offset.checked_add(extension_len)?;
if offset > ipv6.len() {
return None;
}
}
IPV6_NEXT_HEADER_FRAGMENT => {
let fragment = ipv6.get(offset..offset.checked_add(8)?)?;
next_header = fragment[0];
offset = offset.checked_add(8)?;
}
IPV6_NEXT_HEADER_AH => {
let extension = ipv6.get(offset..offset.checked_add(2)?)?;
next_header = extension[0];
let extension_len = (usize::from(extension[1]) + 2) * 4;
offset = offset.checked_add(extension_len)?;
if offset > ipv6.len() {
return None;
}
}
_ => return None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::MAX_STANDARD_ETHERNET_PAYLOAD_LEN;
#[test]
fn classifies_gateway_lan_safety_drops() {
assert_eq!(gateway_lan_safety_drop_reason(ethernet()), None);
let jumbo_payload = vec![0; MAX_STANDARD_ETHERNET_PAYLOAD_LEN + 1];
assert_eq!(
gateway_lan_safety_drop_reason(frame_with_source(MacAddr::BROADCAST)),
Some(EthernetSafetyDrop::InvalidSourceMac)
);
assert_eq!(
gateway_lan_safety_drop_reason(ethernet_with_payload(
MacAddr::new([0x02, 0, 0, 0, 0, 2]),
MacAddr::new([0x02, 0, 0, 0, 0, 1]),
ETHERTYPE_IPV4,
&jumbo_payload,
)),
Some(EthernetSafetyDrop::JumboFrame)
);
assert_eq!(
gateway_lan_safety_drop_reason(control_plane_frame()),
Some(EthernetSafetyDrop::ControlPlaneEtherType)
);
assert_eq!(
gateway_lan_safety_drop_reason(ethernet_with_payload(
MacAddr::new([0x02, 0, 0, 0, 0, 2]),
MacAddr::new([0x02, 0, 0, 0, 0, 1]),
ETHERTYPE_8021Q,
&[0; 4],
)),
None
);
}
#[test]
fn classifies_remote_client_safety_drops() {
assert_eq!(remote_client_safety_drop_reason(ethernet()), None);
assert_eq!(
remote_client_safety_drop_reason(control_plane_frame()),
Some(EthernetSafetyDrop::ControlPlaneEtherType)
);
assert_eq!(
remote_client_safety_drop_reason(ethernet_with_payload(
MacAddr::BROADCAST,
MacAddr::new([0x02, 0, 0, 0, 0, 1]),
ETHERTYPE_8021Q,
&[0; 4],
)),
Some(EthernetSafetyDrop::VlanTaggedFrame)
);
assert_eq!(
remote_client_safety_drop_reason(ethernet_with_payload(
MacAddr::BROADCAST,
MacAddr::new([0x02, 0, 0, 0, 0, 1]),
ETHERTYPE_IPV4,
&ipv4_udp_payload(DHCPV4_SERVER_PORT, DHCPV4_CLIENT_PORT),
)),
Some(EthernetSafetyDrop::DhcpServerReply)
);
assert_eq!(
remote_client_safety_drop_reason(ethernet_with_payload(
MacAddr::BROADCAST,
MacAddr::new([0x02, 0, 0, 0, 0, 1]),
ETHERTYPE_IPV6,
&ipv6_udp_after_destination_options_payload(DHCPV6_SERVER_PORT, DHCPV6_CLIENT_PORT,),
)),
Some(EthernetSafetyDrop::DhcpServerReply)
);
assert_eq!(
remote_client_safety_drop_reason(ethernet_with_payload(
MacAddr::BROADCAST,
MacAddr::new([0x02, 0, 0, 0, 0, 1]),
ETHERTYPE_IPV6,
&ipv6_router_advertisement_after_destination_options_payload(),
)),
Some(EthernetSafetyDrop::Ipv6RouterAdvertisement)
);
assert_eq!(
remote_client_safety_drop_reason(ethernet_with_payload(
MacAddr::BROADCAST,
MacAddr::new([0x02, 0, 0, 0, 0, 1]),
ETHERTYPE_IPV6,
&ipv6_payload(
IPV6_NEXT_HEADER_FRAGMENT,
&ipv6_fragment_payload(IP_PROTOCOL_UDP, b"fragment"),
),
)),
Some(EthernetSafetyDrop::Ipv6Fragment)
);
}
fn ethernet() -> EthernetFrame<'static> {
ethernet_with_payload(
MacAddr::new([0x02, 0, 0, 0, 0, 2]),
MacAddr::new([0x02, 0, 0, 0, 0, 1]),
ETHERTYPE_IPV4,
&[],
)
}
fn frame_with_source(source: MacAddr) -> EthernetFrame<'static> {
ethernet_with_payload(
MacAddr::new([0x02, 0, 0, 0, 0, 2]),
source,
ETHERTYPE_IPV4,
&[],
)
}
fn control_plane_frame() -> EthernetFrame<'static> {
ethernet_with_payload(
MacAddr::new([0x01, 0x80, 0xc2, 0, 0, 0]),
MacAddr::new([0x02, 0, 0, 0, 0, 1]),
ETHERTYPE_LLDP,
&[],
)
}
fn ethernet_with_payload(
destination: MacAddr,
source: MacAddr,
ethertype_or_len: u16,
payload: &[u8],
) -> EthernetFrame<'static> {
let mut frame = Vec::new();
frame.extend_from_slice(&destination.octets());
frame.extend_from_slice(&source.octets());
frame.extend_from_slice(&ethertype_or_len.to_be_bytes());
frame.extend_from_slice(payload);
EthernetFrame::parse(frame.leak()).unwrap()
}
fn ipv4_udp_payload(source_port: u16, destination_port: u16) -> Vec<u8> {
let mut packet = vec![0; 28];
packet[0] = 0x45;
packet[9] = IP_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 udp_payload(source_port: u16, destination_port: u16) -> Vec<u8> {
let mut packet = vec![0; 8];
packet[0..2].copy_from_slice(&source_port.to_be_bytes());
packet[2..4].copy_from_slice(&destination_port.to_be_bytes());
packet
}
fn ipv6_udp_after_destination_options_payload(
source_port: u16,
destination_port: u16,
) -> Vec<u8> {
ipv6_payload(
IPV6_NEXT_HEADER_DESTINATION_OPTIONS,
&ipv6_extension_payload(IP_PROTOCOL_UDP, &udp_payload(source_port, destination_port)),
)
}
fn ipv6_router_advertisement_after_destination_options_payload() -> Vec<u8> {
ipv6_payload(
IPV6_NEXT_HEADER_DESTINATION_OPTIONS,
&ipv6_extension_payload(IPV6_NEXT_HEADER_ICMPV6, &[ICMPV6_ROUTER_ADVERTISEMENT]),
)
}
fn ipv6_fragment_payload(next_header: u8, fragment_payload: &[u8]) -> Vec<u8> {
let mut packet = Vec::with_capacity(8 + fragment_payload.len());
packet.push(next_header);
packet.push(0);
packet.extend_from_slice(&0_u16.to_be_bytes());
packet.extend_from_slice(&1_u32.to_be_bytes());
packet.extend_from_slice(fragment_payload);
packet
}
fn ipv6_payload(next_header: u8, upper_payload: &[u8]) -> Vec<u8> {
let mut packet = vec![0; 40];
packet[0] = 0x60;
packet[6] = next_header;
packet.extend_from_slice(upper_payload);
packet
}
fn ipv6_extension_payload(next_header: u8, upper_payload: &[u8]) -> Vec<u8> {
let mut packet = Vec::with_capacity(8 + upper_payload.len());
packet.push(next_header);
packet.push(0);
packet.extend_from_slice(&[0; 6]);
packet.extend_from_slice(upper_payload);
packet
}
}
+26 -181
View File
@@ -13,7 +13,10 @@ use lanparty_ctrl::{
ControlError, EndpointHello, PeerInfo, Reject, RejectReason, Role, RoomCode, ServerWelcome, ControlError, EndpointHello, PeerInfo, Reject, RejectReason, Role, RoomCode, ServerWelcome,
}; };
use lanparty_obs::{DropReason, FrameAction}; use lanparty_obs::{DropReason, FrameAction};
use lanparty_proto::{ETHERNET_HEADER_LEN, EthernetFrame, MacAddr, recommended_tap_mtu}; use lanparty_proto::{
EthernetFrame, MacAddr, gateway_lan_safety_drop_reason, recommended_tap_mtu,
remote_client_safety_drop_reason,
};
use thiserror::Error; use thiserror::Error;
pub use config::{ConfigError, DEFAULT_RELAY_PORT, ListenEndpoint, RelayArgs, RelayConfig}; pub use config::{ConfigError, DEFAULT_RELAY_PORT, ListenEndpoint, RelayArgs, RelayConfig};
@@ -27,26 +30,35 @@ const CLIENT_UNKNOWN_UNICAST_BURST_FRAMES: u32 = 64;
const CLIENT_UNKNOWN_UNICAST_REFILL_FRAMES_PER_SECOND: u32 = 32; const CLIENT_UNKNOWN_UNICAST_REFILL_FRAMES_PER_SECOND: u32 = 32;
const CLIENT_TOTAL_BANDWIDTH_BURST_BYTES: u64 = 4 * MEBIBYTE; const CLIENT_TOTAL_BANDWIDTH_BURST_BYTES: u64 = 4 * MEBIBYTE;
const CLIENT_TOTAL_BANDWIDTH_REFILL_BYTES_PER_SECOND: u64 = 2 * MEBIBYTE; const CLIENT_TOTAL_BANDWIDTH_REFILL_BYTES_PER_SECOND: u64 = 2 * MEBIBYTE;
#[cfg(test)]
use lanparty_proto::ETHERNET_HEADER_LEN;
#[cfg(test)]
const ETHERTYPE_IPV4: u16 = 0x0800; const ETHERTYPE_IPV4: u16 = 0x0800;
const ETHERTYPE_EAPOL: u16 = 0x888e; #[cfg(test)]
const ETHERTYPE_SLOW_PROTOCOLS: u16 = 0x8809;
const ETHERTYPE_LLDP: u16 = 0x88cc; const ETHERTYPE_LLDP: u16 = 0x88cc;
#[cfg(test)]
const ETHERTYPE_IPV6: u16 = 0x86dd; const ETHERTYPE_IPV6: u16 = 0x86dd;
#[cfg(test)]
const ETHERTYPE_8021Q: u16 = 0x8100; const ETHERTYPE_8021Q: u16 = 0x8100;
#[cfg(test)]
const ETHERTYPE_8021AD: u16 = 0x88a8; const ETHERTYPE_8021AD: u16 = 0x88a8;
const ETHERTYPE_QINQ: u16 = 0x9100; #[cfg(test)]
const IP_PROTOCOL_UDP: u8 = 17; const IP_PROTOCOL_UDP: u8 = 17;
const IPV6_NEXT_HEADER_HOP_BY_HOP: u8 = 0; #[cfg(test)]
const IPV6_NEXT_HEADER_ROUTING: u8 = 43;
const IPV6_NEXT_HEADER_FRAGMENT: u8 = 44; const IPV6_NEXT_HEADER_FRAGMENT: u8 = 44;
const IPV6_NEXT_HEADER_AH: u8 = 51; #[cfg(test)]
const IPV6_NEXT_HEADER_NO_NEXT: u8 = 59;
const IPV6_NEXT_HEADER_DESTINATION_OPTIONS: u8 = 60; const IPV6_NEXT_HEADER_DESTINATION_OPTIONS: u8 = 60;
#[cfg(test)]
const IPV6_NEXT_HEADER_ICMPV6: u8 = 58; const IPV6_NEXT_HEADER_ICMPV6: u8 = 58;
#[cfg(test)]
const DHCPV4_SERVER_PORT: u16 = 67; const DHCPV4_SERVER_PORT: u16 = 67;
#[cfg(test)]
const DHCPV4_CLIENT_PORT: u16 = 68; const DHCPV4_CLIENT_PORT: u16 = 68;
#[cfg(test)]
const DHCPV6_CLIENT_PORT: u16 = 546; const DHCPV6_CLIENT_PORT: u16 = 546;
#[cfg(test)]
const DHCPV6_SERVER_PORT: u16 = 547; const DHCPV6_SERVER_PORT: u16 = 547;
#[cfg(test)]
const ICMPV6_ROUTER_ADVERTISEMENT: u8 = 134; const ICMPV6_ROUTER_ADVERTISEMENT: u8 = 134;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -542,8 +554,12 @@ 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) { let safety_drop_reason = match ingress_role {
return Ok(ForwardingDecision::filtered(drop_reason)); Role::Client => remote_client_safety_drop_reason(frame),
Role::Gateway => gateway_lan_safety_drop_reason(frame),
};
if let Some(drop_reason) = safety_drop_reason {
return Ok(ForwardingDecision::filtered(DropReason::from(drop_reason)));
} }
if ingress_role == Role::Client if ingress_role == Role::Client
@@ -726,177 +742,6 @@ fn client_total_bandwidth_limit() -> TokenBucket {
) )
} }
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_vlan_tagged_frame(frame) {
return Some(DropReason::VlanTaggedFrame);
}
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);
}
if ingress_role == Role::Client && is_ipv6_fragment(frame) {
return Some(DropReason::Ipv6Fragment);
}
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_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();
[a, b, c, d, e] == [0x01, 0x80, 0xc2, 0x00, 0x00] && f <= 0x0f
}
fn is_dhcp_server_reply(frame: EthernetFrame<'_>) -> bool {
is_ipv4_dhcp_server_reply(frame) || is_ipv6_dhcp_server_reply(frame)
}
fn is_ipv4_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] != IP_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 == DHCPV4_SERVER_PORT && destination_port == DHCPV4_CLIENT_PORT
}
fn is_ipv6_dhcp_server_reply(frame: EthernetFrame<'_>) -> bool {
let bytes = frame.bytes();
if frame.ethertype_or_len() != ETHERTYPE_IPV6 || bytes.len() < ETHERNET_HEADER_LEN + 40 {
return false;
}
let ipv6 = &bytes[ETHERNET_HEADER_LEN..];
if !is_ipv6_packet(ipv6) {
return false;
}
let Some(udp_offset) = ipv6_upper_layer_payload_offset(ipv6, IP_PROTOCOL_UDP) else {
return false;
};
let Some(udp) = ipv6.get(udp_offset..udp_offset.saturating_add(4)) else {
return false;
};
let source_port = u16::from_be_bytes([udp[0], udp[1]]);
let destination_port = u16::from_be_bytes([udp[2], udp[3]]);
source_port == DHCPV6_SERVER_PORT && destination_port == DHCPV6_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 {
return false;
}
let ipv6 = &bytes[ETHERNET_HEADER_LEN..];
if !is_ipv6_packet(ipv6) {
return false;
}
let Some(icmpv6_offset) = ipv6_upper_layer_payload_offset(ipv6, IPV6_NEXT_HEADER_ICMPV6) else {
return false;
};
ipv6.get(icmpv6_offset)
.is_some_and(|message_type| *message_type == ICMPV6_ROUTER_ADVERTISEMENT)
}
fn is_ipv6_fragment(frame: EthernetFrame<'_>) -> bool {
let bytes = frame.bytes();
if frame.ethertype_or_len() != ETHERTYPE_IPV6 || bytes.len() < ETHERNET_HEADER_LEN + 40 {
return false;
}
let ipv6 = &bytes[ETHERNET_HEADER_LEN..];
is_ipv6_packet(ipv6) && ipv6_has_extension_header(ipv6, IPV6_NEXT_HEADER_FRAGMENT)
}
fn is_ipv6_packet(ipv6: &[u8]) -> bool {
ipv6.first().is_some_and(|first| first >> 4 == 6)
}
fn ipv6_has_extension_header(ipv6: &[u8], expected_next_header: u8) -> bool {
ipv6_upper_layer_payload_offset(ipv6, expected_next_header).is_some()
}
fn ipv6_upper_layer_payload_offset(ipv6: &[u8], expected_next_header: u8) -> Option<usize> {
let mut next_header = *ipv6.get(6)?;
let mut offset = 40;
loop {
match next_header {
next_header if next_header == expected_next_header => return Some(offset),
IPV6_NEXT_HEADER_NO_NEXT => return None,
IPV6_NEXT_HEADER_HOP_BY_HOP
| IPV6_NEXT_HEADER_ROUTING
| IPV6_NEXT_HEADER_DESTINATION_OPTIONS => {
let extension = ipv6.get(offset..offset.checked_add(2)?)?;
next_header = extension[0];
let extension_len = (usize::from(extension[1]) + 1) * 8;
offset = offset.checked_add(extension_len)?;
if offset > ipv6.len() {
return None;
}
}
IPV6_NEXT_HEADER_FRAGMENT => {
let fragment = ipv6.get(offset..offset.checked_add(8)?)?;
next_header = fragment[0];
offset = offset.checked_add(8)?;
}
IPV6_NEXT_HEADER_AH => {
let extension = ipv6.get(offset..offset.checked_add(2)?)?;
next_header = extension[0];
let extension_len = (usize::from(extension[1]) + 2) * 4;
offset = offset.checked_add(extension_len)?;
if offset > ipv6.len() {
return None;
}
}
_ => return None,
}
}
}
fn reject_control_error(error: ControlError) -> Reject { fn reject_control_error(error: ControlError) -> Reject {
let reason = match error { let reason = match error {
ControlError::UnsupportedVersion { .. } => RejectReason::UnsupportedVersion, ControlError::UnsupportedVersion { .. } => RejectReason::UnsupportedVersion,