feat(relay): add Ethernet forwarding decisions
Add socket-free relay forwarding logic for Ethernet datagrams. The future QUIC relay loop can now ask the room registry which peer IDs should receive a frame instead of embedding switching policy in network IO code. Forwarding validates that the ingress peer belongs to the room, drops malformed Ethernet frames, rejects client frames whose source MAC does not match the MAC announced during admission, never reflects frames back to ingress, routes known client unicasts directly, and floods broadcast/multicast or unknown unicast frames to the other room peers. The decision reports shared observability action and drop-reason values so the networking layer can log consistently. This still does not send bytes over QUIC; it only defines the room-local switch decision that the datagram loop will use. Test Plan: - cargo fmt --check - cargo test --workspace - cargo clippy --workspace --all-targets -- -D warnings Refs: PLAN.md Switching model
This commit is contained in:
Generated
+2
@@ -52,7 +52,9 @@ name = "lanparty-relay"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"lanparty-ctrl",
|
"lanparty-ctrl",
|
||||||
|
"lanparty-obs",
|
||||||
"lanparty-proto",
|
"lanparty-proto",
|
||||||
|
"thiserror",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ Public relay binary and relay-owned room state:
|
|||||||
- room admission for clients and gateways
|
- room admission for clients and gateways
|
||||||
- one gateway per room, duplicate client MAC rejection, and room limits
|
- one gateway per room, duplicate client MAC rejection, and room limits
|
||||||
- stable effective room MTU chosen before Ethernet datagrams flow
|
- stable effective room MTU chosen before Ethernet datagrams flow
|
||||||
|
- Ethernet datagram forwarding decisions with no ingress reflection
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
|
|||||||
@@ -5,4 +5,6 @@ edition.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
lanparty-ctrl = { path = "../lanparty-ctrl" }
|
lanparty-ctrl = { path = "../lanparty-ctrl" }
|
||||||
|
lanparty-obs = { path = "../lanparty-obs" }
|
||||||
lanparty-proto = { path = "../lanparty-proto" }
|
lanparty-proto = { path = "../lanparty-proto" }
|
||||||
|
thiserror.workspace = true
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ use std::collections::HashMap;
|
|||||||
use lanparty_ctrl::{
|
use lanparty_ctrl::{
|
||||||
ControlError, EndpointHello, PeerInfo, Reject, RejectReason, Role, RoomCode, ServerWelcome,
|
ControlError, EndpointHello, PeerInfo, Reject, RejectReason, Role, RoomCode, ServerWelcome,
|
||||||
};
|
};
|
||||||
use lanparty_proto::{MacAddr, recommended_tap_mtu};
|
use lanparty_obs::{DropReason, FrameAction};
|
||||||
|
use lanparty_proto::{EthernetFrame, MacAddr, recommended_tap_mtu};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
pub const DEFAULT_MAX_CLIENTS_PER_ROOM: usize = 16;
|
pub const DEFAULT_MAX_CLIENTS_PER_ROOM: usize = 16;
|
||||||
|
|
||||||
@@ -65,6 +67,56 @@ impl RoomSnapshot {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ForwardingDecision {
|
||||||
|
targets: Vec<u32>,
|
||||||
|
action: FrameAction,
|
||||||
|
drop_reason: Option<DropReason>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ForwardingDecision {
|
||||||
|
#[must_use]
|
||||||
|
fn forwarded(targets: Vec<u32>) -> Self {
|
||||||
|
Self {
|
||||||
|
targets,
|
||||||
|
action: FrameAction::Forwarded,
|
||||||
|
drop_reason: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
fn dropped(drop_reason: DropReason) -> Self {
|
||||||
|
Self {
|
||||||
|
targets: Vec::new(),
|
||||||
|
action: FrameAction::Dropped,
|
||||||
|
drop_reason: Some(drop_reason),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn targets(&self) -> &[u32] {
|
||||||
|
&self.targets
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn action(&self) -> FrameAction {
|
||||||
|
self.action
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn drop_reason(&self) -> Option<DropReason> {
|
||||||
|
self.drop_reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||||
|
pub enum ForwardingError {
|
||||||
|
#[error("room {0} does not exist")]
|
||||||
|
UnknownRoom(RoomCode),
|
||||||
|
#[error("ingress peer {peer_id} is not present in room {room}")]
|
||||||
|
UnknownIngressPeer { room: RoomCode, peer_id: u32 },
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RoomRegistry {
|
pub struct RoomRegistry {
|
||||||
rooms: HashMap<RoomCode, Room>,
|
rooms: HashMap<RoomCode, Room>,
|
||||||
@@ -125,6 +177,18 @@ impl RoomRegistry {
|
|||||||
self.rooms.get(room).map(Room::snapshot)
|
self.rooms.get(room).map(Room::snapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn forward_ethernet(
|
||||||
|
&self,
|
||||||
|
room: &RoomCode,
|
||||||
|
ingress_peer_id: u32,
|
||||||
|
frame_bytes: &[u8],
|
||||||
|
) -> Result<ForwardingDecision, ForwardingError> {
|
||||||
|
self.rooms
|
||||||
|
.get(room)
|
||||||
|
.ok_or_else(|| ForwardingError::UnknownRoom(room.clone()))?
|
||||||
|
.forward_ethernet(room, ingress_peer_id, frame_bytes)
|
||||||
|
}
|
||||||
|
|
||||||
fn allocate_room_id(&mut self) -> Result<u64, Reject> {
|
fn allocate_room_id(&mut self) -> Result<u64, Reject> {
|
||||||
let room_id = self.next_room_id;
|
let room_id = self.next_room_id;
|
||||||
self.next_room_id = self
|
self.next_room_id = self
|
||||||
@@ -260,6 +324,78 @@ impl Room {
|
|||||||
clients,
|
clients,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn forward_ethernet(
|
||||||
|
&self,
|
||||||
|
room_code: &RoomCode,
|
||||||
|
ingress_peer_id: u32,
|
||||||
|
frame_bytes: &[u8],
|
||||||
|
) -> Result<ForwardingDecision, ForwardingError> {
|
||||||
|
let ingress =
|
||||||
|
self.peer(ingress_peer_id)
|
||||||
|
.ok_or_else(|| ForwardingError::UnknownIngressPeer {
|
||||||
|
room: room_code.clone(),
|
||||||
|
peer_id: ingress_peer_id,
|
||||||
|
})?;
|
||||||
|
let frame = match EthernetFrame::parse(frame_bytes) {
|
||||||
|
Ok(frame) => frame,
|
||||||
|
Err(_) => return Ok(ForwardingDecision::dropped(DropReason::Malformed)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if ingress.role() == Role::Client {
|
||||||
|
let expected_source = ingress.mac().expect("client peers have MAC addresses");
|
||||||
|
if frame.source() != expected_source {
|
||||||
|
return Ok(ForwardingDecision::dropped(
|
||||||
|
DropReason::UnauthorizedSourceMac,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()) {
|
||||||
|
if *client_peer_id == ingress_peer_id {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
vec![*client_peer_id]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.all_peer_ids_except(ingress_peer_id)
|
||||||
|
};
|
||||||
|
|
||||||
|
if targets.is_empty() {
|
||||||
|
return Ok(ForwardingDecision::dropped(DropReason::UnknownDestination));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ForwardingDecision::forwarded(targets))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peer(&self, peer_id: u32) -> Option<&PeerInfo> {
|
||||||
|
self.gateway
|
||||||
|
.as_ref()
|
||||||
|
.filter(|peer| peer.peer_id() == peer_id)
|
||||||
|
.or_else(|| self.clients.get(&peer_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn all_peer_ids_except(&self, ingress_peer_id: u32) -> Vec<u32> {
|
||||||
|
let mut peer_ids =
|
||||||
|
Vec::with_capacity(self.clients.len() + usize::from(self.gateway.is_some()));
|
||||||
|
|
||||||
|
if let Some(gateway) = &self.gateway
|
||||||
|
&& gateway.peer_id() != ingress_peer_id
|
||||||
|
{
|
||||||
|
peer_ids.push(gateway.peer_id());
|
||||||
|
}
|
||||||
|
|
||||||
|
peer_ids.extend(
|
||||||
|
self.clients
|
||||||
|
.keys()
|
||||||
|
.copied()
|
||||||
|
.filter(|peer_id| *peer_id != ingress_peer_id),
|
||||||
|
);
|
||||||
|
peer_ids.sort_unstable();
|
||||||
|
peer_ids
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reject_control_error(error: ControlError) -> Reject {
|
fn reject_control_error(error: ControlError) -> Reject {
|
||||||
@@ -299,6 +435,14 @@ mod tests {
|
|||||||
EndpointHello::gateway(room(), 1400).unwrap()
|
EndpointHello::gateway(room(), 1400).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ethernet(destination: MacAddr, source: MacAddr) -> 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
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn accepts_gateway_and_client_into_room() {
|
fn accepts_gateway_and_client_into_room() {
|
||||||
let mut registry = RoomRegistry::default();
|
let mut registry = RoomRegistry::default();
|
||||||
@@ -371,4 +515,104 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(gateway.welcome().effective_tap_mtu(), 972);
|
assert_eq!(gateway.welcome().effective_tap_mtu(), 972);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forwards_unknown_client_unicast_to_gateway() {
|
||||||
|
let mut registry = RoomRegistry::default();
|
||||||
|
let gateway = registry.join(gateway_hello()).unwrap();
|
||||||
|
let client = registry.join(client_hello(1)).unwrap();
|
||||||
|
let frame = ethernet(MacAddr::new([0x00, 1, 2, 3, 4, 5]), mac(1));
|
||||||
|
|
||||||
|
let decision = registry
|
||||||
|
.forward_ethernet(&room(), client.peer().peer_id(), &frame)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(decision.action(), FrameAction::Forwarded);
|
||||||
|
assert_eq!(decision.targets(), &[gateway.peer().peer_id()]);
|
||||||
|
assert_eq!(decision.drop_reason(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forwards_gateway_unicast_to_matching_client() {
|
||||||
|
let mut registry = RoomRegistry::default();
|
||||||
|
let gateway = registry.join(gateway_hello()).unwrap();
|
||||||
|
let client_one = registry.join(client_hello(1)).unwrap();
|
||||||
|
let client_two = registry.join(client_hello(2)).unwrap();
|
||||||
|
let frame = ethernet(mac(2), MacAddr::new([0x00, 1, 2, 3, 4, 5]));
|
||||||
|
|
||||||
|
let decision = registry
|
||||||
|
.forward_ethernet(&room(), gateway.peer().peer_id(), &frame)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(decision.action(), FrameAction::Forwarded);
|
||||||
|
assert_eq!(decision.targets(), &[client_two.peer().peer_id()]);
|
||||||
|
assert!(!decision.targets().contains(&client_one.peer().peer_id()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn floods_broadcast_without_reflecting_ingress() {
|
||||||
|
let mut registry = RoomRegistry::default();
|
||||||
|
let gateway = registry.join(gateway_hello()).unwrap();
|
||||||
|
let client_one = registry.join(client_hello(1)).unwrap();
|
||||||
|
let client_two = registry.join(client_hello(2)).unwrap();
|
||||||
|
let frame = ethernet(MacAddr::BROADCAST, mac(1));
|
||||||
|
|
||||||
|
let decision = registry
|
||||||
|
.forward_ethernet(&room(), client_one.peer().peer_id(), &frame)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(decision.action(), FrameAction::Forwarded);
|
||||||
|
assert_eq!(
|
||||||
|
decision.targets(),
|
||||||
|
&[gateway.peer().peer_id(), client_two.peer().peer_id()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drops_client_frames_with_forged_source_mac() {
|
||||||
|
let mut registry = RoomRegistry::default();
|
||||||
|
let client = registry.join(client_hello(1)).unwrap();
|
||||||
|
let frame = ethernet(MacAddr::BROADCAST, mac(2));
|
||||||
|
|
||||||
|
let decision = registry
|
||||||
|
.forward_ethernet(&room(), client.peer().peer_id(), &frame)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(decision.action(), FrameAction::Dropped);
|
||||||
|
assert_eq!(
|
||||||
|
decision.drop_reason(),
|
||||||
|
Some(DropReason::UnauthorizedSourceMac)
|
||||||
|
);
|
||||||
|
assert!(decision.targets().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drops_malformed_frames() {
|
||||||
|
let mut registry = RoomRegistry::default();
|
||||||
|
let client = registry.join(client_hello(1)).unwrap();
|
||||||
|
|
||||||
|
let decision = registry
|
||||||
|
.forward_ethernet(&room(), client.peer().peer_id(), &[0; 4])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(decision.action(), FrameAction::Dropped);
|
||||||
|
assert_eq!(decision.drop_reason(), Some(DropReason::Malformed));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reports_unknown_ingress_peer() {
|
||||||
|
let mut registry = RoomRegistry::default();
|
||||||
|
registry.join(client_hello(1)).unwrap();
|
||||||
|
let frame = ethernet(MacAddr::BROADCAST, mac(1));
|
||||||
|
|
||||||
|
let error = registry.forward_ethernet(&room(), 99, &frame).unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
error,
|
||||||
|
ForwardingError::UnknownIngressPeer {
|
||||||
|
room: room(),
|
||||||
|
peer_id: 99,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user