diff --git a/README.md b/README.md index d00b83d..b089509 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,9 @@ cargo run -p lanparty-gateway -- \ The gateway connects to the relay as `role = gateway`, completes the control-stream hello/welcome handshake, opens an AF_PACKET socket on the LAN interface, and bridges Ethernet frames between the relay and wired LAN until -shutdown. +shutdown. 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. ## Windows Client diff --git a/crates/lanparty-gateway/src/lib.rs b/crates/lanparty-gateway/src/lib.rs index 61a6779..3973232 100644 --- a/crates/lanparty-gateway/src/lib.rs +++ b/crates/lanparty-gateway/src/lib.rs @@ -1,12 +1,13 @@ //! Linux LAN gateway control-plane connection. //! -//! This crate owns the gateway binary's relay connection and, in later slices, -//! will add the AF_PACKET capture/injection loop that feeds Ethernet frames into -//! this established QUIC session. +//! This crate owns the gateway binary's relay connection and Linux AF_PACKET +//! bridge loop that moves Ethernet frames between the relay and wired LAN. #[cfg(target_os = "linux")] mod packet; +#[cfg(target_os = "linux")] +use std::{collections::BTreeSet, time::Duration}; use std::{ fs, net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, @@ -22,7 +23,8 @@ use lanparty_ctrl::{ RoomCode, ServerWelcome, decode_control_frame, encode_control_message, }; use lanparty_proto::{ - EthernetFrame, FrameType, MAX_STANDARD_ETHERNET_FRAME_LEN, decode_datagram, encode_datagram, + EthernetFrame, FrameType, MAX_STANDARD_ETHERNET_FRAME_LEN, MacAddr, decode_datagram, + encode_datagram, }; use quinn::{ClientConfig, Endpoint, crypto::rustls::QuicClientConfig}; use rustls::pki_types::CertificateDer; @@ -33,6 +35,15 @@ use tokio::io::unix::AsyncFd; pub use packet::PacketSocket; const MAX_CONTROL_FRAME_LEN: usize = CONTROL_LENGTH_PREFIX_LEN + MAX_CONTROL_MESSAGE_LEN; +#[cfg(target_os = "linux")] +const CAM_REFRESH_INTERVAL: Duration = Duration::from_secs(60); +#[cfg(target_os = "linux")] +// Local experimental EtherType for frames whose only job is switch MAC learning. +const CAM_REFRESH_ETHERTYPE: u16 = 0x88b5; +#[cfg(target_os = "linux")] +const CAM_REFRESH_PAYLOAD: &[u8] = b"lanparty-cam-refresh"; +#[cfg(target_os = "linux")] +const MIN_ETHERNET_FRAME_WITHOUT_FCS: usize = 60; #[derive(Debug, Parser)] #[command( @@ -209,6 +220,12 @@ 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 cam_refresh_tick = tokio::time::interval_at( + tokio::time::Instant::now() + CAM_REFRESH_INTERVAL, + CAM_REFRESH_INTERVAL, + ); + cam_refresh_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); let packet_socket = AsyncFd::new(packet_socket) .context("failed to register AF_PACKET socket with Tokio")?; let Self { @@ -230,7 +247,14 @@ impl GatewayConnection { send_gateway_ethernet(&connection, &welcome, &lan_frame?)?; } relay_frame = recv_gateway_ethernet(&connection, &welcome) => { - write_lan_ethernet(&packet_socket, relay_frame?.payload()).await?; + let relay_frame = relay_frame?; + cam_refresh.observe_remote_frame(relay_frame.payload())?; + write_lan_ethernet(&packet_socket, relay_frame.payload()).await?; + } + _ = cam_refresh_tick.tick() => { + for frame in cam_refresh.refresh_frames() { + write_lan_ethernet(&packet_socket, &frame).await?; + } } } } @@ -329,6 +353,56 @@ async fn write_lan_ethernet(packet_socket: &AsyncFd, frame: &[u8]) } } +#[cfg(target_os = "linux")] +#[derive(Debug, Clone)] +struct CamRefresh { + gateway_mac: MacAddr, + remote_macs: BTreeSet, +} + +#[cfg(target_os = "linux")] +impl CamRefresh { + fn new(gateway_mac: MacAddr) -> Self { + Self { + gateway_mac, + remote_macs: BTreeSet::new(), + } + } + + fn observe_remote_frame(&mut self, 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_macs.insert(source); + } + + Ok(()) + } + + fn refresh_frames(&self) -> Vec> { + self.remote_macs + .iter() + .map(|source| cam_refresh_frame(*source, self.gateway_mac)) + .collect() + } + + #[cfg(test)] + fn remote_mac_count(&self) -> usize { + self.remote_macs.len() + } +} + +#[cfg(target_os = "linux")] +fn cam_refresh_frame(source: MacAddr, destination: MacAddr) -> Vec { + let mut frame = Vec::with_capacity(MIN_ETHERNET_FRAME_WITHOUT_FCS); + frame.extend_from_slice(&destination.octets()); + frame.extend_from_slice(&source.octets()); + frame.extend_from_slice(&CAM_REFRESH_ETHERTYPE.to_be_bytes()); + frame.extend_from_slice(CAM_REFRESH_PAYLOAD); + frame.resize(MIN_ETHERNET_FRAME_WITHOUT_FCS, 0); + frame +} + pub async fn connect_gateway(config: GatewayConfig) -> Result { let client_config = relay_client_config(config.relay_ca_cert_der())?; let mut endpoint = Endpoint::client(client_bind_addr(config.relay_addr())) @@ -542,6 +616,46 @@ mod tests { .unwrap(); } + #[cfg(target_os = "linux")] + #[test] + fn builds_padded_cam_refresh_frame() { + let gateway_mac = MacAddr::new([0x0a, 0, 0, 0, 0, 1]); + let remote_mac = MacAddr::new([0x02, 0, 0, 0, 0, 2]); + + let frame = cam_refresh_frame(remote_mac, gateway_mac); + let parsed = EthernetFrame::parse(&frame).unwrap(); + + assert_eq!(frame.len(), MIN_ETHERNET_FRAME_WITHOUT_FCS); + assert_eq!(parsed.destination(), gateway_mac); + assert_eq!(parsed.source(), remote_mac); + assert_eq!(parsed.ethertype_or_len(), CAM_REFRESH_ETHERTYPE); + assert!(frame[14..].starts_with(CAM_REFRESH_PAYLOAD)); + } + + #[cfg(target_os = "linux")] + #[test] + fn tracks_valid_remote_macs_for_cam_refresh() { + 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); + + refresh + .observe_remote_frame(ðernet_frame_from(remote_mac, b"remote")) + .unwrap(); + refresh + .observe_remote_frame(ðernet_frame_from(invalid_remote_mac, b"ignored")) + .unwrap(); + + let frames = refresh.refresh_frames(); + let refresh_frame = EthernetFrame::parse(&frames[0]).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); + } + fn test_server_config() -> (ServerConfig, CertificateDer<'static>) { let certified_key = rcgen::generate_simple_self_signed(vec!["lanparty-relay.local".into()]).unwrap(); @@ -567,9 +681,13 @@ mod tests { } fn ethernet_frame(payload: &[u8]) -> Vec { + ethernet_frame_from(MacAddr::new([0x02, 0, 0, 0, 0, 1]), payload) + } + + fn ethernet_frame_from(source: MacAddr, payload: &[u8]) -> Vec { let mut frame = Vec::new(); frame.extend_from_slice(&[0x02, 0, 0, 0, 0, 2]); - frame.extend_from_slice(&[0x02, 0, 0, 0, 0, 1]); + frame.extend_from_slice(&source.octets()); frame.extend_from_slice(&0x0800_u16.to_be_bytes()); frame.extend_from_slice(payload); frame diff --git a/crates/lanparty-gateway/src/packet.rs b/crates/lanparty-gateway/src/packet.rs index 1955fcf..8136655 100644 --- a/crates/lanparty-gateway/src/packet.rs +++ b/crates/lanparty-gateway/src/packet.rs @@ -4,6 +4,8 @@ use std::{ os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}, }; +use lanparty_proto::MacAddr; + const ETH_P_ALL: u16 = libc::ETH_P_ALL as u16; #[derive(Debug)] @@ -11,6 +13,7 @@ pub struct PacketSocket { fd: OwnedFd, interface: String, interface_index: u32, + interface_mac: MacAddr, } impl PacketSocket { @@ -55,11 +58,13 @@ impl PacketSocket { if result < 0 { return Err(io::Error::last_os_error()); } + let interface_mac = interface_hardware_addr(fd.as_raw_fd(), interface)?; Ok(Self { fd, interface: interface.to_owned(), interface_index, + interface_mac, }) } @@ -73,6 +78,11 @@ impl PacketSocket { self.interface_index } + #[must_use] + pub const fn interface_mac(&self) -> MacAddr { + self.interface_mac + } + pub fn send_frame(&self, frame: &[u8]) -> io::Result { let sent = unsafe { // SAFETY: frame.as_ptr() is valid for frame.len() bytes for the duration of send, @@ -129,6 +139,21 @@ impl AsRawFd for PacketSocket { } pub fn interface_index(interface: &str) -> io::Result { + let name = interface_name(interface)?; + + let index = unsafe { + // SAFETY: name is a valid NUL-terminated C string and if_nametoindex does not retain it. + libc::if_nametoindex(name.as_ptr()) + }; + + if index == 0 { + return Err(io::Error::last_os_error()); + } + + Ok(index) +} + +fn interface_name(interface: &str) -> io::Result { if interface.trim().is_empty() { return Err(io::Error::new( io::ErrorKind::InvalidInput, @@ -142,16 +167,68 @@ pub fn interface_index(interface: &str) -> io::Result { "interface name cannot contain NUL bytes", ) })?; - let index = unsafe { - // SAFETY: name is a valid NUL-terminated C string and if_nametoindex does not retain it. - libc::if_nametoindex(name.as_ptr()) - }; + if name.as_bytes_with_nul().len() > libc::IFNAMSIZ { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "interface name is too long", + )); + } - if index == 0 { + Ok(name) +} + +fn interface_hardware_addr(fd: RawFd, interface: &str) -> io::Result { + let name = interface_name(interface)?; + let mut request = unsafe { + // SAFETY: ifreq is a kernel ABI struct; zeroing it gives a valid base before filling the + // interface name and asking ioctl to populate the hardware-address union field. + std::mem::zeroed::() + }; + for (destination, source) in request + .ifr_name + .iter_mut() + .zip(name.as_bytes_with_nul().iter()) + { + *destination = *source as libc::c_char; + } + + let result = unsafe { + // SAFETY: request points to an initialized ifreq with a NUL-terminated interface name. + // ioctl writes the hardware address into the ifreq union and does not retain pointers. + libc::ioctl(fd, libc::SIOCGIFHWADDR, &mut request) + }; + if result < 0 { return Err(io::Error::last_os_error()); } - Ok(index) + let hardware_addr = unsafe { + // SAFETY: SIOCGIFHWADDR succeeded, so reading the hardware-address union field is valid. + request.ifr_ifru.ifru_hwaddr + }; + if hardware_addr.sa_family != libc::ARPHRD_ETHER { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "interface is not an Ethernet device", + )); + } + + let data = hardware_addr.sa_data; + let mac = MacAddr::new([ + data[0] as u8, + data[1] as u8, + data[2] as u8, + data[3] as u8, + data[4] as u8, + data[5] as u8, + ]); + if !mac.is_valid_unicast() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("interface has invalid Ethernet MAC address {mac}"), + )); + } + + Ok(mac) } fn is_inbound_packet_type(packet_type: u8) -> bool { @@ -176,7 +253,7 @@ mod tests { #[test] fn reports_missing_interface() { - let error = interface_index("lanparty-definitely-missing0").unwrap_err(); + let error = interface_index("lp-missing0").unwrap_err(); assert_ne!(error.kind(), io::ErrorKind::InvalidInput); }