diff --git a/README.md b/README.md index e7ac0be..0448f58 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,8 @@ never fragments Ethernet frames; LAN frames with invalid source MACs, L2 control-plane traffic, jumbo frames, or encoded datagrams exceeding the negotiated QUIC budget are counted, dropped, and logged locally instead of stopping the bridge or consuming relay bandwidth. Remote frames received from -the relay are safety-checked again before LAN injection, so invalid-source, +the relay are safety-checked again before LAN injection and must use the +announced virtual MAC for their source peer, so invalid-source, forged-source, L2 control-plane, remote VLAN, DHCP-server, IPv6 Router Advertisement, IPv6 fragment, and jumbo frames cannot cross the gateway's final physical-LAN boundary even if they reached the gateway over QUIC. @@ -192,9 +193,9 @@ boundary even if they reached the gateway over QUIC. The gateway rejects Linux interfaces that sysfs identifies as Wi-Fi, and rejects wired interfaces whose sysfs carrier state reports no link; managed wireless NICs are not supported for the physical LAN bridge. -It tracks remote-client source MACs seen from relay traffic and periodically -emits small CAM refresh frames so the physical switch keeps those MACs -associated with the gateway port. Gateway +It tracks remote-client MACs from relay lifecycle events and periodically emits +small CAM refresh frames so the physical switch keeps those MACs associated +with the gateway port. Gateway frame logs include direction, peer id when present, MACs, ethertype/length, frame length, action, and drop reason. The gateway also tracks frame/datagram counters and periodically sends stats snapshots to the relay. Malformed or runt diff --git a/TESTING.md b/TESTING.md index 68ec516..30273e6 100644 --- a/TESTING.md +++ b/TESTING.md @@ -220,6 +220,10 @@ drop_reason=Ipv6RouterAdvertisement drop_reason=Ipv6Fragment ``` +On gateway `RemoteToLan` logs, `UnauthorizedSourceMac` means the relayed peer id +did not match the client MAC announced by lifecycle events. If it repeats, +check relay lifecycle logs and duplicate-MAC rejection first. + ## Troubleshooting If the client says `Waiting for LAN gateway`, check that the gateway uses the diff --git a/crates/lanparty-gateway/src/lib.rs b/crates/lanparty-gateway/src/lib.rs index 18835cc..540717a 100644 --- a/crates/lanparty-gateway/src/lib.rs +++ b/crates/lanparty-gateway/src/lib.rs @@ -291,7 +291,7 @@ impl GatewayConnection { #[cfg(target_os = "linux")] pub async fn bridge_until_shutdown(self, packet_socket: PacketSocket) -> Result<()> { - let mut cam_refresh = CamRefresh::new(packet_socket.interface_mac()); + let mut remote_clients = RemoteClientTable::new(packet_socket.interface_mac()); let mut cam_refresh_tick = tokio::time::interval_at( tokio::time::Instant::now() + CAM_REFRESH_INTERVAL, CAM_REFRESH_INTERVAL, @@ -375,8 +375,24 @@ impl GatewayConnection { relay_frame = recv_gateway_ethernet_outcome(&connection, &welcome, &stats) => { match relay_frame? { GatewayReceiveOutcome::Accepted(relay_frame) => { - cam_refresh - .observe_remote_frame(relay_frame.source_peer_id(), relay_frame.payload())?; + if let Some(drop_reason) = remote_clients.relay_frame_drop_reason( + relay_frame.source_peer_id(), + relay_frame.payload(), + )? { + stats.record_dropped_frame(); + println!( + "{}", + gateway_frame_log_line( + packet_socket.get_ref().interface(), + FrameDirection::RemoteToLan, + Some(relay_frame.source_peer_id()), + relay_frame.payload(), + FrameAction::Filtered, + Some(drop_reason), + ) + ); + continue; + } write_lan_ethernet(&packet_socket, relay_frame.payload()).await?; println!( "{}", @@ -406,7 +422,7 @@ impl GatewayConnection { } } _ = cam_refresh_tick.tick() => { - for frame in cam_refresh.refresh_frames() { + for frame in remote_clients.refresh_frames() { write_lan_ethernet(&packet_socket, &frame).await?; } } @@ -419,7 +435,7 @@ impl GatewayConnection { let control_stream = control_stream .context("failed to accept gateway control event stream")?; let control_event = read_gateway_control_event(control_stream).await?; - cam_refresh.observe_control_event(&control_event); + remote_clients.observe_control_event(&control_event); println!("{}", format_gateway_control_event(&control_event)); } } @@ -782,13 +798,13 @@ async fn write_lan_ethernet(packet_socket: &AsyncFd, frame: &[u8]) #[cfg(target_os = "linux")] #[derive(Debug, Clone)] -struct CamRefresh { +struct RemoteClientTable { gateway_mac: MacAddr, remote_clients: BTreeMap, } #[cfg(target_os = "linux")] -impl CamRefresh { +impl RemoteClientTable { fn new(gateway_mac: MacAddr) -> Self { Self { gateway_mac, @@ -796,14 +812,16 @@ impl CamRefresh { } } - fn observe_remote_frame(&mut self, peer_id: u32, frame: &[u8]) -> Result<()> { + fn relay_frame_drop_reason(&self, peer_id: u32, frame: &[u8]) -> Result> { let frame = EthernetFrame::parse(frame).context("relay Ethernet frame is malformed")?; - let source = frame.source(); - if source.is_valid_client_identity() { - self.remote_clients.insert(peer_id, source); + let Some(expected_source) = self.remote_clients.get(&peer_id) else { + return Ok(Some(DropReason::UnauthorizedSourceMac)); + }; + if frame.source() != *expected_source { + return Ok(Some(DropReason::UnauthorizedSourceMac)); } - Ok(()) + Ok(None) } fn observe_control_event(&mut self, event: &ControlMessage) { @@ -817,6 +835,7 @@ impl CamRefresh { fn observe_peer_joined(&mut self, peer: &PeerInfo) { if peer.role() == Role::Client && let Some(mac) = peer.mac() + && mac.is_valid_client_identity() { self.remote_clients.insert(peer.peer_id(), mac); } @@ -1012,6 +1031,8 @@ mod tests { let endpoint = Endpoint::server(server_config, "127.0.0.1:0".parse().unwrap()).unwrap(); let server_addr = endpoint.local_addr().unwrap(); let (stats_received_tx, stats_received_rx) = tokio::sync::oneshot::channel(); + let remote_client_mac = MacAddr::new([0x02, 0, 0, 0, 0, 9]); + let server_remote_client_mac = remote_client_mac; let server_task = tokio::spawn(async move { let incoming = endpoint.accept().await.unwrap(); let connection = incoming.await.unwrap(); @@ -1033,7 +1054,7 @@ mod tests { send.finish().unwrap(); let joined = encode_control_message(&ControlMessage::PeerJoined( - PeerInfo::new(99, Role::Client, Some(MacAddr::new([0x02, 0, 0, 0, 0, 9]))).unwrap(), + PeerInfo::new(99, Role::Client, Some(server_remote_client_mac)).unwrap(), )) .unwrap(); let mut event_send = connection.open_uni().await.unwrap(); @@ -1065,7 +1086,7 @@ mod tests { 7, 99, 0, - ðernet_frame(b"from relay"), + ðernet_frame_from(server_remote_client_mac, b"from relay"), ) .unwrap(); connection.send_datagram(Bytes::from(response)).unwrap(); @@ -1121,7 +1142,7 @@ mod tests { }; assert_eq!(peer.peer_id(), 99); assert_eq!(peer.role(), Role::Client); - assert_eq!(peer.mac(), Some(MacAddr::new([0x02, 0, 0, 0, 0, 9]))); + assert_eq!(peer.mac(), Some(remote_client_mac)); assert!( gateway @@ -1139,7 +1160,10 @@ mod tests { .unwrap() .unwrap(); assert_eq!(received.source_peer_id(), 99); - assert_eq!(received.payload(), ethernet_frame(b"from relay").as_slice()); + assert_eq!( + received.payload(), + ethernet_frame_from(remote_client_mac, b"from relay").as_slice() + ); assert!(gateway.send_ethernet(&[0; 4]).is_err()); let oversized_payload = vec![0; usize::from(gateway.quic_max_datagram_size())]; @@ -1206,26 +1230,41 @@ mod tests { #[cfg(target_os = "linux")] #[test] - fn tracks_valid_remote_macs_for_cam_refresh() { + fn validates_relay_frame_sources_against_announced_client_macs() { let gateway_mac = MacAddr::new([0x0a, 0, 0, 0, 0, 1]); let remote_mac = MacAddr::new([0x02, 0, 0, 0, 0, 2]); - let invalid_remote_mac = MacAddr::BROADCAST; - let mut refresh = CamRefresh::new(gateway_mac); + let wrong_remote_mac = MacAddr::new([0x02, 0, 0, 0, 0, 3]); + let mut remote_clients = RemoteClientTable::new(gateway_mac); - refresh - .observe_remote_frame(7, ðernet_frame_from(remote_mac, b"remote")) - .unwrap(); - refresh - .observe_remote_frame(8, ðernet_frame_from(invalid_remote_mac, b"ignored")) - .unwrap(); + assert_eq!( + remote_clients + .relay_frame_drop_reason(7, ðernet_frame_from(remote_mac, b"unknown peer")) + .unwrap(), + Some(DropReason::UnauthorizedSourceMac) + ); - let frames = refresh.refresh_frames(); - let refresh_frame = EthernetFrame::parse(&frames[0]).unwrap(); + remote_clients.observe_control_event(&ControlMessage::PeerJoined( + PeerInfo::new(7, Role::Client, Some(remote_mac)).unwrap(), + )); - assert_eq!(refresh.remote_mac_count(), 1); - assert_eq!(frames.len(), 1); - assert_eq!(refresh_frame.source(), remote_mac); - assert_eq!(refresh_frame.destination(), gateway_mac); + assert_eq!( + remote_clients + .relay_frame_drop_reason(7, ðernet_frame_from(remote_mac, b"valid")) + .unwrap(), + None + ); + assert_eq!( + remote_clients + .relay_frame_drop_reason(7, ðernet_frame_from(wrong_remote_mac, b"forged")) + .unwrap(), + Some(DropReason::UnauthorizedSourceMac) + ); + assert_eq!( + remote_clients + .relay_frame_drop_reason(8, ðernet_frame_from(remote_mac, b"wrong peer")) + .unwrap(), + Some(DropReason::UnauthorizedSourceMac) + ); } #[cfg(target_os = "linux")] @@ -1233,7 +1272,7 @@ mod tests { fn updates_cam_refresh_from_lifecycle_events() { let gateway_mac = MacAddr::new([0x0a, 0, 0, 0, 0, 1]); let remote_mac = MacAddr::new([0x02, 0, 0, 0, 0, 2]); - let mut refresh = CamRefresh::new(gateway_mac); + let mut refresh = RemoteClientTable::new(gateway_mac); refresh.observe_control_event(&ControlMessage::PeerJoined( PeerInfo::new(7, Role::Client, Some(remote_mac)).unwrap(),